stack/packages/stack-cli/src/commands/project.ts
BilalG1 15faf709f3
stack-cli: explicit --cloud-project-id / --config-file across exec, config, project (#1422)
## Summary

Reworks the `stack` CLI surface so the cloud-vs-local choice is
**explicit at every invocation**, removing the global `--project-id` /
`STACK_PROJECT_ID` env var and the local-default `exec` behavior
introduced earlier in this branch.

### `stack exec`
- Removes `--cloud`, `STACK_EXEC_DEFAULT_TARGET`, and the implicit local
default. The CLI now requires **exactly one** of:
  - `--cloud-project-id <id>` — run against the Stack Auth cloud API
- `--config-file <path>` — run against the local emulator project mapped
to that absolute config-file path
- The `--config-file` branch resolves the project id by calling the
existing `GET /api/latest/internal/local-emulator/project` endpoint and
matching `absolute_file_path` client-side. No new backend endpoint
introduced.

### `stack config pull` / `stack config push`
- Both now take `--cloud-project-id <id>` per-command instead of the
global flag / `STACK_PROJECT_ID` env.
- `config pull --config-file` is **optional**: when omitted, the CLI
uses `./stack.config.ts` from the current directory. If neither flag nor
cwd file is present, it exits with a clear hint to pass `--config-file`
or `cd` into a directory containing `stack.config.ts`.

### `stack project list`
- Default (no flags) lists both **cloud and local emulator** projects.
Each entry carries a `target: "cloud" | "dev"` field (text format:
`<id>\t<displayName>\t[<target>]`).
- `--cloud` / `--dev` filter to a single source (mutually exclusive —
passing both errors).
- On the default code path, an unreachable local emulator emits a single
stderr warning (`warning: skipping dev projects — local emulator not
reachable …`) and the command still succeeds with cloud results. With
`--dev` explicit, the unreachable case hard-errors.

### `stack project create`
- Now requires `--cloud` to make the cloud-vs-local choice explicit.
There is no local alternative today; the flag exists to surface the
decision so a future local-project create doesn't silently change
behavior.

### Backend
- Bumps the `LIMIT` on `GET /api/latest/internal/local-emulator/project`
from 20 → 100 so `project list --dev` doesn't silently truncate.

### Refactors (from earlier in this branch, unchanged here)
- Local-emulator paths/ports/PCK polling live in
`packages/stack-cli/src/lib/emulator-paths.ts`.
- Shared local-emulator admin credentials live in
`packages/stack-shared/src/local-emulator.ts`.
- `resolveAuth` / `resolveLocalEmulatorAuth` take an explicit
`projectId: string` (no more `Flags` parameter).
- New `packages/stack-cli/src/lib/local-emulator-client.ts` encapsulates
the GET-and-match flow used by both `exec --config-file` and `project
list --dev`.

## Breaking changes

**Scripts that relied on any of the following must be updated:**

| Removed | Replacement |
| --- | --- |
| Global `--project-id <id>` flag | Per-command `--cloud-project-id
<id>` |
| `STACK_PROJECT_ID` env var | Per-command `--cloud-project-id <id>` |
| `stack exec --cloud` | `stack exec --cloud-project-id <id>` |
| `STACK_EXEC_DEFAULT_TARGET=cloud\|local` | `--cloud-project-id <id>`
or `--config-file <path>` |
| `stack exec` defaulting to local emulator | Explicit `--config-file
<path>` required |
| `stack project create` without a flag | `stack project create --cloud
…` required |

## Test plan
- [x] `pnpm lint` (stack-cli, backend, e2e) — clean
- [x] `pnpm --filter @stackframe/stack-cli typecheck` — clean
- [x] `pnpm --filter @stackframe/stack-cli exec vitest run` — **72/72
passing** (new unit tests: `parseExecTarget`,
`resolveConfigFilePathForPull`, `resolveProjectListSources`,
`formatProjectList`)
- [x] `pnpm test run apps/e2e/tests/general/cli.test.ts` — **73 passing,
4 skipped, 0 failing**. New e2e cases cover:
  - `exec` with neither flag → errors with "Specify a target"
  - `exec` with both flags → errors with "not both"
- `exec --config-file` with missing file / missing PCK / unreachable API
- `exec --config-file` happy path against a real local-emulator backend
(gated on `NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR=true`)
  - `config pull` cwd fallback to `./stack.config.ts`
- `config pull` with no `--config-file` and no cwd `stack.config.ts` →
errors with `Pass --config-file …`
  - `project list --cloud --dev` together → errors
- `project list` default with unreachable emulator → cloud results +
single stderr warning
  - `project create` without `--cloud` → errors
  - All previously-`--cloud` exec cases ported to `--cloud-project-id`
- [x] Manual smoke: `stack exec --help`, `stack project list --cloud
--dev`, `stack project create` all emit the expected friendly errors /
help text.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

## Release Notes

* **New Features**
* CLI `exec`, `config`, and `project` commands now require explicit
targeting via `--cloud-project-id` (cloud) or `--config-file` (local
emulator).
* `project list` now supports `--cloud` and `--dev` flags to display
projects from both sources with target indicators.
* Enhanced environment variable validation for emulator service ports
with proper fallback handling.

* **Bug Fixes**
* `project list` now gracefully handles unreachable emulator with
warning fallback instead of failure.

* **Tests**
* Expanded test coverage for project targeting, config file resolution,
and emulator connectivity scenarios.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-14 17:20:40 -07:00

114 lines
4.2 KiB
TypeScript

import { Command } from "commander";
import { getInternalUser } from "../lib/app.js";
import { resolveLoginConfig, resolveSessionAuth } from "../lib/auth.js";
import { listLocalEmulatorProjects } from "../lib/local-emulator-client.js";
import { createProjectInteractively } from "../lib/create-project.js";
import { CliError } from "../lib/errors.js";
export type ProjectTarget = "cloud" | "dev";
export type ProjectListEntry = {
id: string,
displayName: string,
target: ProjectTarget,
};
export type ProjectListFlags = {
cloud?: boolean,
dev?: boolean,
};
// Returns which sources `project list` should query. Mutually exclusive; with
// no flags we hit both. Exported for unit tests.
export function resolveProjectListSources(opts: ProjectListFlags): { cloud: boolean, dev: boolean } {
if (opts.cloud && opts.dev) {
throw new CliError("Pass either --cloud or --dev, not both. Omit both flags to list projects from both sources.");
}
if (opts.cloud) return { cloud: true, dev: false };
if (opts.dev) return { cloud: false, dev: true };
return { cloud: true, dev: true };
}
// Render projects for the human-readable list output. Each line is
// `<id>\t<displayName>\t[cloud|dev]`. No projects → "No projects found." sentinel.
export function formatProjectList(projects: ProjectListEntry[]): string {
if (projects.length === 0) {
return "No projects found.";
}
return projects.map((p) => `${p.id}\t${p.displayName}\t[${p.target}]`).join("\n");
}
export function registerProjectCommand(program: Command) {
const project = program
.command("project")
.description("Manage projects");
project
.command("list")
.description("List your projects (defaults to both cloud and local emulator)")
.option("--cloud", "Only list cloud projects")
.option("--dev", "Only list local emulator (dev) projects")
.action(async (opts: ProjectListFlags) => {
const sources = resolveProjectListSources(opts);
const results: ProjectListEntry[] = [];
if (sources.cloud) {
const auth = resolveSessionAuth();
const user = await getInternalUser(auth);
const cloudProjects = await user.listOwnedProjects();
for (const p of cloudProjects) {
results.push({ id: p.id, displayName: p.displayName, target: "cloud" });
}
}
if (sources.dev) {
try {
const devProjects = await listLocalEmulatorProjects();
for (const p of devProjects) {
results.push({ id: p.projectId, displayName: p.displayName, target: "dev" });
}
} catch (err) {
// When the user did not explicitly request --dev, treat an unreachable
// emulator as a soft failure: warn on stderr and keep the cloud
// results. With --dev (sources.cloud === false) we surface the error.
if (!sources.cloud) {
throw err;
}
const message = err instanceof Error ? err.message : String(err);
console.error(`warning: skipping dev projects — local emulator not reachable (${message}). Start it with \`stack emulator start\`.`);
}
}
if (program.opts().json) {
console.log(JSON.stringify(results, null, 2));
} else {
console.log(formatProjectList(results));
}
});
project
.command("create")
.description("Create a new cloud project")
.option("--cloud", "Confirm that this creates a cloud (not local emulator) project")
.option("--display-name <name>", "Project display name")
.action(async (opts) => {
if (!opts.cloud) {
throw new CliError("stack project create currently only creates cloud projects. Pass --cloud to confirm.");
}
const auth = resolveSessionAuth();
const user = await getInternalUser(auth);
const { dashboardUrl } = resolveLoginConfig();
const newProject = await createProjectInteractively(user, {
displayName: opts.displayName,
dashboardUrl,
});
if (program.opts().json) {
console.log(JSON.stringify({ id: newProject.id, displayName: newProject.displayName, target: "cloud" }, null, 2));
} else {
console.log(`Project created: ${newProject.id} (${newProject.displayName})`);
}
});
}