From 6acd56151c8635d69e53132985b7bfd157335675 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Fri, 8 May 2026 09:01:46 -0700 Subject: [PATCH 1/4] stack exec: default to local emulator, add --cloud opt-out Local-first dev workflow: `stack exec` now signs in as the emulator admin via the well-known shared credentials and the run-dir PCK, falling back to the cloud only when --cloud is passed (or STACK_EXEC_DEFAULT_TARGET=cloud is set). Also: STACK_EMULATOR_*_PORT env vars take precedence over the legacy unprefixed names; emulator paths/ports/PCK polling extracted to lib/emulator-paths.ts; shared local-emulator admin creds hoisted to stack-shared so backend, dashboard auto-login, and CLI agree. --- apps/backend/src/lib/local-emulator.ts | 4 +- .../app/(main)/(protected)/layout-client.tsx | 5 +- apps/e2e/tests/general/cli.test.ts | 112 +++++++++++-- .../stack-cli/src/commands/emulator.test.ts | 48 +++++- packages/stack-cli/src/commands/emulator.ts | 85 ++++------ packages/stack-cli/src/commands/exec.test.ts | 40 +++++ packages/stack-cli/src/commands/exec.ts | 36 ++++- packages/stack-cli/src/commands/init.ts | 20 +-- packages/stack-cli/src/commands/login.ts | 3 +- packages/stack-cli/src/commands/project.ts | 6 +- packages/stack-cli/src/lib/app.ts | 3 +- packages/stack-cli/src/lib/auth.test.ts | 71 +++++++++ packages/stack-cli/src/lib/auth.ts | 150 +++++++++++++++++- packages/stack-cli/src/lib/config.ts | 2 +- .../stack-cli/src/lib/emulator-paths.test.ts | 59 +++++++ packages/stack-cli/src/lib/emulator-paths.ts | 93 +++++++++++ packages/stack-shared/src/local-emulator.ts | 5 + 17 files changed, 635 insertions(+), 107 deletions(-) create mode 100644 packages/stack-cli/src/commands/exec.test.ts create mode 100644 packages/stack-cli/src/lib/auth.test.ts create mode 100644 packages/stack-cli/src/lib/emulator-paths.test.ts create mode 100644 packages/stack-cli/src/lib/emulator-paths.ts create mode 100644 packages/stack-shared/src/local-emulator.ts diff --git a/apps/backend/src/lib/local-emulator.ts b/apps/backend/src/lib/local-emulator.ts index 841045afa0..fa3ba464cd 100644 --- a/apps/backend/src/lib/local-emulator.ts +++ b/apps/backend/src/lib/local-emulator.ts @@ -1,6 +1,7 @@ import { globalPrismaClient } from "@/prisma-client"; import { detectImportPackageFromDir, renderConfigFileContent } from "@stackframe/stack-shared/dist/config-rendering"; import { isValidConfig } from "@stackframe/stack-shared/dist/config/format"; +import { LOCAL_EMULATOR_ADMIN_EMAIL, LOCAL_EMULATOR_ADMIN_PASSWORD } from "@stackframe/stack-shared/dist/local-emulator"; import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; import fs from "fs/promises"; @@ -9,8 +10,7 @@ import path from "path"; export const LOCAL_EMULATOR_ADMIN_USER_ID = "63abbc96-5329-454a-ba56-e0460173c6c1"; export const LOCAL_EMULATOR_OWNER_TEAM_ID = "5a0c858b-d9e9-49d4-9943-8ce385d86428"; -export const LOCAL_EMULATOR_ADMIN_EMAIL = "local-emulator@stack-auth.com"; -export const LOCAL_EMULATOR_ADMIN_PASSWORD = "LocalEmulatorPassword"; +export { LOCAL_EMULATOR_ADMIN_EMAIL, LOCAL_EMULATOR_ADMIN_PASSWORD }; export const LOCAL_EMULATOR_ENV_CONFIG_BLOCKED_MESSAGE = "Environment configuration overrides cannot be changed in the local emulator. Update this in your production deployment instead."; diff --git a/apps/dashboard/src/app/(main)/(protected)/layout-client.tsx b/apps/dashboard/src/app/(main)/(protected)/layout-client.tsx index f86c3b50bd..005bee0738 100644 --- a/apps/dashboard/src/app/(main)/(protected)/layout-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/layout-client.tsx @@ -5,6 +5,7 @@ import { CursorBlastEffect } from "@stackframe/dashboard-ui-components"; import { ConfigUpdateDialogProvider } from "@/lib/config-update"; import { getPublicEnvVar } from '@/lib/env'; import { useStackApp, useUser } from "@stackframe/stack"; +import { LOCAL_EMULATOR_ADMIN_EMAIL, LOCAL_EMULATOR_ADMIN_PASSWORD } from "@stackframe/stack-shared/dist/local-emulator"; import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids"; import { useEffect } from "react"; @@ -20,8 +21,8 @@ export default function LayoutClient({ children }: { children: React.ReactNode } if (user) return; if (isLocalEmulator) { await app.signInWithCredential({ - email: "local-emulator@stack-auth.com", - password: "LocalEmulatorPassword", + email: LOCAL_EMULATOR_ADMIN_EMAIL, + password: LOCAL_EMULATOR_ADMIN_PASSWORD, }); } else if (isPreview) { const id = generateUuid(); diff --git a/apps/e2e/tests/general/cli.test.ts b/apps/e2e/tests/general/cli.test.ts index a922ed6f7b..b6a3e51fe8 100644 --- a/apps/e2e/tests/general/cli.test.ts +++ b/apps/e2e/tests/general/cli.test.ts @@ -7,6 +7,8 @@ import { Result } from "@stackframe/stack-shared/dist/utils/results"; import { describe, beforeAll, afterAll } from "vitest"; import { it, niceFetch, STACK_BACKEND_BASE_URL, STACK_INTERNAL_PROJECT_CLIENT_KEY, STACK_INTERNAL_PROJECT_SERVER_KEY, STACK_INTERNAL_PROJECT_ADMIN_KEY } from "../helpers"; +const isLocalEmulator = process.env.NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR === "true"; + const CLI_BIN = path.resolve("packages/stack-cli/dist/index.js"); const CLI_SRC_BIN = path.resolve("packages/stack-cli/src/index.ts"); @@ -134,6 +136,9 @@ describe("Stack CLI", () => { }); it("errors when no project ID given", async ({ expect }) => { + // Exercise the default (local) path: project-ID resolution happens before + // any emulator I/O, so the missing-ID error fires regardless of whether + // an emulator is running. const { stderr, exitCode } = await runCli(["exec", "return 1"]); expect(exitCode).toBe(1); expect(stderr).toContain("No project ID"); @@ -183,7 +188,7 @@ describe("Stack CLI", () => { it("returns basic expression", async ({ expect }) => { expect(createdProjectId).toBeDefined(); const { stdout, exitCode } = await runCli( - ["exec", "return 1+1"], + ["exec", "--cloud", "return 1+1"], { STACK_PROJECT_ID: createdProjectId }, ); expect(exitCode).toBe(0); @@ -192,7 +197,7 @@ describe("Stack CLI", () => { it("has stackServerApp object available", async ({ expect }) => { const { stdout, exitCode } = await runCli( - ["exec", "return typeof stackServerApp"], + ["exec", "--cloud", "return typeof stackServerApp"], { STACK_PROJECT_ID: createdProjectId }, ); expect(exitCode).toBe(0); @@ -205,15 +210,21 @@ describe("Stack CLI", () => { expect(stdout).toContain("https://docs.stack-auth.com/docs/sdk"); }); + it("exec help mentions --cloud option", async ({ expect }) => { + const { stdout, exitCode } = await runCli(["exec", "--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("--cloud"); + }); + it("errors when no javascript is provided", async ({ expect }) => { - const { stderr, exitCode } = await runCli(["exec"], { STACK_PROJECT_ID: createdProjectId }); + const { stderr, exitCode } = await runCli(["exec", "--cloud"], { STACK_PROJECT_ID: createdProjectId }); expect(exitCode).toBe(1); expect(stderr).toContain("Missing JavaScript argument"); }); it("reports syntax error", async ({ expect }) => { const { stderr, exitCode } = await runCli( - ["exec", "return @@invalid"], + ["exec", "--cloud", "return @@invalid"], { STACK_PROJECT_ID: createdProjectId }, ); expect(exitCode).toBe(1); @@ -222,7 +233,7 @@ describe("Stack CLI", () => { it("reports runtime error", async ({ expect }) => { const { stderr, exitCode } = await runCli( - ["exec", "throw new Error('boom')"], + ["exec", "--cloud", "throw new Error('boom')"], { STACK_PROJECT_ID: createdProjectId }, ); expect(exitCode).toBe(1); @@ -231,7 +242,7 @@ describe("Stack CLI", () => { it("reports string runtime error", async ({ expect }) => { const { stderr, exitCode } = await runCli( - ["exec", "throw 'boom-string'"], + ["exec", "--cloud", "throw 'boom-string'"], { STACK_PROJECT_ID: createdProjectId }, ); expect(exitCode).toBe(1); @@ -240,7 +251,7 @@ describe("Stack CLI", () => { it("reports object runtime error", async ({ expect }) => { const { stderr, exitCode } = await runCli( - ["exec", "throw { code: 123 }"], + ["exec", "--cloud", "throw { code: 123 }"], { STACK_PROJECT_ID: createdProjectId }, ); expect(exitCode).toBe(1); @@ -249,7 +260,7 @@ describe("Stack CLI", () => { it("reports undefined variable", async ({ expect }) => { const { stderr, exitCode } = await runCli( - ["exec", "return nonExistentVar"], + ["exec", "--cloud", "return nonExistentVar"], { STACK_PROJECT_ID: createdProjectId }, ); expect(exitCode).toBe(1); @@ -258,7 +269,7 @@ describe("Stack CLI", () => { it("returns undefined for no return value", async ({ expect }) => { const { stdout, exitCode } = await runCli( - ["exec", "const x = 1"], + ["exec", "--cloud", "const x = 1"], { STACK_PROJECT_ID: createdProjectId }, ); expect(exitCode).toBe(0); @@ -267,7 +278,7 @@ describe("Stack CLI", () => { it("returns complex object as JSON", async ({ expect }) => { const { stdout, exitCode } = await runCli( - ["exec", "return {a: 1, b: [2, 3]}"], + ["exec", "--cloud", "return {a: 1, b: [2, 3]}"], { STACK_PROJECT_ID: createdProjectId }, ); expect(exitCode).toBe(0); @@ -277,7 +288,7 @@ describe("Stack CLI", () => { it("supports async code", async ({ expect }) => { const { stdout, exitCode } = await runCli( - ["exec", "return await Promise.resolve(42)"], + ["exec", "--cloud", "return await Promise.resolve(42)"], { STACK_PROJECT_ID: createdProjectId }, ); expect(exitCode).toBe(0); @@ -290,7 +301,7 @@ describe("Stack CLI", () => { createdUserEmail = `exec-test-${crypto.randomUUID()}@stack-generated.example.com`; const code = `const u = await stackServerApp.createUser({ primaryEmail: "${createdUserEmail}", password: "test123456" }); return { id: u.id, email: u.primaryEmail }`; const { stdout, exitCode } = await runCli( - ["exec", code], + ["exec", "--cloud", code], { STACK_PROJECT_ID: createdProjectId }, ); expect(exitCode).toBe(0); @@ -303,7 +314,7 @@ describe("Stack CLI", () => { expect(createdProjectId).toBeDefined(); expect(createdUserEmail).toBeDefined(); const { stdout, exitCode } = await runCli( - ["exec", "const users = await stackServerApp.listUsers(); return users.length"], + ["exec", "--cloud", "const users = await stackServerApp.listUsers(); return users.length"], { STACK_PROJECT_ID: createdProjectId }, ); expect(exitCode).toBe(0); @@ -311,6 +322,81 @@ describe("Stack CLI", () => { expect(count).toBeGreaterThanOrEqual(1); }); + it("local-default exec errors when emulator PCK file is missing", async ({ expect }) => { + // Without --cloud, exec defaults to the local emulator. With + // STACK_EMULATOR_HOME pointed at an empty dir, the PCK file lookup fires + // before any network call and we get a clear error. Setting + // STACK_EMULATOR_READY_TIMEOUT_MS=0 disables the boot-race polling window + // so this test fails fast. + const fakeEmulatorHome = fs.mkdtempSync(path.join(os.tmpdir(), "stack-cli-fake-emulator-")); + try { + const { stderr, exitCode } = await runCli( + ["exec", "return 1"], + { + STACK_PROJECT_ID: createdProjectId, + STACK_EMULATOR_HOME: fakeEmulatorHome, + STACK_EMULATOR_READY_TIMEOUT_MS: "0", + }, + ); + expect(exitCode).toBe(1); + expect(stderr).toContain("Local emulator publishable client key not found"); + } finally { + fs.rmSync(fakeEmulatorHome, { recursive: true }); + } + }); + + it("local-default exec errors when emulator API is unreachable", async ({ expect }) => { + // PCK file present (so we get past the file check) but STACK_EMULATOR_API_URL + // points at a port nothing is listening on — fetch fails with a clear error. + // STACK_EMULATOR_READY_TIMEOUT_MS=0 keeps the retry loop from waiting. + const fakeEmulatorHome = fs.mkdtempSync(path.join(os.tmpdir(), "stack-cli-fake-emulator-")); + try { + const pckDir = path.join(fakeEmulatorHome, "run", "vm"); + fs.mkdirSync(pckDir, { recursive: true }); + fs.writeFileSync(path.join(pckDir, "internal-pck"), "pck_stub_for_test"); + const { stderr, exitCode } = await runCli( + ["exec", "return 1"], + { + STACK_PROJECT_ID: createdProjectId, + STACK_EMULATOR_HOME: fakeEmulatorHome, + STACK_EMULATOR_API_URL: "http://127.0.0.1:1", + STACK_EMULATOR_READY_TIMEOUT_MS: "0", + }, + ); + expect(exitCode).toBe(1); + expect(stderr).toContain("Cannot reach local emulator"); + } finally { + fs.rmSync(fakeEmulatorHome, { recursive: true }); + } + }); + + // Positive happy-path: only runs when the backend is in local-emulator mode + // (the password sign-in for local-emulator@stack-auth.com only succeeds + // there). Stages a STACK_EMULATOR_HOME with the real internal PCK and + // points STACK_EMULATOR_API_URL at the running backend, so the CLI takes + // the local-default path and signs in as the emulator admin. + it.runIf(isLocalEmulator)("local-default exec runs against the local emulator backend", async ({ expect }) => { + expect(createdProjectId).toBeDefined(); + const fakeEmulatorHome = fs.mkdtempSync(path.join(os.tmpdir(), "stack-cli-emu-positive-")); + try { + const pckDir = path.join(fakeEmulatorHome, "run", "vm"); + fs.mkdirSync(pckDir, { recursive: true }); + fs.writeFileSync(path.join(pckDir, "internal-pck"), STACK_INTERNAL_PROJECT_CLIENT_KEY); + const { stdout, exitCode } = await runCli( + ["exec", "return 1+1"], + { + STACK_PROJECT_ID: createdProjectId, + STACK_EMULATOR_HOME: fakeEmulatorHome, + STACK_EMULATOR_API_URL: STACK_BACKEND_BASE_URL, + }, + ); + expect(exitCode).toBe(0); + expect(stdout.trim()).toBe("2"); + } finally { + fs.rmSync(fakeEmulatorHome, { recursive: true }); + } + }); + let configTsPath: string; it("config pull writes a .ts file", async ({ expect }) => { diff --git a/packages/stack-cli/src/commands/emulator.test.ts b/packages/stack-cli/src/commands/emulator.test.ts index 9cbe9caa16..623dcb2b50 100644 --- a/packages/stack-cli/src/commands/emulator.test.ts +++ b/packages/stack-cli/src/commands/emulator.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { emulatorBackendPort, emulatorDashboardPort, envPort } from "../lib/emulator-paths.js"; import { - envPort, formatBytes, formatDuration, platformInstallHint, @@ -128,6 +128,52 @@ describe("envPort", () => { }); }); +describe("emulator port resolution (STACK_ prefix + legacy alias)", () => { + const PORT_VARS = [ + "STACK_EMULATOR_BACKEND_PORT", + "EMULATOR_BACKEND_PORT", + "STACK_EMULATOR_DASHBOARD_PORT", + "EMULATOR_DASHBOARD_PORT", + ] as const; + const SAVED: Record = {}; + beforeEach(() => { + for (const v of PORT_VARS) { + SAVED[v] = process.env[v]; + delete process.env[v]; + } + }); + afterEach(() => { + for (const v of PORT_VARS) { + if (SAVED[v] === undefined) delete process.env[v]; + else process.env[v] = SAVED[v]; + } + }); + + it("uses default ports when neither alias is set", () => { + expect(emulatorBackendPort()).toBe(26701); + expect(emulatorDashboardPort()).toBe(26700); + }); + + it("prefers STACK_ prefix over the unprefixed legacy alias", () => { + process.env.STACK_EMULATOR_BACKEND_PORT = "30001"; + process.env.EMULATOR_BACKEND_PORT = "40001"; + expect(emulatorBackendPort()).toBe(30001); + }); + + it("falls back to the unprefixed legacy alias when STACK_ prefix is unset", () => { + process.env.EMULATOR_BACKEND_PORT = "40002"; + expect(emulatorBackendPort()).toBe(40002); + }); + + it("validates the alias that is actually used", () => { + process.env.STACK_EMULATOR_BACKEND_PORT = "not-a-number"; + expect(() => emulatorBackendPort()).toThrow(/Invalid STACK_EMULATOR_BACKEND_PORT/); + delete process.env.STACK_EMULATOR_BACKEND_PORT; + process.env.EMULATOR_BACKEND_PORT = "not-a-number"; + expect(() => emulatorBackendPort()).toThrow(/Invalid EMULATOR_BACKEND_PORT/); + }); +}); + describe("resolveArch", () => { it("accepts explicit arm64 / amd64", () => { expect(resolveArch("arm64")).toBe("arm64"); diff --git a/packages/stack-cli/src/commands/emulator.ts b/packages/stack-cli/src/commands/emulator.ts index 53ed406233..66fc70dc07 100644 --- a/packages/stack-cli/src/commands/emulator.ts +++ b/packages/stack-cli/src/commands/emulator.ts @@ -2,20 +2,25 @@ import { Command } from "commander"; import { execFileSync, execSync, spawn } from "child_process"; import extract from "extract-zip"; import { chmodSync, createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync } from "fs"; -import { homedir } from "os"; import { dirname, join, resolve } from "path"; import { createInterface } from "readline"; import { Readable } from "stream"; import { pipeline } from "stream/promises"; import { fileURLToPath } from "url"; +import { + emulatorBackendPort, + emulatorDashboardPort, + emulatorImageDir, + emulatorInbucketPort, + emulatorMinioPort, + emulatorMockOAuthPort, + emulatorRunDir, + internalPckPath, + pollInternalPck, +} from "../lib/emulator-paths.js"; import { CliError } from "../lib/errors.js"; import { writeIso } from "../lib/iso.js"; -const DEFAULT_EMULATOR_BACKEND_PORT = 26701; -const DEFAULT_EMULATOR_DASHBOARD_PORT = 26700; -const DEFAULT_EMULATOR_MINIO_PORT = 26702; -const DEFAULT_EMULATOR_INBUCKET_PORT = 26703; -const DEFAULT_EMULATOR_MOCK_OAUTH_PORT = 26704; const DEFAULT_PORT_PREFIX = "81"; const GITHUB_API = "https://api.github.com"; const DEFAULT_REPO = "stack-auth/stack-auth"; @@ -26,55 +31,12 @@ const AARCH64_FIRMWARE_PATHS = [ "/usr/share/qemu-efi-aarch64/QEMU_EFI.fd", ]; -export function envPort(name: string, fallback: number): number { - const raw = process.env[name]; - if (!raw) return fallback; - const parsed = Number(raw); - if (!Number.isInteger(parsed) || parsed <= 0) { - throw new CliError(`Invalid ${name}: ${raw}`); - } - return parsed; -} - -function emulatorDashboardPort(): number { - return envPort("EMULATOR_DASHBOARD_PORT", DEFAULT_EMULATOR_DASHBOARD_PORT); -} - -function emulatorBackendPort(): number { - return envPort("EMULATOR_BACKEND_PORT", DEFAULT_EMULATOR_BACKEND_PORT); -} - -function emulatorHome(): string { - return process.env.STACK_EMULATOR_HOME ?? join(homedir(), ".stack", "emulator"); -} - -function emulatorRunDir(): string { - return join(emulatorHome(), "run"); -} - -function emulatorImageDir(): string { - return join(emulatorHome(), "images"); -} - -function internalPckPath(): string { - return join(emulatorRunDir(), "vm", "internal-pck"); -} - async function readInternalPck(timeoutMs = 60_000): Promise { - const path = internalPckPath(); - const deadline = Date.now() + timeoutMs; - let delay = 50; - while (Date.now() < deadline) { - try { - const contents = readFileSync(path, "utf-8").trim(); - if (contents) return contents; - } catch (e) { - if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e; - } - await new Promise((r) => setTimeout(r, delay)); - delay = Math.min(delay * 2, 2000); + const contents = await pollInternalPck(timeoutMs); + if (contents === null) { + throw new CliError(`Timed out waiting for emulator internal publishable client key at ${internalPckPath()}`); } - throw new CliError(`Timed out waiting for emulator internal publishable client key at ${path}`); + return contents; } type EmulatorCredentials = { @@ -229,10 +191,17 @@ function baseEnvPath(): string { } function emulatorSpawnEnv(extra?: Record): NodeJS.ProcessEnv { + // run-emulator.sh only reads the unprefixed EMULATOR_*_PORT names, so forward + // the resolved values whether they came from the STACK_-prefixed alias or not. return { ...process.env, EMULATOR_RUN_DIR: emulatorRunDir(), EMULATOR_IMAGE_DIR: emulatorImageDir(), + EMULATOR_BACKEND_PORT: String(emulatorBackendPort()), + EMULATOR_DASHBOARD_PORT: String(emulatorDashboardPort()), + EMULATOR_MINIO_PORT: String(emulatorMinioPort()), + EMULATOR_INBUCKET_PORT: String(emulatorInbucketPort()), + EMULATOR_MOCK_OAUTH_PORT: String(emulatorMockOAuthPort()), ...extra, }; } @@ -243,11 +212,11 @@ function prepareRuntimeConfigIso(): void { const vmDir = join(emulatorRunDir(), "vm"); mkdirSync(vmDir, { recursive: true }); const portPrefix = process.env.PORT_PREFIX ?? process.env.NEXT_PUBLIC_STACK_PORT_PREFIX ?? DEFAULT_PORT_PREFIX; - const dashboardPort = envPort("EMULATOR_DASHBOARD_PORT", DEFAULT_EMULATOR_DASHBOARD_PORT); - const backendPort = envPort("EMULATOR_BACKEND_PORT", DEFAULT_EMULATOR_BACKEND_PORT); - const minioPort = envPort("EMULATOR_MINIO_PORT", DEFAULT_EMULATOR_MINIO_PORT); - const inbucketPort = envPort("EMULATOR_INBUCKET_PORT", DEFAULT_EMULATOR_INBUCKET_PORT); - const mockOAuthPort = envPort("EMULATOR_MOCK_OAUTH_PORT", DEFAULT_EMULATOR_MOCK_OAUTH_PORT); + const dashboardPort = emulatorDashboardPort(); + const backendPort = emulatorBackendPort(); + const minioPort = emulatorMinioPort(); + const inbucketPort = emulatorInbucketPort(); + const mockOAuthPort = emulatorMockOAuthPort(); const runtimeEnv = [ `STACK_EMULATOR_PORT_PREFIX=${portPrefix}`, diff --git a/packages/stack-cli/src/commands/exec.test.ts b/packages/stack-cli/src/commands/exec.test.ts new file mode 100644 index 0000000000..a97c3e5e8c --- /dev/null +++ b/packages/stack-cli/src/commands/exec.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { resolveExecTarget } from "./exec.js"; + +describe("resolveExecTarget", () => { + it("defaults to local when --cloud is not passed and the env var is unset", () => { + expect(resolveExecTarget({}, {})).toBe("local"); + }); + + it("treats an empty STACK_EXEC_DEFAULT_TARGET as unset", () => { + expect(resolveExecTarget({}, { STACK_EXEC_DEFAULT_TARGET: "" })).toBe("local"); + }); + + it("respects STACK_EXEC_DEFAULT_TARGET=cloud", () => { + expect(resolveExecTarget({}, { STACK_EXEC_DEFAULT_TARGET: "cloud" })).toBe("cloud"); + }); + + it("respects STACK_EXEC_DEFAULT_TARGET=local explicitly", () => { + expect(resolveExecTarget({}, { STACK_EXEC_DEFAULT_TARGET: "local" })).toBe("local"); + }); + + it("--cloud wins even when STACK_EXEC_DEFAULT_TARGET=local", () => { + expect(resolveExecTarget({ cloud: true }, { STACK_EXEC_DEFAULT_TARGET: "local" })).toBe("cloud"); + }); + + it("--cloud wins when the env var is unset", () => { + expect(resolveExecTarget({ cloud: true }, {})).toBe("cloud"); + }); + + it("rejects unknown STACK_EXEC_DEFAULT_TARGET values", () => { + expect(() => resolveExecTarget({}, { STACK_EXEC_DEFAULT_TARGET: "Cloud" })).toThrow(/Invalid STACK_EXEC_DEFAULT_TARGET/); + expect(() => resolveExecTarget({}, { STACK_EXEC_DEFAULT_TARGET: "remote" })).toThrow(/Invalid STACK_EXEC_DEFAULT_TARGET/); + expect(() => resolveExecTarget({}, { STACK_EXEC_DEFAULT_TARGET: "1" })).toThrow(/Invalid STACK_EXEC_DEFAULT_TARGET/); + }); + + it("does not validate the env var when --cloud short-circuits", () => { + // --cloud is explicit, so we don't bother surfacing a typo in the env var. + // This is intentional: an invalid value shouldn't block the explicit flag. + expect(resolveExecTarget({ cloud: true }, { STACK_EXEC_DEFAULT_TARGET: "garbage" })).toBe("cloud"); + }); +}); diff --git a/packages/stack-cli/src/commands/exec.ts b/packages/stack-cli/src/commands/exec.ts index b09c9ae9c0..dc106631a6 100644 --- a/packages/stack-cli/src/commands/exec.ts +++ b/packages/stack-cli/src/commands/exec.ts @@ -1,5 +1,5 @@ import { Command } from "commander"; -import { isProjectAuthWithRefreshToken, resolveAuth } from "../lib/auth.js"; +import { isProjectAuthWithRefreshToken, resolveAuth, resolveLocalEmulatorAuth, type ProjectAuthWithRefreshToken } from "../lib/auth.js"; import { getAdminProject } from "../lib/app.js"; import { CliError } from "../lib/errors.js"; @@ -17,20 +17,44 @@ function getErrorMessage(err: unknown): string { } } +export type ExecTarget = "cloud" | "local"; + +// Decide whether `stack exec` should target the cloud API or the local emulator. +// `--cloud` always wins. Otherwise STACK_EXEC_DEFAULT_TARGET picks the default +// (local if unset). Anything other than "cloud" or "local" is rejected so a +// typo doesn't silently fall back to one branch. +export function resolveExecTarget(opts: { cloud?: boolean }, env: NodeJS.ProcessEnv): ExecTarget { + if (opts.cloud) return "cloud"; + const raw = env.STACK_EXEC_DEFAULT_TARGET; + if (raw === undefined || raw === "") return "local"; + if (raw !== "cloud" && raw !== "local") { + throw new CliError(`Invalid STACK_EXEC_DEFAULT_TARGET: ${raw}. Must be 'cloud' or 'local'.`); + } + return raw; +} + export function registerExecCommand(program: Command) { program .command("exec [javascript]") - .description("Execute JavaScript with a pre-configured StackServerApp as `stackServerApp`") + .description("Execute JavaScript with a pre-configured StackServerApp as `stackServerApp`. Defaults to the local emulator; pass --cloud to target the Stack Auth cloud API.") + .option("--cloud", "Run against the Stack Auth cloud API instead of the local emulator") .addHelpText("after", "\nFor available API methods, see: https://docs.stack-auth.com/docs/sdk") - .action(async (javascript: string | undefined) => { + .action(async (javascript: string | undefined, opts: { cloud?: boolean }) => { if (javascript === undefined) { throw new CliError("Missing JavaScript argument. Use `stack exec \"\"` or `stack exec --help`."); } const flags = program.opts(); - const auth = resolveAuth(flags); - if (!isProjectAuthWithRefreshToken(auth)) { - throw new CliError("`stack exec` requires `stack login`. Remove STACK_SECRET_SERVER_KEY and try again."); + const target = resolveExecTarget(opts, process.env); + let auth: ProjectAuthWithRefreshToken; + if (target === "cloud") { + const cloudAuth = resolveAuth(flags); + if (!isProjectAuthWithRefreshToken(cloudAuth)) { + throw new CliError("`stack exec --cloud` requires `stack login`. Remove STACK_SECRET_SERVER_KEY and try again."); + } + auth = cloudAuth; + } else { + auth = await resolveLocalEmulatorAuth(flags); } const project = await getAdminProject(auth); diff --git a/packages/stack-cli/src/commands/init.ts b/packages/stack-cli/src/commands/init.ts index 9053308566..66eae92cfa 100644 --- a/packages/stack-cli/src/commands/init.ts +++ b/packages/stack-cli/src/commands/init.ts @@ -204,17 +204,17 @@ async function handleLinkFromConfigFile(opts: InitOptions): Promise<{ configPath return { configPath }; } -async function ensureLoggedInSession(flags: Record) { +async function ensureLoggedInSession() { try { - return resolveSessionAuth(flags as { projectId?: string }); + return resolveSessionAuth(); } catch (e) { if (e instanceof AuthError) { if (isNonInteractiveEnv()) { throw new CliError("Not logged in. Run `stack login` first or set STACK_CLI_REFRESH_TOKEN."); } console.log("You need to log in first.\n"); - await performLogin(flags); - return resolveSessionAuth(flags as { projectId?: string }); + await performLogin(); + return resolveSessionAuth(); } throw e; } @@ -271,8 +271,8 @@ async function writeProjectKeysToEnv( } } -async function handleCreateCloud(flags: Record, opts: InitOptions, outputDir: string): Promise<{ configPath?: string }> { - const sessionAuth = await ensureLoggedInSession(flags); +async function handleCreateCloud(_flags: Record, opts: InitOptions, outputDir: string): Promise<{ configPath?: string }> { + const sessionAuth = await ensureLoggedInSession(); const user = await getInternalUser(sessionAuth); const newProject = await createProjectInteractively(user, { @@ -284,8 +284,8 @@ async function handleCreateCloud(flags: Record, opts: InitOptio return {}; } -async function handleLinkFromCloud(flags: Record, opts: InitOptions, outputDir: string): Promise<{ configPath?: string }> { - const sessionAuth = await ensureLoggedInSession(flags); +async function handleLinkFromCloud(_flags: Record, opts: InitOptions, outputDir: string): Promise<{ configPath?: string }> { + const sessionAuth = await ensureLoggedInSession(); const user = await getInternalUser(sessionAuth); let projects = await user.listOwnedProjects(); let autoCreatedProjectId: string | null = null; @@ -336,8 +336,8 @@ async function handleLinkFromCloud(flags: Record, opts: InitOpt return {}; } -async function performLogin(flags: Record) { - const config = resolveLoginConfig(flags as { projectId?: string }); +async function performLogin() { + const config = resolveLoginConfig(); const app = new StackClientApp({ projectId: "internal", diff --git a/packages/stack-cli/src/commands/login.ts b/packages/stack-cli/src/commands/login.ts index 7be093b08e..b8864c2fdf 100644 --- a/packages/stack-cli/src/commands/login.ts +++ b/packages/stack-cli/src/commands/login.ts @@ -11,8 +11,7 @@ export function registerLoginCommand(program: Command) { "Log in to Stack Auth via browser. To attach this login to an existing anonymous session, set STACK_CLI_ANON_REFRESH_TOKEN (env var) or the same key in the CLI credentials file before running; login does not write that value.", ) .action(async () => { - const flags = program.opts(); - const config = resolveLoginConfig(flags); + const config = resolveLoginConfig(); const app = new StackClientApp({ projectId: "internal", diff --git a/packages/stack-cli/src/commands/project.ts b/packages/stack-cli/src/commands/project.ts index 2b1f49ff16..8ff761526f 100644 --- a/packages/stack-cli/src/commands/project.ts +++ b/packages/stack-cli/src/commands/project.ts @@ -12,8 +12,7 @@ export function registerProjectCommand(program: Command) { .command("list") .description("List your owned projects") .action(async () => { - const flags = program.opts(); - const auth = resolveSessionAuth(flags); + const auth = resolveSessionAuth(); const user = await getInternalUser(auth); const projects = await user.listOwnedProjects(); @@ -35,8 +34,7 @@ export function registerProjectCommand(program: Command) { .description("Create a new project") .option("--display-name ", "Project display name") .action(async (opts) => { - const flags = program.opts(); - const auth = resolveSessionAuth(flags); + const auth = resolveSessionAuth(); const user = await getInternalUser(auth); const newProject = await createProjectInteractively(user, { diff --git a/packages/stack-cli/src/lib/app.ts b/packages/stack-cli/src/lib/app.ts index 0ce8f9e2b4..499aac59a5 100644 --- a/packages/stack-cli/src/lib/app.ts +++ b/packages/stack-cli/src/lib/app.ts @@ -1,13 +1,12 @@ import { StackClientApp } from "@stackframe/js"; import type { CurrentInternalUser, AdminOwnedProject } from "@stackframe/js"; import { AuthError } from "./errors.js"; -import { DEFAULT_PUBLISHABLE_CLIENT_KEY } from "./auth.js"; import type { SessionAuth, ProjectAuthWithRefreshToken } from "./auth.js"; export function getInternalApp(auth: SessionAuth): StackClientApp { return new StackClientApp({ projectId: "internal", - publishableClientKey: DEFAULT_PUBLISHABLE_CLIENT_KEY, + publishableClientKey: auth.publishableClientKey, baseUrl: auth.apiUrl, tokenStore: { accessToken: "", diff --git a/packages/stack-cli/src/lib/auth.test.ts b/packages/stack-cli/src/lib/auth.test.ts new file mode 100644 index 0000000000..8feb5a9ede --- /dev/null +++ b/packages/stack-cli/src/lib/auth.test.ts @@ -0,0 +1,71 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { isRetryableFetchError, localEmulatorReadyTimeoutMs } from "./auth.js"; + +describe("isRetryableFetchError", () => { + it("retries TypeError (Node fetch wraps connection errors as TypeError)", () => { + expect(isRetryableFetchError(new TypeError("fetch failed"))).toBe(true); + }); + + it("retries AbortError and TimeoutError (per-request signal fired)", () => { + const abort = new Error("aborted"); + abort.name = "AbortError"; + expect(isRetryableFetchError(abort)).toBe(true); + const timeout = new Error("timed out"); + timeout.name = "TimeoutError"; + expect(isRetryableFetchError(timeout)).toBe(true); + }); + + it("retries ECONNREFUSED / ENOTFOUND / ETIMEDOUT / ECONNRESET messages", () => { + expect(isRetryableFetchError(new Error("connect ECONNREFUSED 127.0.0.1:1"))).toBe(true); + expect(isRetryableFetchError(new Error("getaddrinfo ENOTFOUND foo"))).toBe(true); + expect(isRetryableFetchError(new Error("ETIMEDOUT"))).toBe(true); + expect(isRetryableFetchError(new Error("read ECONNRESET"))).toBe(true); + }); + + it("retries non-Error throws (defensive: unknown shape, give it another go)", () => { + expect(isRetryableFetchError("string")).toBe(true); + expect(isRetryableFetchError(undefined)).toBe(true); + expect(isRetryableFetchError({ weird: true })).toBe(true); + }); + + it("does not retry generic Errors that aren't transport-shaped", () => { + expect(isRetryableFetchError(new Error("something else broke"))).toBe(false); + expect(isRetryableFetchError(new SyntaxError("bad json"))).toBe(false); + }); +}); + +describe("localEmulatorReadyTimeoutMs", () => { + const SAVED = process.env.STACK_EMULATOR_READY_TIMEOUT_MS; + beforeEach(() => { + delete process.env.STACK_EMULATOR_READY_TIMEOUT_MS; + }); + afterEach(() => { + if (SAVED === undefined) delete process.env.STACK_EMULATOR_READY_TIMEOUT_MS; + else process.env.STACK_EMULATOR_READY_TIMEOUT_MS = SAVED; + }); + + it("returns the default when the env var is unset", () => { + expect(localEmulatorReadyTimeoutMs()).toBe(10_000); + }); + + it("treats empty string as unset", () => { + process.env.STACK_EMULATOR_READY_TIMEOUT_MS = ""; + expect(localEmulatorReadyTimeoutMs()).toBe(10_000); + }); + + it("parses a valid non-negative integer (including 0 for fail-fast)", () => { + process.env.STACK_EMULATOR_READY_TIMEOUT_MS = "0"; + expect(localEmulatorReadyTimeoutMs()).toBe(0); + process.env.STACK_EMULATOR_READY_TIMEOUT_MS = "2500"; + expect(localEmulatorReadyTimeoutMs()).toBe(2500); + }); + + it("rejects negative, non-integer, and non-numeric values", () => { + process.env.STACK_EMULATOR_READY_TIMEOUT_MS = "-1"; + expect(() => localEmulatorReadyTimeoutMs()).toThrow(/Invalid STACK_EMULATOR_READY_TIMEOUT_MS/); + process.env.STACK_EMULATOR_READY_TIMEOUT_MS = "1.5"; + expect(() => localEmulatorReadyTimeoutMs()).toThrow(/Invalid STACK_EMULATOR_READY_TIMEOUT_MS/); + process.env.STACK_EMULATOR_READY_TIMEOUT_MS = "abc"; + expect(() => localEmulatorReadyTimeoutMs()).toThrow(/Invalid STACK_EMULATOR_READY_TIMEOUT_MS/); + }); +}); diff --git a/packages/stack-cli/src/lib/auth.ts b/packages/stack-cli/src/lib/auth.ts index a2d2cc081b..645255080a 100644 --- a/packages/stack-cli/src/lib/auth.ts +++ b/packages/stack-cli/src/lib/auth.ts @@ -1,5 +1,7 @@ +import { LOCAL_EMULATOR_ADMIN_EMAIL, LOCAL_EMULATOR_ADMIN_PASSWORD } from "@stackframe/stack-shared/dist/local-emulator"; import { readConfigValue } from "./config.js"; -import { AuthError } from "./errors.js"; +import { emulatorBackendPort, emulatorDashboardPort, internalPckPath, pollInternalPck } from "./emulator-paths.js"; +import { AuthError, CliError } from "./errors.js"; export const DEFAULT_API_URL = "https://api.stack-auth.com"; export const DEFAULT_DASHBOARD_URL = "https://app.stack-auth.com"; @@ -12,6 +14,7 @@ type Flags = { export type LoginConfig = { apiUrl: string, dashboardUrl: string, + publishableClientKey: string, }; export type SessionAuth = LoginConfig & { @@ -64,16 +67,17 @@ function resolveProjectId(flags: Flags): string { return projectId; } -export function resolveLoginConfig(flags: Flags): LoginConfig { +export function resolveLoginConfig(): LoginConfig { return { apiUrl: resolveApiUrl(), dashboardUrl: resolveDashboardUrl(), + publishableClientKey: DEFAULT_PUBLISHABLE_CLIENT_KEY, }; } -export function resolveSessionAuth(flags: Flags): SessionAuth { +export function resolveSessionAuth(): SessionAuth { return { - ...resolveLoginConfig(flags), + ...resolveLoginConfig(), refreshToken: resolveRefreshToken(), }; } @@ -82,14 +86,14 @@ export function resolveAuth(flags: Flags): ProjectAuth { const secretServerKey = resolveSecretServerKey(); if (secretServerKey) { return { - ...resolveLoginConfig(flags), + ...resolveLoginConfig(), projectId: resolveProjectId(flags), secretServerKey, }; } return { - ...resolveSessionAuth(flags), + ...resolveSessionAuth(), projectId: resolveProjectId(flags), }; } @@ -101,3 +105,137 @@ export function isProjectAuthWithSecretServerKey(auth: ProjectAuth): auth is Pro export function isProjectAuthWithRefreshToken(auth: ProjectAuth): auth is ProjectAuthWithRefreshToken { return "refreshToken" in auth; } + +function resolveLocalEmulatorUrl(envName: "STACK_EMULATOR_API_URL" | "STACK_EMULATOR_DASHBOARD_URL", port: number): string { + return process.env[envName] + ?? readConfigValue(envName) + ?? `http://127.0.0.1:${port}`; +} + +export function resolveLocalEmulatorApiUrl(): string { + return resolveLocalEmulatorUrl("STACK_EMULATOR_API_URL", emulatorBackendPort()); +} + +export function resolveLocalEmulatorDashboardUrl(): string { + return resolveLocalEmulatorUrl("STACK_EMULATOR_DASHBOARD_URL", emulatorDashboardPort()); +} + +// Per-phase budget for "absorb the race between `stack emulator start` and the +// next CLI invocation". Applied independently to (a) waiting for the PCK file +// to appear and (b) the sign-in retry loop, so the worst-case wall-clock is up +// to ~2× this value when both phases hit the deadline. Override via +// STACK_EMULATOR_READY_TIMEOUT_MS (in milliseconds). +const DEFAULT_LOCAL_EMULATOR_READY_TIMEOUT_MS = 10_000; +const LOCAL_EMULATOR_PER_REQUEST_TIMEOUT_MS = 5_000; + +// Exported for unit tests. Reads the env var, validates, and returns the +// resolved timeout in milliseconds. +export function localEmulatorReadyTimeoutMs(): number { + const raw = process.env.STACK_EMULATOR_READY_TIMEOUT_MS; + if (!raw) return DEFAULT_LOCAL_EMULATOR_READY_TIMEOUT_MS; + const parsed = Number(raw); + if (!Number.isInteger(parsed) || parsed < 0) { + throw new CliError(`Invalid STACK_EMULATOR_READY_TIMEOUT_MS: ${raw}. Must be a non-negative integer (milliseconds).`); + } + return parsed; +} + +async function resolveLocalEmulatorInternalPck(timeoutMs: number): Promise { + const contents = await pollInternalPck(timeoutMs); + if (contents === null) { + throw new AuthError(`Local emulator publishable client key not found at ${internalPckPath()} (waited ${timeoutMs}ms). Start the emulator with \`stack emulator start\`, or pass --cloud to use the cloud API.`); + } + return contents; +} + +type SignInBody = { + email: string, + password: string, +}; + +// Retry on transport-level failures (connection refused, DNS, abort/timeout). +// HTTP errors come back as a Response with !ok and are handled separately — +// they are not retried because the emulator is reachable, just unhappy. +export function isRetryableFetchError(err: unknown): boolean { + if (!(err instanceof Error)) return true; + if (err.name === "AbortError" || err.name === "TimeoutError") return true; + return err.name === "TypeError" || /fetch failed|ECONNREFUSED|ENOTFOUND|ETIMEDOUT|ECONNRESET/i.test(err.message); +} + +async function attemptLocalEmulatorSignIn(apiUrl: string, internalPck: string, body: SignInBody, perRequestTimeoutMs: number): Promise { + return await fetch(`${apiUrl}/api/v1/auth/password/sign-in`, { + method: "POST", + signal: AbortSignal.timeout(perRequestTimeoutMs), + headers: { + "Content-Type": "application/json", + "X-Stack-Project-Id": "internal", + "X-Stack-Access-Type": "client", + "X-Stack-Publishable-Client-Key": internalPck, + }, + body: JSON.stringify(body), + }); +} + +async function localEmulatorSignInWithRetry(apiUrl: string, internalPck: string, body: SignInBody, totalTimeoutMs: number): Promise { + const deadline = Date.now() + totalTimeoutMs; + let delay = 100; + let lastError: unknown = null; + while (true) { + // Cap each request so the user-set total budget is actually honored — a + // 5s default per-request would otherwise overshoot a small total. + const remainingForRequest = Math.max(1, deadline - Date.now()); + const perRequestTimeoutMs = Math.min(LOCAL_EMULATOR_PER_REQUEST_TIMEOUT_MS, remainingForRequest); + try { + return await attemptLocalEmulatorSignIn(apiUrl, internalPck, body, perRequestTimeoutMs); + } catch (err) { + if (!isRetryableFetchError(err)) throw err; + lastError = err; + } + if (Date.now() >= deadline) { + const message = lastError instanceof Error ? lastError.message : String(lastError); + throw new AuthError(`Cannot reach local emulator at ${apiUrl} (after ${totalTimeoutMs}ms): ${message}. Start it with \`stack emulator start\`, or pass --cloud to use the cloud API.`); + } + const remaining = deadline - Date.now(); + await new Promise((r) => setTimeout(r, Math.min(delay, remaining))); + delay = Math.min(delay * 2, 1_000); + } +} + +export async function resolveLocalEmulatorAuth(flags: Flags): Promise { + const apiUrl = resolveLocalEmulatorApiUrl(); + const projectId = resolveProjectId(flags); + const readyTimeoutMs = localEmulatorReadyTimeoutMs(); + const internalPck = await resolveLocalEmulatorInternalPck(readyTimeoutMs); + + const res = await localEmulatorSignInWithRetry( + apiUrl, + internalPck, + { email: LOCAL_EMULATOR_ADMIN_EMAIL, password: LOCAL_EMULATOR_ADMIN_PASSWORD }, + readyTimeoutMs, + ); + + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new AuthError(`Local emulator sign-in failed (${res.status} ${res.statusText})${body ? `: ${body}` : ""}. Make sure the emulator is running with NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR=true.`); + } + + let data: unknown; + try { + data = await res.json(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new AuthError(`Local emulator sign-in returned a non-JSON response: ${message}.`); + } + if (data === null || typeof data !== "object" || typeof (data as { refresh_token?: unknown }).refresh_token !== "string") { + throw new AuthError("Local emulator sign-in response was missing a refresh token."); + } + const refreshToken = (data as { refresh_token: string }).refresh_token; + + return { + apiUrl, + dashboardUrl: resolveLocalEmulatorDashboardUrl(), + publishableClientKey: internalPck, + refreshToken, + projectId, + }; +} diff --git a/packages/stack-cli/src/lib/config.ts b/packages/stack-cli/src/lib/config.ts index 670e57aa5c..f058d7fb0d 100644 --- a/packages/stack-cli/src/lib/config.ts +++ b/packages/stack-cli/src/lib/config.ts @@ -4,7 +4,7 @@ import * as os from "os"; const CONFIG_PATH = process.env.STACK_CLI_CONFIG_PATH ?? path.join(os.homedir(), ".config", "stack-auth", "credentials.json"); -type ConfigKey = "STACK_CLI_REFRESH_TOKEN" | "STACK_CLI_ANON_REFRESH_TOKEN" | "STACK_API_URL" | "STACK_DASHBOARD_URL"; +type ConfigKey = "STACK_CLI_REFRESH_TOKEN" | "STACK_CLI_ANON_REFRESH_TOKEN" | "STACK_API_URL" | "STACK_DASHBOARD_URL" | "STACK_EMULATOR_API_URL" | "STACK_EMULATOR_DASHBOARD_URL"; function readConfigJson(): Record { try { diff --git a/packages/stack-cli/src/lib/emulator-paths.test.ts b/packages/stack-cli/src/lib/emulator-paths.test.ts new file mode 100644 index 0000000000..52ad765b08 --- /dev/null +++ b/packages/stack-cli/src/lib/emulator-paths.test.ts @@ -0,0 +1,59 @@ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { internalPckPath, pollInternalPck } from "./emulator-paths.js"; + +describe("pollInternalPck", () => { + const SAVED_HOME = process.env.STACK_EMULATOR_HOME; + let tmpHome: string; + + beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "stack-cli-poll-pck-")); + process.env.STACK_EMULATOR_HOME = tmpHome; + }); + afterEach(() => { + if (SAVED_HOME === undefined) delete process.env.STACK_EMULATOR_HOME; + else process.env.STACK_EMULATOR_HOME = SAVED_HOME; + fs.rmSync(tmpHome, { recursive: true, force: true }); + }); + + function writePck(contents: string): void { + const pckPath = internalPckPath(); + fs.mkdirSync(path.dirname(pckPath), { recursive: true }); + fs.writeFileSync(pckPath, contents); + } + + it("returns trimmed contents when the file already exists", async () => { + writePck(" pck_existing \n"); + const result = await pollInternalPck(50); + expect(result).toBe("pck_existing"); + }); + + it("returns null when the deadline elapses with no file", async () => { + const start = Date.now(); + const result = await pollInternalPck(0); + expect(result).toBeNull(); + // 0ms budget should resolve almost instantly. + expect(Date.now() - start).toBeLessThan(500); + }); + + it("treats an empty/whitespace-only file as not-yet-ready and times out null", async () => { + writePck(" \n"); + const result = await pollInternalPck(0); + expect(result).toBeNull(); + }); + + it("picks up the file if it appears mid-poll", async () => { + setTimeout(() => writePck("pck_appears_late"), 80); + const result = await pollInternalPck(2000); + expect(result).toBe("pck_appears_late"); + }); + + it("propagates non-ENOENT read errors", async () => { + // Create a directory at the PCK path so readFileSync throws EISDIR. + const pckPath = internalPckPath(); + fs.mkdirSync(pckPath, { recursive: true }); + await expect(pollInternalPck(50)).rejects.toThrow(); + }); +}); diff --git a/packages/stack-cli/src/lib/emulator-paths.ts b/packages/stack-cli/src/lib/emulator-paths.ts new file mode 100644 index 0000000000..60f74db36c --- /dev/null +++ b/packages/stack-cli/src/lib/emulator-paths.ts @@ -0,0 +1,93 @@ +import { readFileSync } from "fs"; +import { homedir } from "os"; +import { join } from "path"; +import { CliError } from "./errors.js"; + +export const DEFAULT_EMULATOR_BACKEND_PORT = 26701; +export const DEFAULT_EMULATOR_DASHBOARD_PORT = 26700; +export const DEFAULT_EMULATOR_MINIO_PORT = 26702; +export const DEFAULT_EMULATOR_INBUCKET_PORT = 26703; +export const DEFAULT_EMULATOR_MOCK_OAUTH_PORT = 26704; + +export function envPort(name: string, fallback: number): number { + const raw = process.env[name]; + if (!raw) return fallback; + const parsed = Number(raw); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new CliError(`Invalid ${name}: ${raw}`); + } + return parsed; +} + +// First-set-wins lookup across alias env names. Use this for port vars where +// we want a STACK_-prefixed canonical name plus a legacy unprefixed alias. +export function envPortFirstSet(names: [string, ...string[]], fallback: number): number { + for (const name of names) { + if (process.env[name]) return envPort(name, fallback); + } + return fallback; +} + +export function emulatorHome(): string { + return process.env.STACK_EMULATOR_HOME ?? join(homedir(), ".stack", "emulator"); +} + +export function emulatorRunDir(): string { + return join(emulatorHome(), "run"); +} + +export function emulatorImageDir(): string { + return join(emulatorHome(), "images"); +} + +export function internalPckPath(): string { + return join(emulatorRunDir(), "vm", "internal-pck"); +} + +export function emulatorBackendPort(): number { + return envPortFirstSet(["STACK_EMULATOR_BACKEND_PORT", "EMULATOR_BACKEND_PORT"], DEFAULT_EMULATOR_BACKEND_PORT); +} + +export function emulatorDashboardPort(): number { + return envPortFirstSet(["STACK_EMULATOR_DASHBOARD_PORT", "EMULATOR_DASHBOARD_PORT"], DEFAULT_EMULATOR_DASHBOARD_PORT); +} + +export function emulatorMinioPort(): number { + return envPortFirstSet(["STACK_EMULATOR_MINIO_PORT", "EMULATOR_MINIO_PORT"], DEFAULT_EMULATOR_MINIO_PORT); +} + +export function emulatorInbucketPort(): number { + return envPortFirstSet(["STACK_EMULATOR_INBUCKET_PORT", "EMULATOR_INBUCKET_PORT"], DEFAULT_EMULATOR_INBUCKET_PORT); +} + +export function emulatorMockOAuthPort(): number { + return envPortFirstSet(["STACK_EMULATOR_MOCK_OAUTH_PORT", "EMULATOR_MOCK_OAUTH_PORT"], DEFAULT_EMULATOR_MOCK_OAUTH_PORT); +} + +// Polls the emulator runtime dir for the internal PCK file with exponential +// backoff. Returns the trimmed contents on success, or `null` if the file is +// still missing/empty when the deadline elapses. Non-ENOENT read errors throw. +// +// Two callers care about this race: +// - `stack emulator start --config-file` waits up to ~60s for the VM to come +// up after a fresh boot. +// - `stack exec` (local default) waits a much shorter window so we still +// surface "emulator not running" quickly while absorbing a typical race +// between `stack emulator start` and the next CLI invocation. +export async function pollInternalPck(timeoutMs: number): Promise { + const pckPath = internalPckPath(); + const deadline = Date.now() + timeoutMs; + let delay = 50; + while (true) { + try { + const contents = readFileSync(pckPath, "utf-8").trim(); + if (contents) return contents; + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e; + } + if (Date.now() >= deadline) return null; + const remaining = deadline - Date.now(); + await new Promise((r) => setTimeout(r, Math.min(delay, remaining))); + delay = Math.min(delay * 2, 2000); + } +} diff --git a/packages/stack-shared/src/local-emulator.ts b/packages/stack-shared/src/local-emulator.ts new file mode 100644 index 0000000000..83c17442e2 --- /dev/null +++ b/packages/stack-shared/src/local-emulator.ts @@ -0,0 +1,5 @@ +// Well-known shared credentials for the dev-only local emulator's admin user. +// Backend, dashboard auto-login, and CLI all sign in with these exact values — +// do not "harden" them. +export const LOCAL_EMULATOR_ADMIN_EMAIL = "local-emulator@stack-auth.com"; +export const LOCAL_EMULATOR_ADMIN_PASSWORD = "LocalEmulatorPassword"; From 497b9d2876545ccbaf69ceb22d1ceb6ee0ed6abd Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Fri, 8 May 2026 10:45:49 -0700 Subject: [PATCH 2/4] Fix local-emulator e2e test and address review feedback - The positive happy-path test minted a project owned by the test user's team, but the CLI signs in as the local-emulator admin whose listOwnedProjects() only returns LOCAL_EMULATOR_OWNER_TEAM_ID-owned projects. Mint the project via /internal/local-emulator/project so it shows up under the admin's team. - Surface stderr when the positive test exits non-zero so future regressions report the real CLI error instead of a bare exit-code mismatch. - Add expect(createdProjectId).toBeDefined() guards to the two negative emulator tests for parity with the positive test. - Use performance.now() instead of Date.now() for the local-emulator sign-in retry deadline so wall-clock skew can't break the loop. --- apps/e2e/tests/general/cli.test.ts | 33 +++++++++++++++++++++++++++--- packages/stack-cli/src/lib/auth.ts | 8 ++++---- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/apps/e2e/tests/general/cli.test.ts b/apps/e2e/tests/general/cli.test.ts index b6a3e51fe8..94dff6a019 100644 --- a/apps/e2e/tests/general/cli.test.ts +++ b/apps/e2e/tests/general/cli.test.ts @@ -323,6 +323,7 @@ describe("Stack CLI", () => { }); it("local-default exec errors when emulator PCK file is missing", async ({ expect }) => { + expect(createdProjectId).toBeDefined(); // Without --cloud, exec defaults to the local emulator. With // STACK_EMULATOR_HOME pointed at an empty dir, the PCK file lookup fires // before any network call and we get a clear error. Setting @@ -346,6 +347,7 @@ describe("Stack CLI", () => { }); it("local-default exec errors when emulator API is unreachable", async ({ expect }) => { + expect(createdProjectId).toBeDefined(); // PCK file present (so we get past the file check) but STACK_EMULATOR_API_URL // points at a port nothing is listening on — fetch fails with a clear error. // STACK_EMULATOR_READY_TIMEOUT_MS=0 keeps the retry loop from waiting. @@ -375,21 +377,46 @@ describe("Stack CLI", () => { // there). Stages a STACK_EMULATOR_HOME with the real internal PCK and // points STACK_EMULATOR_API_URL at the running backend, so the CLI takes // the local-default path and signs in as the emulator admin. + // + // The CLI signs in as the emulator admin, whose listOwnedProjects() only + // returns projects owned by LOCAL_EMULATOR_OWNER_TEAM_ID. createdProjectId + // is owned by the test user's team and would be invisible, so we mint a + // fresh project via the local-emulator endpoint instead. it.runIf(isLocalEmulator)("local-default exec runs against the local emulator backend", async ({ expect }) => { - expect(createdProjectId).toBeDefined(); + const emulatorConfigPath = path.join(tmpDir, `stack-emulator-${crypto.randomUUID()}.config.ts`); + fs.writeFileSync(emulatorConfigPath, ""); + const projectRes = await niceFetch(`${STACK_BACKEND_BASE_URL}/api/v1/internal/local-emulator/project`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-stack-access-type": "server", + "x-stack-project-id": "internal", + "x-stack-publishable-client-key": STACK_INTERNAL_PROJECT_CLIENT_KEY, + "x-stack-secret-server-key": STACK_INTERNAL_PROJECT_SERVER_KEY, + }, + body: JSON.stringify({ absolute_file_path: emulatorConfigPath }), + }); + if (projectRes.status !== 200) { + throw new Error(`Failed to mint local emulator project: ${projectRes.status} ${JSON.stringify(projectRes.body)}`); + } + const emulatorProjectId = (projectRes.body as { project_id: string }).project_id; + const fakeEmulatorHome = fs.mkdtempSync(path.join(os.tmpdir(), "stack-cli-emu-positive-")); try { const pckDir = path.join(fakeEmulatorHome, "run", "vm"); fs.mkdirSync(pckDir, { recursive: true }); fs.writeFileSync(path.join(pckDir, "internal-pck"), STACK_INTERNAL_PROJECT_CLIENT_KEY); - const { stdout, exitCode } = await runCli( + const { stdout, stderr, exitCode } = await runCli( ["exec", "return 1+1"], { - STACK_PROJECT_ID: createdProjectId, + STACK_PROJECT_ID: emulatorProjectId, STACK_EMULATOR_HOME: fakeEmulatorHome, STACK_EMULATOR_API_URL: STACK_BACKEND_BASE_URL, }, ); + if (exitCode !== 0) { + throw new Error(`CLI exited ${exitCode}. stderr: ${stderr}`); + } expect(exitCode).toBe(0); expect(stdout.trim()).toBe("2"); } finally { diff --git a/packages/stack-cli/src/lib/auth.ts b/packages/stack-cli/src/lib/auth.ts index 645255080a..8c2d4cdc36 100644 --- a/packages/stack-cli/src/lib/auth.ts +++ b/packages/stack-cli/src/lib/auth.ts @@ -177,13 +177,13 @@ async function attemptLocalEmulatorSignIn(apiUrl: string, internalPck: string, b } async function localEmulatorSignInWithRetry(apiUrl: string, internalPck: string, body: SignInBody, totalTimeoutMs: number): Promise { - const deadline = Date.now() + totalTimeoutMs; + const deadline = performance.now() + totalTimeoutMs; let delay = 100; let lastError: unknown = null; while (true) { // Cap each request so the user-set total budget is actually honored — a // 5s default per-request would otherwise overshoot a small total. - const remainingForRequest = Math.max(1, deadline - Date.now()); + const remainingForRequest = Math.max(1, deadline - performance.now()); const perRequestTimeoutMs = Math.min(LOCAL_EMULATOR_PER_REQUEST_TIMEOUT_MS, remainingForRequest); try { return await attemptLocalEmulatorSignIn(apiUrl, internalPck, body, perRequestTimeoutMs); @@ -191,11 +191,11 @@ async function localEmulatorSignInWithRetry(apiUrl: string, internalPck: string, if (!isRetryableFetchError(err)) throw err; lastError = err; } - if (Date.now() >= deadline) { + if (performance.now() >= deadline) { const message = lastError instanceof Error ? lastError.message : String(lastError); throw new AuthError(`Cannot reach local emulator at ${apiUrl} (after ${totalTimeoutMs}ms): ${message}. Start it with \`stack emulator start\`, or pass --cloud to use the cloud API.`); } - const remaining = deadline - Date.now(); + const remaining = deadline - performance.now(); await new Promise((r) => setTimeout(r, Math.min(delay, remaining))); delay = Math.min(delay * 2, 1_000); } From 00e17beb7f04e5cec4f4983da23efad4fd83e0de Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Fri, 8 May 2026 11:02:35 -0700 Subject: [PATCH 3/4] Address VADE review feedback on local-emulator auth - Use performance.now() in pollInternalPck for the polling deadline so wall-clock skew can't break the loop, mirroring the same change in localEmulatorSignInWithRetry. - Surface response-body read failures in resolveLocalEmulatorAuth instead of swallowing them with .catch(() => ""). The original message ("Local emulator sign-in failed (status text)") loses all diagnostic info when res.text() itself throws; now we throw an AuthError that includes the read error. --- packages/stack-cli/src/lib/auth.ts | 8 +++++++- packages/stack-cli/src/lib/emulator-paths.ts | 6 +++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/stack-cli/src/lib/auth.ts b/packages/stack-cli/src/lib/auth.ts index 8c2d4cdc36..f4222da1a2 100644 --- a/packages/stack-cli/src/lib/auth.ts +++ b/packages/stack-cli/src/lib/auth.ts @@ -215,7 +215,13 @@ export async function resolveLocalEmulatorAuth(flags: Flags): Promise ""); + let body: string; + try { + body = await res.text(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new AuthError(`Local emulator sign-in failed (${res.status} ${res.statusText}). Failed to read response body: ${message}. Make sure the emulator is running with NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR=true.`); + } throw new AuthError(`Local emulator sign-in failed (${res.status} ${res.statusText})${body ? `: ${body}` : ""}. Make sure the emulator is running with NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR=true.`); } diff --git a/packages/stack-cli/src/lib/emulator-paths.ts b/packages/stack-cli/src/lib/emulator-paths.ts index 60f74db36c..fee7014ca9 100644 --- a/packages/stack-cli/src/lib/emulator-paths.ts +++ b/packages/stack-cli/src/lib/emulator-paths.ts @@ -76,7 +76,7 @@ export function emulatorMockOAuthPort(): number { // between `stack emulator start` and the next CLI invocation. export async function pollInternalPck(timeoutMs: number): Promise { const pckPath = internalPckPath(); - const deadline = Date.now() + timeoutMs; + const deadline = performance.now() + timeoutMs; let delay = 50; while (true) { try { @@ -85,8 +85,8 @@ export async function pollInternalPck(timeoutMs: number): Promise } catch (e) { if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e; } - if (Date.now() >= deadline) return null; - const remaining = deadline - Date.now(); + if (performance.now() >= deadline) return null; + const remaining = deadline - performance.now(); await new Promise((r) => setTimeout(r, Math.min(delay, remaining))); delay = Math.min(delay * 2, 2000); } From 033ad79809589adb33cc979f1d6da0d34b19cd85 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 13 May 2026 16:39:47 -0700 Subject: [PATCH 4/4] stack-cli: per-command --cloud-project-id, explicit --cloud / --dev surfaces Replaces the global --project-id / STACK_PROJECT_ID surface (and the local-default exec path added in this branch) with explicit per-command flags: - `stack exec` now requires exactly one of `--cloud-project-id ` or `--config-file `. `--cloud` and STACK_EXEC_DEFAULT_TARGET are removed. `--config-file` resolves the local emulator project by absolute path via the existing GET /api/latest/internal/local-emulator/project endpoint. - `stack config pull/push` take `--cloud-project-id` instead of the global flag. `config pull --config-file` is optional and defaults to ./stack.config.ts in cwd, erroring with a clear hint when neither is present. - `stack project list` lists cloud + dev by default with a `target` field on each entry; `--cloud` / `--dev` filter to one source (mutually exclusive). Unreachable emulator on the default path emits a single stderr warning rather than failing. - `stack project create` now requires `--cloud` to make cloud-vs-local explicit. - Bumps the LIMIT on local-emulator project listing from 20 to 100 so `project list --dev` doesn't silently truncate. --- .../internal/local-emulator/project/route.tsx | 2 +- apps/e2e/tests/general/cli.test.ts | 209 +++++++++++------- .../src/commands/config-file.test.ts | 38 ++++ .../stack-cli/src/commands/config-file.ts | 26 ++- packages/stack-cli/src/commands/exec.test.ts | 40 ++-- packages/stack-cli/src/commands/exec.ts | 62 ++++-- .../stack-cli/src/commands/project.test.ts | 34 +++ packages/stack-cli/src/commands/project.ts | 91 ++++++-- packages/stack-cli/src/index.ts | 1 - packages/stack-cli/src/lib/auth.ts | 25 +-- .../src/lib/local-emulator-client.ts | 128 +++++++++++ 11 files changed, 492 insertions(+), 164 deletions(-) create mode 100644 packages/stack-cli/src/commands/config-file.test.ts create mode 100644 packages/stack-cli/src/commands/project.test.ts create mode 100644 packages/stack-cli/src/lib/local-emulator-client.ts diff --git a/apps/backend/src/app/api/latest/internal/local-emulator/project/route.tsx b/apps/backend/src/app/api/latest/internal/local-emulator/project/route.tsx index 77b2afa719..1e81ff9c71 100644 --- a/apps/backend/src/app/api/latest/internal/local-emulator/project/route.tsx +++ b/apps/backend/src/app/api/latest/internal/local-emulator/project/route.tsx @@ -404,7 +404,7 @@ export const GET = createSmartRouteHandler({ SELECT "projectId", "absoluteFilePath", "updatedAt" FROM "LocalEmulatorProject" ORDER BY "updatedAt" DESC - LIMIT 20 + LIMIT 100 `); const projectIds = rows.map((r) => r.projectId); diff --git a/apps/e2e/tests/general/cli.test.ts b/apps/e2e/tests/general/cli.test.ts index 43936905b6..114f3ac7cb 100644 --- a/apps/e2e/tests/general/cli.test.ts +++ b/apps/e2e/tests/general/cli.test.ts @@ -23,10 +23,12 @@ function extractConfigObjectString(content: string): string { function runCli( args: string[], envOverrides?: Record, + cwd?: string, ): Promise<{ stdout: string, stderr: string, exitCode: number | null }> { return new Promise((resolve) => { execFile("node", [CLI_BIN, ...args], { env: { ...baseEnv, ...envOverrides }, + cwd, timeout: 30_000, }, (error, stdout, stderr) => { resolve({ @@ -135,13 +137,16 @@ describe("Stack CLI", () => { expect(stderr).toContain("Not logged in"); }); - it("errors when no project ID given", async ({ expect }) => { - // Exercise the default (local) path: project-ID resolution happens before - // any emulator I/O, so the missing-ID error fires regardless of whether - // an emulator is running. + it("exec errors when neither --cloud-project-id nor --config-file is given", async ({ expect }) => { const { stderr, exitCode } = await runCli(["exec", "return 1"]); expect(exitCode).toBe(1); - expect(stderr).toContain("No project ID"); + expect(stderr).toContain("Specify a target"); + }); + + it("exec errors when both --cloud-project-id and --config-file are given", async ({ expect }) => { + const { stderr, exitCode } = await runCli(["exec", "--cloud-project-id", "proj_x", "--config-file", "./stack.config.ts", "return 1"]); + expect(exitCode).toBe(1); + expect(stderr).toContain("not both"); }); it("logout clears config", async ({ expect }) => { @@ -158,38 +163,73 @@ describe("Stack CLI", () => { let createdProjectId: string; - it("lists projects as empty JSON array", async ({ expect }) => { - const { stdout, exitCode } = await runCli(["--json", "project", "list"]); + it("lists cloud projects as empty JSON array", async ({ expect }) => { + const { stdout, exitCode } = await runCli(["--json", "project", "list", "--cloud"]); expect(exitCode).toBe(0); const projects = JSON.parse(stdout); expect(Array.isArray(projects)).toBe(true); }); + it("project create requires --cloud", async ({ expect }) => { + const { stderr, exitCode } = await runCli(["project", "create", "--display-name", "Should Fail"]); + expect(exitCode).toBe(1); + expect(stderr).toContain("--cloud to confirm"); + }); + + it("project list rejects --cloud and --dev together", async ({ expect }) => { + const { stderr, exitCode } = await runCli(["project", "list", "--cloud", "--dev"]); + expect(exitCode).toBe(1); + expect(stderr).toContain("not both"); + }); + it("creates a project", async ({ expect }) => { - const { stdout, exitCode } = await runCli(["--json", "project", "create", "--display-name", "CLI Test"]); + const { stdout, exitCode } = await runCli(["--json", "project", "create", "--cloud", "--display-name", "CLI Test"]); expect(exitCode).toBe(0); const project = JSON.parse(stdout); expect(project).toHaveProperty("id"); expect(project).toHaveProperty("displayName"); + expect(project.target).toBe("cloud"); expect(project.displayName).toBe("CLI Test"); createdProjectId = project.id; }); - it("lists projects including created one", async ({ expect }) => { + it("lists cloud projects including created one with target=cloud", async ({ expect }) => { expect(createdProjectId).toBeDefined(); - const { stdout, exitCode } = await runCli(["--json", "project", "list"]); + const { stdout, exitCode } = await runCli(["--json", "project", "list", "--cloud"]); expect(exitCode).toBe(0); const projects = JSON.parse(stdout); const found = projects.find((p: any) => p.id === createdProjectId); expect(found).toBeDefined(); expect(found.displayName).toBe("CLI Test"); + expect(found.target).toBe("cloud"); + }); + + it("project list (no flags) emits a stderr warning when the emulator is unreachable", async ({ expect }) => { + expect(createdProjectId).toBeDefined(); + // Default (no flags) tries both sources; the dev branch fails because the + // emulator PCK isn't where the CLI expects. We should still get a 0 exit + // and cloud results, plus a single stderr warning line. + const fakeEmulatorHome = fs.mkdtempSync(path.join(os.tmpdir(), "stack-cli-list-warn-")); + try { + const { stdout, stderr, exitCode } = await runCli(["--json", "project", "list"], { + STACK_EMULATOR_HOME: fakeEmulatorHome, + STACK_EMULATOR_READY_TIMEOUT_MS: "0", + }); + expect(exitCode).toBe(0); + expect(stderr).toContain("skipping dev projects"); + const projects = JSON.parse(stdout); + const found = projects.find((p: any) => p.id === createdProjectId); + expect(found).toBeDefined(); + expect(found.target).toBe("cloud"); + } finally { + fs.rmSync(fakeEmulatorHome, { recursive: true }); + } }); it("returns basic expression", async ({ expect }) => { expect(createdProjectId).toBeDefined(); const { stdout, exitCode } = await runCli( - ["exec", "--cloud", "return 1+1"], - { STACK_PROJECT_ID: createdProjectId }, + ["exec", "--cloud-project-id", createdProjectId, "return 1+1"], ); expect(exitCode).toBe(0); expect(stdout.trim()).toBe("2"); @@ -197,8 +237,7 @@ describe("Stack CLI", () => { it("has stackServerApp object available", async ({ expect }) => { const { stdout, exitCode } = await runCli( - ["exec", "--cloud", "return typeof stackServerApp"], - { STACK_PROJECT_ID: createdProjectId }, + ["exec", "--cloud-project-id", createdProjectId, "return typeof stackServerApp"], ); expect(exitCode).toBe(0); expect(stdout.trim()).toBe('"object"'); @@ -210,22 +249,22 @@ describe("Stack CLI", () => { expect(stdout).toContain("https://docs.stack-auth.com/docs/sdk"); }); - it("exec help mentions --cloud option", async ({ expect }) => { + it("exec help mentions --cloud-project-id and --config-file", async ({ expect }) => { const { stdout, exitCode } = await runCli(["exec", "--help"]); expect(exitCode).toBe(0); - expect(stdout).toContain("--cloud"); + expect(stdout).toContain("--cloud-project-id"); + expect(stdout).toContain("--config-file"); }); it("errors when no javascript is provided", async ({ expect }) => { - const { stderr, exitCode } = await runCli(["exec", "--cloud"], { STACK_PROJECT_ID: createdProjectId }); + const { stderr, exitCode } = await runCli(["exec", "--cloud-project-id", createdProjectId]); expect(exitCode).toBe(1); expect(stderr).toContain("Missing JavaScript argument"); }); it("reports syntax error", async ({ expect }) => { const { stderr, exitCode } = await runCli( - ["exec", "--cloud", "return @@invalid"], - { STACK_PROJECT_ID: createdProjectId }, + ["exec", "--cloud-project-id", createdProjectId, "return @@invalid"], ); expect(exitCode).toBe(1); expect(stderr).toContain("Syntax error"); @@ -233,8 +272,7 @@ describe("Stack CLI", () => { it("reports runtime error", async ({ expect }) => { const { stderr, exitCode } = await runCli( - ["exec", "--cloud", "throw new Error('boom')"], - { STACK_PROJECT_ID: createdProjectId }, + ["exec", "--cloud-project-id", createdProjectId, "throw new Error('boom')"], ); expect(exitCode).toBe(1); expect(stderr).toContain("boom"); @@ -242,8 +280,7 @@ describe("Stack CLI", () => { it("reports string runtime error", async ({ expect }) => { const { stderr, exitCode } = await runCli( - ["exec", "--cloud", "throw 'boom-string'"], - { STACK_PROJECT_ID: createdProjectId }, + ["exec", "--cloud-project-id", createdProjectId, "throw 'boom-string'"], ); expect(exitCode).toBe(1); expect(stderr).toContain("boom-string"); @@ -251,8 +288,7 @@ describe("Stack CLI", () => { it("reports object runtime error", async ({ expect }) => { const { stderr, exitCode } = await runCli( - ["exec", "--cloud", "throw { code: 123 }"], - { STACK_PROJECT_ID: createdProjectId }, + ["exec", "--cloud-project-id", createdProjectId, "throw { code: 123 }"], ); expect(exitCode).toBe(1); expect(stderr).toContain('{"code":123}'); @@ -260,8 +296,7 @@ describe("Stack CLI", () => { it("reports undefined variable", async ({ expect }) => { const { stderr, exitCode } = await runCli( - ["exec", "--cloud", "return nonExistentVar"], - { STACK_PROJECT_ID: createdProjectId }, + ["exec", "--cloud-project-id", createdProjectId, "return nonExistentVar"], ); expect(exitCode).toBe(1); expect(stderr).toContain("nonExistentVar"); @@ -269,8 +304,7 @@ describe("Stack CLI", () => { it("returns undefined for no return value", async ({ expect }) => { const { stdout, exitCode } = await runCli( - ["exec", "--cloud", "const x = 1"], - { STACK_PROJECT_ID: createdProjectId }, + ["exec", "--cloud-project-id", createdProjectId, "const x = 1"], ); expect(exitCode).toBe(0); expect(stdout.trim()).toBe(""); @@ -278,8 +312,7 @@ describe("Stack CLI", () => { it("returns complex object as JSON", async ({ expect }) => { const { stdout, exitCode } = await runCli( - ["exec", "--cloud", "return {a: 1, b: [2, 3]}"], - { STACK_PROJECT_ID: createdProjectId }, + ["exec", "--cloud-project-id", createdProjectId, "return {a: 1, b: [2, 3]}"], ); expect(exitCode).toBe(0); const parsed = JSON.parse(stdout); @@ -288,8 +321,7 @@ describe("Stack CLI", () => { it("supports async code", async ({ expect }) => { const { stdout, exitCode } = await runCli( - ["exec", "--cloud", "return await Promise.resolve(42)"], - { STACK_PROJECT_ID: createdProjectId }, + ["exec", "--cloud-project-id", createdProjectId, "return await Promise.resolve(42)"], ); expect(exitCode).toBe(0); expect(stdout.trim()).toBe("42"); @@ -301,8 +333,7 @@ describe("Stack CLI", () => { createdUserEmail = `exec-test-${crypto.randomUUID()}@stack-generated.example.com`; const code = `const u = await stackServerApp.createUser({ primaryEmail: "${createdUserEmail}", password: "test123456" }); return { id: u.id, email: u.primaryEmail }`; const { stdout, exitCode } = await runCli( - ["exec", "--cloud", code], - { STACK_PROJECT_ID: createdProjectId }, + ["exec", "--cloud-project-id", createdProjectId, code], ); expect(exitCode).toBe(0); const parsed = JSON.parse(stdout); @@ -314,27 +345,31 @@ describe("Stack CLI", () => { expect(createdProjectId).toBeDefined(); expect(createdUserEmail).toBeDefined(); const { stdout, exitCode } = await runCli( - ["exec", "--cloud", "const users = await stackServerApp.listUsers(); return users.length"], - { STACK_PROJECT_ID: createdProjectId }, + ["exec", "--cloud-project-id", createdProjectId, "const users = await stackServerApp.listUsers(); return users.length"], ); expect(exitCode).toBe(0); const count = JSON.parse(stdout); expect(count).toBeGreaterThanOrEqual(1); }); - it("local-default exec errors when emulator PCK file is missing", async ({ expect }) => { - expect(createdProjectId).toBeDefined(); - // Without --cloud, exec defaults to the local emulator. With - // STACK_EMULATOR_HOME pointed at an empty dir, the PCK file lookup fires - // before any network call and we get a clear error. Setting - // STACK_EMULATOR_READY_TIMEOUT_MS=0 disables the boot-race polling window - // so this test fails fast. + it("exec --config-file errors when the config file does not exist", async ({ expect }) => { + const { stderr, exitCode } = await runCli( + ["exec", "--config-file", path.join(tmpDir, "missing-stack.config.ts"), "return 1"], + ); + expect(exitCode).toBe(1); + expect(stderr).toContain("Config file not found"); + }); + + it("exec --config-file errors when emulator PCK file is missing", async ({ expect }) => { + // The file exists on disk but the emulator PCK file isn't where the CLI + // expects. PCK lookup fires before any network call so this fails fast. const fakeEmulatorHome = fs.mkdtempSync(path.join(os.tmpdir(), "stack-cli-fake-emulator-")); + const configFile = path.join(tmpDir, `cfg-pck-missing-${crypto.randomUUID()}.config.ts`); + fs.writeFileSync(configFile, ""); try { const { stderr, exitCode } = await runCli( - ["exec", "return 1"], + ["exec", "--config-file", configFile, "return 1"], { - STACK_PROJECT_ID: createdProjectId, STACK_EMULATOR_HOME: fakeEmulatorHome, STACK_EMULATOR_READY_TIMEOUT_MS: "0", }, @@ -346,20 +381,20 @@ describe("Stack CLI", () => { } }); - it("local-default exec errors when emulator API is unreachable", async ({ expect }) => { - expect(createdProjectId).toBeDefined(); - // PCK file present (so we get past the file check) but STACK_EMULATOR_API_URL - // points at a port nothing is listening on — fetch fails with a clear error. - // STACK_EMULATOR_READY_TIMEOUT_MS=0 keeps the retry loop from waiting. + it("exec --config-file errors when emulator API is unreachable", async ({ expect }) => { + // PCK file present but the API URL points at a port nothing is listening + // on — fetch fails with a clear error. READY_TIMEOUT_MS=0 keeps the retry + // loop from waiting. const fakeEmulatorHome = fs.mkdtempSync(path.join(os.tmpdir(), "stack-cli-fake-emulator-")); + const configFile = path.join(tmpDir, `cfg-unreachable-${crypto.randomUUID()}.config.ts`); + fs.writeFileSync(configFile, ""); try { const pckDir = path.join(fakeEmulatorHome, "run", "vm"); fs.mkdirSync(pckDir, { recursive: true }); fs.writeFileSync(path.join(pckDir, "internal-pck"), "pck_stub_for_test"); const { stderr, exitCode } = await runCli( - ["exec", "return 1"], + ["exec", "--config-file", configFile, "return 1"], { - STACK_PROJECT_ID: createdProjectId, STACK_EMULATOR_HOME: fakeEmulatorHome, STACK_EMULATOR_API_URL: "http://127.0.0.1:1", STACK_EMULATOR_READY_TIMEOUT_MS: "0", @@ -374,15 +409,10 @@ describe("Stack CLI", () => { // Positive happy-path: only runs when the backend is in local-emulator mode // (the password sign-in for local-emulator@stack-auth.com only succeeds - // there). Stages a STACK_EMULATOR_HOME with the real internal PCK and - // points STACK_EMULATOR_API_URL at the running backend, so the CLI takes - // the local-default path and signs in as the emulator admin. - // - // The CLI signs in as the emulator admin, whose listOwnedProjects() only - // returns projects owned by LOCAL_EMULATOR_OWNER_TEAM_ID. createdProjectId - // is owned by the test user's team and would be invisible, so we mint a - // fresh project via the local-emulator endpoint instead. - it.runIf(isLocalEmulator)("local-default exec runs against the local emulator backend", async ({ expect }) => { + // there). Mints a project against the local-emulator backend keyed by an + // absolute config-file path, then runs `stack exec --config-file ` + // and expects it to resolve the same project. + it.runIf(isLocalEmulator)("exec --config-file runs against the local emulator backend", async ({ expect }) => { const emulatorConfigPath = path.join(tmpDir, `stack-emulator-${crypto.randomUUID()}.config.ts`); fs.writeFileSync(emulatorConfigPath, ""); const projectRes = await niceFetch(`${STACK_BACKEND_BASE_URL}/api/v1/internal/local-emulator/project`, { @@ -399,7 +429,6 @@ describe("Stack CLI", () => { if (projectRes.status !== 200) { throw new Error(`Failed to mint local emulator project: ${projectRes.status} ${JSON.stringify(projectRes.body)}`); } - const emulatorProjectId = (projectRes.body as { project_id: string }).project_id; const fakeEmulatorHome = fs.mkdtempSync(path.join(os.tmpdir(), "stack-cli-emu-positive-")); try { @@ -407,9 +436,8 @@ describe("Stack CLI", () => { fs.mkdirSync(pckDir, { recursive: true }); fs.writeFileSync(path.join(pckDir, "internal-pck"), STACK_INTERNAL_PROJECT_CLIENT_KEY); const { stdout, stderr, exitCode } = await runCli( - ["exec", "return 1+1"], + ["exec", "--config-file", emulatorConfigPath, "return 1+1"], { - STACK_PROJECT_ID: emulatorProjectId, STACK_EMULATOR_HOME: fakeEmulatorHome, STACK_EMULATOR_API_URL: STACK_BACKEND_BASE_URL, }, @@ -429,8 +457,7 @@ describe("Stack CLI", () => { it("config pull writes a .ts file", async ({ expect }) => { configTsPath = path.join(tmpDir, "config.ts"); const { stdout, exitCode } = await runCli( - ["config", "pull", "--config-file", configTsPath, "--overwrite"], - { STACK_PROJECT_ID: createdProjectId }, + ["config", "pull", "--cloud-project-id", createdProjectId, "--config-file", configTsPath, "--overwrite"], ); expect(exitCode).toBe(0); expect(stdout).toContain("Config written to"); @@ -442,8 +469,7 @@ describe("Stack CLI", () => { it("config push succeeds", async ({ expect }) => { expect(configTsPath).toBeDefined(); const { stdout, exitCode } = await runCli( - ["config", "push", "--config-file", configTsPath], - { STACK_PROJECT_ID: createdProjectId }, + ["config", "push", "--cloud-project-id", createdProjectId, "--config-file", configTsPath], ); expect(exitCode).toBe(0); expect(stdout).toContain("Config pushed successfully"); @@ -452,8 +478,7 @@ describe("Stack CLI", () => { it("config pull rejects bad extension", async ({ expect }) => { const badPath = path.join(tmpDir, "config.json"); const { stderr, exitCode } = await runCli( - ["config", "pull", "--config-file", badPath], - { STACK_PROJECT_ID: createdProjectId }, + ["config", "pull", "--cloud-project-id", createdProjectId, "--config-file", badPath], ); expect(exitCode).toBe(1); expect(stderr).toContain(".ts extension"); @@ -463,8 +488,7 @@ describe("Stack CLI", () => { const badConfigPath = path.join(tmpDir, "config-array.ts"); fs.writeFileSync(badConfigPath, "export const config = [];\n"); const { stderr, exitCode } = await runCli( - ["config", "push", "--config-file", badConfigPath], - { STACK_PROJECT_ID: createdProjectId }, + ["config", "push", "--cloud-project-id", createdProjectId, "--config-file", badConfigPath], ); expect(exitCode).toBe(1); expect(stderr).toContain("plain `config` object"); @@ -475,14 +499,49 @@ describe("Stack CLI", () => { fs.writeFileSync(existingConfigPath, "existing\n"); const { stderr, exitCode } = await runCli( - ["config", "pull", "--config-file", existingConfigPath], - { STACK_PROJECT_ID: createdProjectId }, + ["config", "pull", "--cloud-project-id", createdProjectId, "--config-file", existingConfigPath], ); expect(exitCode).toBe(1); expect(stderr).toContain("re-run with --overwrite"); }); + it("config pull falls back to ./stack.config.ts in cwd when --config-file is omitted", async ({ expect }) => { + // realpathSync normalizes macOS's /var/folders/... → /private/var/folders/... + // (Node resolves the symlink when reporting the written path). + const cwdDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "stack-cli-config-pull-cwd-"))); + const expected = path.join(cwdDir, "stack.config.ts"); + fs.writeFileSync(expected, "// placeholder so the file exists\n"); + try { + const { stdout, exitCode } = await runCli( + ["config", "pull", "--cloud-project-id", createdProjectId, "--overwrite"], + undefined, + cwdDir, + ); + expect(exitCode).toBe(0); + expect(stdout).toContain(`Config written to ${expected}`); + const content = fs.readFileSync(expected, "utf-8"); + expect(content).toContain("export const config: StackConfig"); + } finally { + fs.rmSync(cwdDir, { recursive: true }); + } + }); + + it("config pull errors when --config-file is omitted and cwd has no stack.config.ts", async ({ expect }) => { + const cwdDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "stack-cli-config-pull-empty-"))); + try { + const { stderr, exitCode } = await runCli( + ["config", "pull", "--cloud-project-id", createdProjectId], + undefined, + cwdDir, + ); + expect(exitCode).toBe(1); + expect(stderr).toContain("Pass --config-file"); + } finally { + fs.rmSync(cwdDir, { recursive: true }); + } + }); + // --- init command tests --- // TODO: Re-enable these create-mode tests once init mode handling is finalized. diff --git a/packages/stack-cli/src/commands/config-file.test.ts b/packages/stack-cli/src/commands/config-file.test.ts new file mode 100644 index 0000000000..4ad21975d9 --- /dev/null +++ b/packages/stack-cli/src/commands/config-file.test.ts @@ -0,0 +1,38 @@ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { resolveConfigFilePathForPull } from "./config-file.js"; + +describe("resolveConfigFilePathForPull", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "stack-cli-config-pull-")); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("returns the resolved --config-file path when provided", () => { + const explicit = path.join(tmpDir, "nested", "config.ts"); + expect(resolveConfigFilePathForPull({ configFile: explicit }, tmpDir)).toBe(path.resolve(explicit)); + }); + + it("falls back to ./stack.config.ts in cwd when --config-file is omitted", () => { + const expected = path.join(tmpDir, "stack.config.ts"); + fs.writeFileSync(expected, "// placeholder\n"); + expect(resolveConfigFilePathForPull({}, tmpDir)).toBe(expected); + }); + + it("treats an empty --config-file string as omitted (falls back to cwd)", () => { + const expected = path.join(tmpDir, "stack.config.ts"); + fs.writeFileSync(expected, "// placeholder\n"); + expect(resolveConfigFilePathForPull({ configFile: "" }, tmpDir)).toBe(expected); + }); + + it("throws a CliError with help text when neither --config-file nor cwd stack.config.ts exists", () => { + expect(() => resolveConfigFilePathForPull({}, tmpDir)).toThrow(/Pass --config-file/); + }); +}); diff --git a/packages/stack-cli/src/commands/config-file.ts b/packages/stack-cli/src/commands/config-file.ts index 132c36e7a6..093da8006a 100644 --- a/packages/stack-cli/src/commands/config-file.ts +++ b/packages/stack-cli/src/commands/config-file.ts @@ -116,6 +116,20 @@ function sourceToSdkSource(source: BranchConfigSourceApi): return { type: "unlinked" }; } +// Resolve the path for `config pull` when `--config-file` was omitted. Falls +// back to `./stack.config.ts` in cwd, and throws a CliError with a clear hint +// if it isn't there. Exported for unit tests. +export function resolveConfigFilePathForPull(opts: { configFile?: string }, cwd: string): string { + if (opts.configFile != null && opts.configFile !== "") { + return path.resolve(opts.configFile); + } + const candidate = path.join(cwd, "stack.config.ts"); + if (!fs.existsSync(candidate)) { + throw new CliError("No --config-file provided and no stack.config.ts found in the current directory. Pass --config-file or run this command in a directory containing a stack.config.ts file."); + } + return candidate; +} + export function registerConfigCommand(program: Command) { const config = program .command("config") @@ -124,18 +138,18 @@ export function registerConfigCommand(program: Command) { config .command("pull") .description("Pull branch config to a local file") - .requiredOption("--config-file ", "Path to write config file (.ts)") + .requiredOption("--cloud-project-id ", "Cloud project ID to pull config from") + .option("--config-file ", "Path to write config file (.ts); defaults to ./stack.config.ts in the current directory") .option("--overwrite", "Overwrite an existing config file") .action(async (opts) => { - const flags = program.opts(); - const auth = resolveAuth(flags); + const auth = resolveAuth(opts.cloudProjectId); if (!isProjectAuthWithRefreshToken(auth)) { throw new CliError("`stack config pull` requires `stack login`. Remove STACK_SECRET_SERVER_KEY and try again."); } const project = await getAdminProject(auth); const configOverride = await project.getConfigOverride("branch"); - const filePath = path.resolve(opts.configFile); + const filePath = resolveConfigFilePathForPull(opts, process.cwd()); const ext = path.extname(filePath); if (ext !== ".ts") { @@ -156,10 +170,10 @@ export function registerConfigCommand(program: Command) { config .command("push") .description("Push a local config file to branch config") + .requiredOption("--cloud-project-id ", "Cloud project ID to push config to") .requiredOption("--config-file ", "Path to config file (.js or .ts)") .action(async (opts) => { - const flags = program.opts(); - const auth = resolveAuth(flags); + const auth = resolveAuth(opts.cloudProjectId); const filePath = path.resolve(opts.configFile); const ext = path.extname(filePath); diff --git a/packages/stack-cli/src/commands/exec.test.ts b/packages/stack-cli/src/commands/exec.test.ts index a97c3e5e8c..eaec597565 100644 --- a/packages/stack-cli/src/commands/exec.test.ts +++ b/packages/stack-cli/src/commands/exec.test.ts @@ -1,40 +1,28 @@ import { describe, expect, it } from "vitest"; -import { resolveExecTarget } from "./exec.js"; +import { parseExecTarget } from "./exec.js"; -describe("resolveExecTarget", () => { - it("defaults to local when --cloud is not passed and the env var is unset", () => { - expect(resolveExecTarget({}, {})).toBe("local"); +describe("parseExecTarget", () => { + it("returns a cloud target when --cloud-project-id is set", () => { + expect(parseExecTarget({ cloudProjectId: "proj_123" })).toEqual({ kind: "cloud", projectId: "proj_123" }); }); - it("treats an empty STACK_EXEC_DEFAULT_TARGET as unset", () => { - expect(resolveExecTarget({}, { STACK_EXEC_DEFAULT_TARGET: "" })).toBe("local"); + it("returns a config target when --config-file is set", () => { + expect(parseExecTarget({ configFile: "./stack.config.ts" })).toEqual({ kind: "config", configFile: "./stack.config.ts" }); }); - it("respects STACK_EXEC_DEFAULT_TARGET=cloud", () => { - expect(resolveExecTarget({}, { STACK_EXEC_DEFAULT_TARGET: "cloud" })).toBe("cloud"); + it("rejects passing both --cloud-project-id and --config-file", () => { + expect(() => parseExecTarget({ cloudProjectId: "proj_123", configFile: "./stack.config.ts" })).toThrow(/not both/); }); - it("respects STACK_EXEC_DEFAULT_TARGET=local explicitly", () => { - expect(resolveExecTarget({}, { STACK_EXEC_DEFAULT_TARGET: "local" })).toBe("local"); + it("rejects passing neither", () => { + expect(() => parseExecTarget({})).toThrow(/Specify a target/); }); - it("--cloud wins even when STACK_EXEC_DEFAULT_TARGET=local", () => { - expect(resolveExecTarget({ cloud: true }, { STACK_EXEC_DEFAULT_TARGET: "local" })).toBe("cloud"); + it("treats an empty --cloud-project-id as absent", () => { + expect(() => parseExecTarget({ cloudProjectId: "" })).toThrow(/Specify a target/); }); - it("--cloud wins when the env var is unset", () => { - expect(resolveExecTarget({ cloud: true }, {})).toBe("cloud"); - }); - - it("rejects unknown STACK_EXEC_DEFAULT_TARGET values", () => { - expect(() => resolveExecTarget({}, { STACK_EXEC_DEFAULT_TARGET: "Cloud" })).toThrow(/Invalid STACK_EXEC_DEFAULT_TARGET/); - expect(() => resolveExecTarget({}, { STACK_EXEC_DEFAULT_TARGET: "remote" })).toThrow(/Invalid STACK_EXEC_DEFAULT_TARGET/); - expect(() => resolveExecTarget({}, { STACK_EXEC_DEFAULT_TARGET: "1" })).toThrow(/Invalid STACK_EXEC_DEFAULT_TARGET/); - }); - - it("does not validate the env var when --cloud short-circuits", () => { - // --cloud is explicit, so we don't bother surfacing a typo in the env var. - // This is intentional: an invalid value shouldn't block the explicit flag. - expect(resolveExecTarget({ cloud: true }, { STACK_EXEC_DEFAULT_TARGET: "garbage" })).toBe("cloud"); + it("treats an empty --config-file as absent", () => { + expect(() => parseExecTarget({ configFile: "" })).toThrow(/Specify a target/); }); }); diff --git a/packages/stack-cli/src/commands/exec.ts b/packages/stack-cli/src/commands/exec.ts index dc106631a6..824b369a06 100644 --- a/packages/stack-cli/src/commands/exec.ts +++ b/packages/stack-cli/src/commands/exec.ts @@ -1,5 +1,8 @@ import { Command } from "commander"; +import * as fs from "fs"; +import * as path from "path"; import { isProjectAuthWithRefreshToken, resolveAuth, resolveLocalEmulatorAuth, type ProjectAuthWithRefreshToken } from "../lib/auth.js"; +import { lookupLocalEmulatorProjectIdByPath } from "../lib/local-emulator-client.js"; import { getAdminProject } from "../lib/app.js"; import { CliError } from "../lib/errors.js"; @@ -17,44 +20,61 @@ function getErrorMessage(err: unknown): string { } } -export type ExecTarget = "cloud" | "local"; +export type ExecTargetOpts = { + cloudProjectId?: string, + configFile?: string, +}; -// Decide whether `stack exec` should target the cloud API or the local emulator. -// `--cloud` always wins. Otherwise STACK_EXEC_DEFAULT_TARGET picks the default -// (local if unset). Anything other than "cloud" or "local" is rejected so a -// typo doesn't silently fall back to one branch. -export function resolveExecTarget(opts: { cloud?: boolean }, env: NodeJS.ProcessEnv): ExecTarget { - if (opts.cloud) return "cloud"; - const raw = env.STACK_EXEC_DEFAULT_TARGET; - if (raw === undefined || raw === "") return "local"; - if (raw !== "cloud" && raw !== "local") { - throw new CliError(`Invalid STACK_EXEC_DEFAULT_TARGET: ${raw}. Must be 'cloud' or 'local'.`); +export type ExecTarget = + | { kind: "cloud", projectId: string } + | { kind: "config", configFile: string }; + +// Validate that exactly one of --cloud-project-id / --config-file was provided +// and return a tagged target. Both branches are mutually exclusive; passing +// neither (or both) is rejected so the user has to make the cloud-vs-local +// choice explicit at every invocation. +export function parseExecTarget(opts: ExecTargetOpts): ExecTarget { + const hasCloud = opts.cloudProjectId != null && opts.cloudProjectId !== ""; + const hasConfig = opts.configFile != null && opts.configFile !== ""; + if (hasCloud && hasConfig) { + throw new CliError("Pass either --cloud-project-id or --config-file, not both."); + } + if (!hasCloud && !hasConfig) { + throw new CliError("Specify a target: pass --cloud-project-id for the Stack Auth cloud API, or --config-file for the local emulator."); } - return raw; + if (hasCloud) { + return { kind: "cloud", projectId: opts.cloudProjectId as string }; + } + return { kind: "config", configFile: opts.configFile as string }; } export function registerExecCommand(program: Command) { program .command("exec [javascript]") - .description("Execute JavaScript with a pre-configured StackServerApp as `stackServerApp`. Defaults to the local emulator; pass --cloud to target the Stack Auth cloud API.") - .option("--cloud", "Run against the Stack Auth cloud API instead of the local emulator") + .description("Execute JavaScript with a pre-configured StackServerApp as `stackServerApp`. Pass --cloud-project-id for the cloud API, or --config-file for the local emulator.") + .option("--cloud-project-id ", "Cloud project ID to run against (use --config-file instead for the local emulator)") + .option("--config-file ", "Path to a local emulator stack.config.ts (use --cloud-project-id instead for the cloud API)") .addHelpText("after", "\nFor available API methods, see: https://docs.stack-auth.com/docs/sdk") - .action(async (javascript: string | undefined, opts: { cloud?: boolean }) => { + .action(async (javascript: string | undefined, opts: ExecTargetOpts) => { if (javascript === undefined) { throw new CliError("Missing JavaScript argument. Use `stack exec \"\"` or `stack exec --help`."); } - const flags = program.opts(); - const target = resolveExecTarget(opts, process.env); + const target = parseExecTarget(opts); let auth: ProjectAuthWithRefreshToken; - if (target === "cloud") { - const cloudAuth = resolveAuth(flags); + if (target.kind === "cloud") { + const cloudAuth = resolveAuth(target.projectId); if (!isProjectAuthWithRefreshToken(cloudAuth)) { - throw new CliError("`stack exec --cloud` requires `stack login`. Remove STACK_SECRET_SERVER_KEY and try again."); + throw new CliError("`stack exec --cloud-project-id` requires `stack login`. Remove STACK_SECRET_SERVER_KEY and try again."); } auth = cloudAuth; } else { - auth = await resolveLocalEmulatorAuth(flags); + const absPath = path.resolve(target.configFile); + if (!fs.existsSync(absPath)) { + throw new CliError(`Config file not found: ${absPath}`); + } + const projectId = await lookupLocalEmulatorProjectIdByPath(absPath); + auth = await resolveLocalEmulatorAuth(projectId); } const project = await getAdminProject(auth); diff --git a/packages/stack-cli/src/commands/project.test.ts b/packages/stack-cli/src/commands/project.test.ts new file mode 100644 index 0000000000..0380b5ba0c --- /dev/null +++ b/packages/stack-cli/src/commands/project.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { formatProjectList, resolveProjectListSources, type ProjectListEntry } from "./project.js"; + +describe("resolveProjectListSources", () => { + it("defaults to both sources when no flag is passed", () => { + expect(resolveProjectListSources({})).toEqual({ cloud: true, dev: true }); + }); + + it("filters to cloud-only when --cloud is set", () => { + expect(resolveProjectListSources({ cloud: true })).toEqual({ cloud: true, dev: false }); + }); + + it("filters to dev-only when --dev is set", () => { + expect(resolveProjectListSources({ dev: true })).toEqual({ cloud: false, dev: true }); + }); + + it("rejects passing both flags", () => { + expect(() => resolveProjectListSources({ cloud: true, dev: true })).toThrow(/not both/); + }); +}); + +describe("formatProjectList", () => { + it("returns the empty-list sentinel when no projects are passed", () => { + expect(formatProjectList([])).toBe("No projects found."); + }); + + it("formats each project as `\\t\\t[]`", () => { + const projects: ProjectListEntry[] = [ + { id: "p1", displayName: "Cloud A", target: "cloud" }, + { id: "p2", displayName: "Local B", target: "dev" }, + ]; + expect(formatProjectList(projects)).toBe("p1\tCloud A\t[cloud]\np2\tLocal B\t[dev]"); + }); +}); diff --git a/packages/stack-cli/src/commands/project.ts b/packages/stack-cli/src/commands/project.ts index d3905063c4..2b5228c50e 100644 --- a/packages/stack-cli/src/commands/project.ts +++ b/packages/stack-cli/src/commands/project.ts @@ -1,7 +1,42 @@ 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 +// `\t\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 @@ -10,30 +45,56 @@ export function registerProjectCommand(program: Command) { project .command("list") - .description("List your owned projects") - .action(async () => { - const auth = resolveSessionAuth(); - const user = await getInternalUser(auth); - const projects = await user.listOwnedProjects(); + .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 (program.opts().json) { - console.log(JSON.stringify(projects.map((p) => ({ id: p.id, displayName: p.displayName })), null, 2)); - } else { - if (projects.length === 0) { - console.log("No projects found."); - return; + 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" }); } - for (const p of projects) { - console.log(`${p.id}\t${p.displayName}`); + } + + 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 project") + .description("Create a new cloud project") + .option("--cloud", "Confirm that this creates a cloud (not local emulator) project") .option("--display-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(); @@ -44,7 +105,7 @@ export function registerProjectCommand(program: Command) { }); if (program.opts().json) { - console.log(JSON.stringify({ id: newProject.id, displayName: newProject.displayName }, null, 2)); + console.log(JSON.stringify({ id: newProject.id, displayName: newProject.displayName, target: "cloud" }, null, 2)); } else { console.log(`Project created: ${newProject.id} (${newProject.displayName})`); } diff --git a/packages/stack-cli/src/index.ts b/packages/stack-cli/src/index.ts index b3b34179f1..6c73f91562 100644 --- a/packages/stack-cli/src/index.ts +++ b/packages/stack-cli/src/index.ts @@ -28,7 +28,6 @@ program .name("stack") .description("Stack Auth CLI") .version(pkg.version) - .option("--project-id ", "Project ID") .option("--json", "Output in JSON format"); registerLoginCommand(program); diff --git a/packages/stack-cli/src/lib/auth.ts b/packages/stack-cli/src/lib/auth.ts index f4222da1a2..14147af7d6 100644 --- a/packages/stack-cli/src/lib/auth.ts +++ b/packages/stack-cli/src/lib/auth.ts @@ -7,10 +7,6 @@ export const DEFAULT_API_URL = "https://api.stack-auth.com"; export const DEFAULT_DASHBOARD_URL = "https://app.stack-auth.com"; export const DEFAULT_PUBLISHABLE_CLIENT_KEY = process.env.STACK_CLI_PUBLISHABLE_CLIENT_KEY ?? "pck_9bbqvqsbh0gdb6smk11d71qg4ktc4rz8ya7cc69yndm7g"; -type Flags = { - projectId?: string, -}; - export type LoginConfig = { apiUrl: string, dashboardUrl: string, @@ -59,14 +55,6 @@ function resolveSecretServerKey(): string | null { return process.env.STACK_SECRET_SERVER_KEY ?? null; } -function resolveProjectId(flags: Flags): string { - const projectId = flags.projectId ?? process.env.STACK_PROJECT_ID; - if (!projectId) { - throw new AuthError("No project ID specified. Use --project-id or set STACK_PROJECT_ID."); - } - return projectId; -} - export function resolveLoginConfig(): LoginConfig { return { apiUrl: resolveApiUrl(), @@ -82,19 +70,19 @@ export function resolveSessionAuth(): SessionAuth { }; } -export function resolveAuth(flags: Flags): ProjectAuth { +export function resolveAuth(projectId: string): ProjectAuth { const secretServerKey = resolveSecretServerKey(); if (secretServerKey) { return { ...resolveLoginConfig(), - projectId: resolveProjectId(flags), + projectId, secretServerKey, }; } return { ...resolveSessionAuth(), - projectId: resolveProjectId(flags), + projectId, }; } @@ -143,7 +131,7 @@ export function localEmulatorReadyTimeoutMs(): number { async function resolveLocalEmulatorInternalPck(timeoutMs: number): Promise { const contents = await pollInternalPck(timeoutMs); if (contents === null) { - throw new AuthError(`Local emulator publishable client key not found at ${internalPckPath()} (waited ${timeoutMs}ms). Start the emulator with \`stack emulator start\`, or pass --cloud to use the cloud API.`); + throw new AuthError(`Local emulator publishable client key not found at ${internalPckPath()} (waited ${timeoutMs}ms). Start the emulator with \`stack emulator start\`.`); } return contents; } @@ -193,7 +181,7 @@ async function localEmulatorSignInWithRetry(apiUrl: string, internalPck: string, } if (performance.now() >= deadline) { const message = lastError instanceof Error ? lastError.message : String(lastError); - throw new AuthError(`Cannot reach local emulator at ${apiUrl} (after ${totalTimeoutMs}ms): ${message}. Start it with \`stack emulator start\`, or pass --cloud to use the cloud API.`); + throw new AuthError(`Cannot reach local emulator at ${apiUrl} (after ${totalTimeoutMs}ms): ${message}. Start it with \`stack emulator start\`.`); } const remaining = deadline - performance.now(); await new Promise((r) => setTimeout(r, Math.min(delay, remaining))); @@ -201,9 +189,8 @@ async function localEmulatorSignInWithRetry(apiUrl: string, internalPck: string, } } -export async function resolveLocalEmulatorAuth(flags: Flags): Promise { +export async function resolveLocalEmulatorAuth(projectId: string): Promise { const apiUrl = resolveLocalEmulatorApiUrl(); - const projectId = resolveProjectId(flags); const readyTimeoutMs = localEmulatorReadyTimeoutMs(); const internalPck = await resolveLocalEmulatorInternalPck(readyTimeoutMs); diff --git a/packages/stack-cli/src/lib/local-emulator-client.ts b/packages/stack-cli/src/lib/local-emulator-client.ts new file mode 100644 index 0000000000..68f76254ee --- /dev/null +++ b/packages/stack-cli/src/lib/local-emulator-client.ts @@ -0,0 +1,128 @@ +import { AuthError, CliError } from "./errors.js"; +import { isRetryableFetchError, localEmulatorReadyTimeoutMs, resolveLocalEmulatorApiUrl } from "./auth.js"; +import { internalPckPath, pollInternalPck } from "./emulator-paths.js"; + +const PER_REQUEST_TIMEOUT_MS = 5_000; + +export type LocalEmulatorProjectListEntry = { + projectId: string, + absoluteFilePath: string, + displayName: string, +}; + +async function getInternalPck(timeoutMs: number): Promise { + const contents = await pollInternalPck(timeoutMs); + if (contents === null) { + throw new AuthError(`Local emulator publishable client key not found at ${internalPckPath()} (waited ${timeoutMs}ms). Start the emulator with \`stack emulator start\`.`); + } + return contents; +} + +async function fetchWithRetry(url: string, init: RequestInit, totalTimeoutMs: number): Promise { + const deadline = performance.now() + totalTimeoutMs; + let delay = 100; + let lastError: unknown = null; + while (true) { + const remainingForRequest = Math.max(1, deadline - performance.now()); + const perRequestTimeoutMs = Math.min(PER_REQUEST_TIMEOUT_MS, remainingForRequest); + try { + return await fetch(url, { ...init, signal: AbortSignal.timeout(perRequestTimeoutMs) }); + } catch (err) { + if (!isRetryableFetchError(err)) throw err; + lastError = err; + } + if (performance.now() >= deadline) { + const message = lastError instanceof Error ? lastError.message : String(lastError); + throw new AuthError(`Cannot reach local emulator at ${url} (after ${totalTimeoutMs}ms): ${message}. Start it with \`stack emulator start\`.`); + } + const remaining = deadline - performance.now(); + await new Promise((r) => setTimeout(r, Math.min(delay, remaining))); + delay = Math.min(delay * 2, 1_000); + } +} + +type ListResponseBody = { + projects: Array<{ + project_id: string, + absolute_file_path: string, + display_name: string, + }>, +}; + +function isListResponseBody(value: unknown): value is ListResponseBody { + if (value === null || typeof value !== "object") return false; + const projects = (value as { projects?: unknown }).projects; + if (!Array.isArray(projects)) return false; + return projects.every((p) => + p !== null + && typeof p === "object" + && typeof (p as { project_id?: unknown }).project_id === "string" + && typeof (p as { absolute_file_path?: unknown }).absolute_file_path === "string" + && typeof (p as { display_name?: unknown }).display_name === "string" + ); +} + +export async function listLocalEmulatorProjects(): Promise { + const apiUrl = resolveLocalEmulatorApiUrl(); + const readyTimeoutMs = localEmulatorReadyTimeoutMs(); + const internalPck = await getInternalPck(readyTimeoutMs); + + const res = await fetchWithRetry( + `${apiUrl}/api/latest/internal/local-emulator/project`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + "X-Stack-Project-Id": "internal", + "X-Stack-Access-Type": "client", + "X-Stack-Publishable-Client-Key": internalPck, + }, + }, + readyTimeoutMs, + ); + + if (!res.ok) { + let body: string; + try { + body = await res.text(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new AuthError(`Local emulator project list failed (${res.status} ${res.statusText}). Failed to read response body: ${message}. Make sure the emulator is running with NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR=true.`); + } + throw new AuthError(`Local emulator project list failed (${res.status} ${res.statusText})${body ? `: ${body}` : ""}. Make sure the emulator is running with NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR=true.`); + } + + let data: unknown; + try { + data = await res.json(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new AuthError(`Local emulator project list returned a non-JSON response: ${message}.`); + } + if (!isListResponseBody(data)) { + throw new AuthError("Local emulator project list response had an unexpected shape."); + } + + return data.projects.map((p) => ({ + projectId: p.project_id, + absoluteFilePath: p.absolute_file_path, + displayName: p.display_name, + })); +} + +// Pure resolver, exported for unit tests. +export function findProjectByAbsolutePath( + projects: LocalEmulatorProjectListEntry[], + absolutePath: string, +): LocalEmulatorProjectListEntry | null { + return projects.find((p) => p.absoluteFilePath === absolutePath) ?? null; +} + +export async function lookupLocalEmulatorProjectIdByPath(absolutePath: string): Promise { + const projects = await listLocalEmulatorProjects(); + const match = findProjectByAbsolutePath(projects, absolutePath); + if (!match) { + throw new CliError(`No local emulator project registered for ${absolutePath}. Open it in the dashboard or run \`stack init\` from that directory first.`); + } + return match.projectId; +}