From 222b6151faaeb78d9cc3c73e7aaeb68fbf3ecbe6 Mon Sep 17 00:00:00 2001 From: Konsti Wohlwend Date: Fri, 29 May 2026 13:12:44 -0700 Subject: [PATCH] Remove stack emulator CLI commands (#1522) --- docs-mintlify/guides/going-further/cli.mdx | 18 +- packages/js/package.json | 2 +- packages/react/package.json | 2 +- .../stack-cli/scripts/copy-runtime-assets.mjs | 23 +- .../stack-cli/src/commands/emulator.test.ts | 226 ----- packages/stack-cli/src/commands/emulator.ts | 949 ------------------ packages/stack-cli/src/commands/exec.ts | 8 +- packages/stack-cli/src/commands/init.ts | 23 +- packages/stack-cli/src/commands/project.ts | 8 +- packages/stack-cli/src/index.ts | 2 - packages/stack-cli/src/lib/auth.ts | 22 +- packages/stack-cli/src/lib/emulator-paths.ts | 33 +- .../src/lib/local-emulator-client.ts | 14 +- packages/stack/package.json | 2 +- packages/tanstack-start/package.json | 2 +- packages/template/package.json | 2 +- 16 files changed, 42 insertions(+), 1294 deletions(-) delete mode 100644 packages/stack-cli/src/commands/emulator.test.ts delete mode 100644 packages/stack-cli/src/commands/emulator.ts diff --git a/docs-mintlify/guides/going-further/cli.mdx b/docs-mintlify/guides/going-further/cli.mdx index 7b015bb89..3b3c63363 100644 --- a/docs-mintlify/guides/going-further/cli.mdx +++ b/docs-mintlify/guides/going-further/cli.mdx @@ -1,6 +1,6 @@ --- title: "Stack CLI" -description: "Use the Hexclave CLI to initialize projects, manage config, run admin scripts, and start the local emulator" +description: "Use the Hexclave CLI to initialize projects, manage config, and run admin scripts" sidebarTitle: "Stack CLI" --- @@ -8,7 +8,7 @@ import { HexclaveAgentReminders } from "/snippets/hexclave-agent-reminders.jsx"; -The Hexclave CLI is published as `@hexclave/cli` and exposes the `stack` command. Use it for project setup, branch config workflows, quick admin scripts, and the [local emulator](/guides/going-further/local-emulator). +The Hexclave CLI is published as `@hexclave/cli` and exposes the `stack` command. Use it for project setup, branch config workflows, and quick admin scripts. ## Install @@ -72,7 +72,7 @@ Run the interactive setup wizard: stack init ``` -The wizard can create a cloud project, create a local config file for the emulator, or link an existing project. In interactive terminals, it can run an agent to apply the setup changes to your app. Use `--no-agent` to print manual setup instructions instead. +The wizard can create a cloud project, create a local config file, or link an existing project. In interactive terminals, it can run an agent to apply the setup changes to your app. Use `--no-agent` to print manual setup instructions instead. ### Non-interactive init @@ -148,15 +148,3 @@ The JavaScript is executed as an async function. Return values are printed as fo `stack exec` has server-level access to the selected project. It requires `stack login` and intentionally rejects `STACK_SECRET_SERVER_KEY` auth. - -## Local emulator commands - -The CLI also manages the QEMU local emulator: - -```sh title="Terminal" -stack emulator start -stack emulator status -stack emulator stop -``` - -See the [local emulator guide](/guides/going-further/local-emulator) for setup, ports, environment variables, and troubleshooting. diff --git a/packages/js/package.json b/packages/js/package.json index db7a74532..327f4c184 100644 --- a/packages/js/package.json +++ b/packages/js/package.json @@ -2,7 +2,7 @@ "//": "THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY, INSTEAD EDIT THE CORRESPONDING FILE IN packages/template (FOR package.json FILES, PLEASE EDIT package-template.json)", "name": "@stackframe/js", "version": "2.8.109", - "repository": "https://github.com/hexclave/stack-auth", + "repository": "https://github.com/hexclave/hexclave", "sideEffects": false, "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/react/package.json b/packages/react/package.json index 37c215188..48cd01496 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -2,7 +2,7 @@ "//": "THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY, INSTEAD EDIT THE CORRESPONDING FILE IN packages/template (FOR package.json FILES, PLEASE EDIT package-template.json)", "name": "@stackframe/react", "version": "2.8.109", - "repository": "https://github.com/hexclave/stack-auth", + "repository": "https://github.com/hexclave/hexclave", "sideEffects": false, "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/stack-cli/scripts/copy-runtime-assets.mjs b/packages/stack-cli/scripts/copy-runtime-assets.mjs index cc222b3ce..e7af07be8 100644 --- a/packages/stack-cli/scripts/copy-runtime-assets.mjs +++ b/packages/stack-cli/scripts/copy-runtime-assets.mjs @@ -1,21 +1,16 @@ #!/usr/bin/env node -import { execFileSync } from "child_process"; -import { chmodSync, cpSync, existsSync, mkdirSync, readlinkSync, readdirSync, rmSync } from "fs"; +import { cpSync, existsSync, readlinkSync, readdirSync, rmSync } from "fs"; import { dirname, join, relative, resolve } from "path"; import { fileURLToPath } from "url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const packageRoot = resolve(__dirname, ".."); const repoRoot = resolve(packageRoot, "../.."); -const qemuSrc = resolve(repoRoot, "docker/local-emulator/qemu"); -const envGenScript = resolve(repoRoot, "docker/local-emulator/generate-env-development.mjs"); -const envSrc = resolve(repoRoot, "docker/local-emulator/.env.development"); const dashboardRoot = resolve(repoRoot, "apps/dashboard"); const dashboardStandaloneSrc = join(dashboardRoot, ".next/standalone"); const dashboardStaticSrc = join(dashboardRoot, ".next/static"); const dashboardPublicSrc = join(dashboardRoot, "public"); const distDir = join(packageRoot, "dist"); -const emulatorDist = join(distDir, "emulator"); const dashboardDist = join(distDir, "dashboard"); function assertExists(path, message) { @@ -24,21 +19,6 @@ function assertExists(path, message) { } } -function copyEmulatorAssets() { - execFileSync(process.execPath, [envGenScript], { stdio: "inherit" }); - - mkdirSync(emulatorDist, { recursive: true }); - - for (const name of ["run-emulator.sh", "common.sh", "cloud-init"]) { - cpSync(join(qemuSrc, name), join(emulatorDist, name), { recursive: true }); - } - - chmodSync(join(emulatorDist, "run-emulator.sh"), 0o755); - cpSync(envSrc, join(distDir, ".env.development")); - - console.log(`Copied emulator assets into ${emulatorDist} (+ .env.development into ${distDir}).`); -} - function shouldCopyDashboardFile(path) { return existsSync(path); } @@ -111,5 +91,4 @@ function copyDashboardAssets() { console.log(`Copied dashboard standalone runtime into ${dashboardDist}.`); } -copyEmulatorAssets(); copyDashboardAssets(); diff --git a/packages/stack-cli/src/commands/emulator.test.ts b/packages/stack-cli/src/commands/emulator.test.ts deleted file mode 100644 index f5a6c82ab..000000000 --- a/packages/stack-cli/src/commands/emulator.test.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { emulatorBackendPort, emulatorDashboardPort, envPort } from "../lib/emulator-paths.js"; -import { - formatBytes, - formatDuration, - platformInstallHint, - renderProgressLine, - resolveArch, - splitEmulatorCommandArgs, -} from "./emulator.js"; - -describe("formatBytes", () => { - it("renders B / KB / MB / GB across unit boundaries", () => { - expect(formatBytes(0)).toBe("0 B"); - expect(formatBytes(1)).toBe("1 B"); - expect(formatBytes(1023)).toBe("1023 B"); - expect(formatBytes(1024)).toBe("1.0 KB"); - expect(formatBytes(1536)).toBe("1.5 KB"); - expect(formatBytes(1024 * 1024)).toBe("1.0 MB"); - expect(formatBytes(1024 * 1024 * 1024)).toBe("1.0 GB"); - expect(formatBytes(1024 * 1024 * 1024 * 1024)).toBe("1.0 TB"); - }); - - it("switches precision at v>=10 within a unit", () => { - expect(formatBytes(1024 * 10)).toBe("10 KB"); - expect(formatBytes(1024 * 9.5)).toBe("9.5 KB"); - }); - - it("returns '?' for non-finite and negative values", () => { - expect(formatBytes(NaN)).toBe("?"); - expect(formatBytes(Infinity)).toBe("?"); - expect(formatBytes(-1)).toBe("?"); - }); - - it("caps at TB for very large values", () => { - // Even if we exceed TB, we don't walk off the end of the units array. - const huge = 1024 ** 6; // exabyte-scale - expect(formatBytes(huge)).toMatch(/ TB$/); - }); -}); - -describe("formatDuration", () => { - it("uses s/m/h units at the right boundaries", () => { - expect(formatDuration(0)).toBe("0s"); - expect(formatDuration(59)).toBe("59s"); - expect(formatDuration(60)).toBe("1m00s"); - expect(formatDuration(61)).toBe("1m01s"); - expect(formatDuration(3599)).toBe("59m59s"); - expect(formatDuration(3600)).toBe("1h00m"); - expect(formatDuration(3660)).toBe("1h01m"); - }); - - it("rounds seconds to integers", () => { - expect(formatDuration(59.4)).toBe("59s"); - expect(formatDuration(59.9)).toBe("1m00s"); - }); - - it("returns '?' for non-finite and negative values", () => { - expect(formatDuration(NaN)).toBe("?"); - expect(formatDuration(Infinity)).toBe("?"); - expect(formatDuration(-1)).toBe("?"); - }); -}); - -describe("renderProgressLine", () => { - it("renders a known-size progress bar with percent, size, speed, and ETA", () => { - const line = renderProgressLine(1024, 2048, 512); - expect(line).toContain("50.0%"); - expect(line).toContain("/"); - expect(line).toContain("/s"); - expect(line).toContain("eta"); - }); - - it("hides the percent / ETA fields when total size is unknown (total=0)", () => { - const line = renderProgressLine(1024, 0, 512); - expect(line).not.toContain("%"); - expect(line).not.toContain("eta"); - expect(line).toContain("/s"); - }); - - it("clamps percent at 100 if downloaded overshoots total (rounding)", () => { - const line = renderProgressLine(2050, 2048, 100); - expect(line).toContain("100.0%"); - }); - - it("handles bytesPerSec = 0 by suppressing ETA", () => { - const line = renderProgressLine(512, 2048, 0); - expect(line).not.toContain("eta"); - }); -}); - -describe("envPort", () => { - const SAVED = process.env.__TEST_PORT; - beforeEach(() => { - delete process.env.__TEST_PORT; - }); - afterEach(() => { - if (SAVED === undefined) delete process.env.__TEST_PORT; - else process.env.__TEST_PORT = SAVED; - }); - - it("returns the fallback when the env var is not set", () => { - expect(envPort("__TEST_PORT", 1234)).toBe(1234); - }); - - it("parses a valid integer value", () => { - process.env.__TEST_PORT = "9876"; - expect(envPort("__TEST_PORT", 1234)).toBe(9876); - }); - - it("rejects zero and negative values", () => { - process.env.__TEST_PORT = "0"; - expect(() => envPort("__TEST_PORT", 1234)).toThrow(/Invalid __TEST_PORT/); - process.env.__TEST_PORT = "-5"; - expect(() => envPort("__TEST_PORT", 1234)).toThrow(/Invalid __TEST_PORT/); - }); - - it("rejects non-integer and non-numeric values", () => { - process.env.__TEST_PORT = "3.14"; - expect(() => envPort("__TEST_PORT", 1234)).toThrow(/Invalid __TEST_PORT/); - process.env.__TEST_PORT = "not-a-port"; - expect(() => envPort("__TEST_PORT", 1234)).toThrow(/Invalid __TEST_PORT/); - }); - - it("treats empty string as not set (returns fallback)", () => { - // Regression target: earlier versions sometimes parsed "" as 0 and threw. - process.env.__TEST_PORT = ""; - expect(envPort("__TEST_PORT", 1234)).toBe(1234); - }); -}); - -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"); - expect(resolveArch("amd64")).toBe("amd64"); - }); - - it("throws on unsupported explicit arch", () => { - expect(() => resolveArch("mips")).toThrow(/Invalid architecture/); - expect(() => resolveArch("x86")).toThrow(/Invalid architecture/); - }); - - it("maps the current process arch when raw is undefined", () => { - const expected = process.arch === "arm64" ? "arm64" : process.arch === "x64" ? "amd64" : null; - if (expected === null) { - expect(() => resolveArch()).toThrow(/Invalid architecture/); - } else { - expect(resolveArch()).toBe(expected); - } - }); -}); - -describe("splitEmulatorCommandArgs", () => { - it("splits the command from its arguments", () => { - expect(splitEmulatorCommandArgs(["pnpm", "dev", "--host", "127.0.0.1"])).toEqual({ - command: "pnpm", - args: ["dev", "--host", "127.0.0.1"], - }); - }); - - it("requires a command", () => { - expect(() => splitEmulatorCommandArgs([])).toThrow(/stack emulator run -- /); - }); -}); - -describe("platformInstallHint", () => { - it("uses brew on darwin and apt on linux", () => { - const spy = vi.spyOn(process, "platform", "get"); - try { - spy.mockReturnValue("darwin"); - expect(platformInstallHint("foo-linux", "foo-mac")).toContain("brew install foo-mac"); - spy.mockReturnValue("linux"); - expect(platformInstallHint("foo-linux", "foo-mac")).toContain("apt install foo-linux"); - spy.mockReturnValue("win32"); - expect(platformInstallHint("foo-linux", "foo-mac")).toContain("install foo-mac"); - } finally { - spy.mockRestore(); - } - }); -}); diff --git a/packages/stack-cli/src/commands/emulator.ts b/packages/stack-cli/src/commands/emulator.ts deleted file mode 100644 index 4fcd05c1d..000000000 --- a/packages/stack-cli/src/commands/emulator.ts +++ /dev/null @@ -1,949 +0,0 @@ -import { execFileSync, execSync, spawn } from "child_process"; -import { Command } from "commander"; -import extract from "extract-zip"; -import { chmodSync, createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync } from "fs"; -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 { resolveConfigFilePathOption } from "../lib/config-file-path.js"; -import { writeIso } from "../lib/iso.js"; - -const DEFAULT_PORT_PREFIX = "81"; -const GITHUB_API = "https://api.github.com"; -const DEFAULT_REPO = "stack-auth/stack-auth"; -const AARCH64_FIRMWARE_PATHS = [ - "/opt/homebrew/share/qemu/edk2-aarch64-code.fd", - "/usr/share/qemu/edk2-aarch64-code.fd", - "/usr/share/AAVMF/AAVMF_CODE.fd", - "/usr/share/qemu-efi-aarch64/QEMU_EFI.fd", -]; - -async function readInternalPck(timeoutMs = 60_000): Promise { - const contents = await pollInternalPck(timeoutMs); - if (contents === null) { - throw new CliError(`Timed out waiting for emulator internal publishable client key at ${internalPckPath()}`); - } - return contents; -} - -type EmulatorCredentials = { - project_id: string, - publishable_client_key: string, - secret_server_key: string, - onboarding_status: string, - onboarding_outstanding: boolean, -}; - -type EmulatorChildOptions = { - arch?: string, - configFile?: string, -}; - -export type EmulatorChildCommand = { - command: string, - args: string[], -}; - -async function fetchEmulatorCredentials(pck: string, backendPort: number, configFile: string): Promise { - const url = `http://127.0.0.1:${backendPort}/api/v1/internal/local-emulator/project`; - const res = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-Stack-Project-Id": "internal", - "X-Stack-Access-Type": "client", - "X-Stack-Publishable-Client-Key": pck, - }, - body: JSON.stringify({ absolute_file_path: configFile }), - }); - if (!res.ok) { - throw new CliError(`Failed to initialize local emulator project (${res.status}): ${await res.text()}`); - } - const data = await res.json() as { - project_id: string, - publishable_client_key: string, - secret_server_key: string, - onboarding_status: string, - onboarding_outstanding: boolean, - }; - if ( - typeof data.project_id !== "string" - || typeof data.publishable_client_key !== "string" - || typeof data.secret_server_key !== "string" - || typeof data.onboarding_status !== "string" - || typeof data.onboarding_outstanding !== "boolean" - ) { - throw new CliError("Local emulator project endpoint returned an invalid credentials response."); - } - return { - project_id: data.project_id, - publishable_client_key: data.publishable_client_key, - secret_server_key: data.secret_server_key, - onboarding_status: data.onboarding_status, - onboarding_outstanding: data.onboarding_outstanding, - }; -} - -function localEmulatorDashboardBaseUrl(): string { - const explicit = process.env.STACK_LOCAL_EMULATOR_DASHBOARD_URL; - if (explicit && explicit.trim().length > 0) { - return explicit.replace(/\/$/, ""); - } - return `http://localhost:${emulatorDashboardPort()}`; -} - -function openUrlInBrowser(url: string): boolean { - try { - if (process.platform === "darwin") { - execFileSync("open", [url], { stdio: "ignore" }); - return true; - } - if (process.platform === "win32") { - execFileSync("cmd", ["/c", "start", "", url], { stdio: "ignore" }); - return true; - } - execFileSync("xdg-open", [url], { stdio: "ignore" }); - return true; - } catch { - return false; - } -} - -function maybeOpenOnboardingPage(credentials: EmulatorCredentials): void { - if (!credentials.onboarding_outstanding) { - return; - } - const url = `${localEmulatorDashboardBaseUrl()}/new-project?project_id=${encodeURIComponent(credentials.project_id)}`; - const opened = openUrlInBrowser(url); - if (opened) { - console.log(`Onboarding is still pending for project ${credentials.project_id}. Opened: ${url}`); - } else { - console.warn(`Onboarding is still pending for project ${credentials.project_id}. Open this URL manually: ${url}`); - } -} - -export function splitEmulatorCommandArgs(commandArgs: string[], commandName = "run"): EmulatorChildCommand { - if (commandArgs.length === 0) { - throw new CliError(`Missing command. Usage: stack emulator ${commandName} -- [args...]`); - } - const command = commandArgs[0]; - return { command, args: commandArgs.slice(1) }; -} - -// Resolve a GitHub auth token. We try GITHUB_TOKEN first so users can pin a -// PAT, then fall back to `gh auth token` if the gh CLI is installed and -// signed in. If neither works we return undefined — public release downloads -// still work (anonymous, lower rate limit) but artifact downloads fail with a -// clear error at the call site. -function githubToken(): string | undefined { - if (process.env.GITHUB_TOKEN) return process.env.GITHUB_TOKEN; - try { - const out = execFileSync("gh", ["auth", "token"], { - encoding: "utf-8", - stdio: ["pipe", "pipe", "pipe"], - }).trim(); - return out || undefined; - } catch { - return undefined; - } -} - -async function ghApi(path: string): Promise { - const token = githubToken(); - const headers: Record = { - Accept: "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - }; - if (token) headers.Authorization = `Bearer ${token}`; - const res = await fetch(`${GITHUB_API}${path}`, { headers }); - if (!res.ok) { - const body = await res.text().catch(() => ""); - const hint = res.status === 401 || res.status === 403 - ? " (set GITHUB_TOKEN or run `gh auth login` for higher rate limits / private access)" - : ""; - throw new CliError(`GitHub API ${res.status} ${res.statusText} for ${path}${hint}${body ? `: ${body.slice(0, 300)}` : ""}`); - } - return await (res.json() as Promise); -} - -function emulatorScriptsDir(): string { - const here = dirname(fileURLToPath(import.meta.url)); - const bundled = join(here, "emulator"); - if (existsSync(join(bundled, "run-emulator.sh"))) return ensureExecutable(bundled); - const repo = resolve(here, "../../../docker/local-emulator/qemu"); - if (existsSync(join(repo, "run-emulator.sh"))) return ensureExecutable(repo); - throw new CliError("Emulator scripts not found in CLI bundle."); -} - -// npm pack strips the execute bit from non-`bin` files, so restore it here. -function ensureExecutable(scriptsDir: string): string { - try { - chmodSync(join(scriptsDir, "run-emulator.sh"), 0o755); - } catch { - // best-effort - } - return scriptsDir; -} - -function baseEnvPath(): string { - // Lives one directory up from the scripts dir in both bundled and repo - // layouts (dist/.env.development vs docker/local-emulator/.env.development). - const path = resolve(emulatorScriptsDir(), "..", ".env.development"); - if (!existsSync(path)) { - throw new CliError(`Emulator base.env not found at ${path}`); - } - return path; -} - -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, - }; -} - -// Generate the runtime config ISO that the VM mounts via STACKCFG. Replaces -// the hdiutil/mkisofs/genisoimage host dep — see ../lib/iso.ts. -function prepareRuntimeConfigIso(): void { - const vmDir = join(emulatorRunDir(), "vm"); - mkdirSync(vmDir, { recursive: true }); - const portPrefix = process.env.PORT_PREFIX ?? process.env.NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX ?? DEFAULT_PORT_PREFIX; - const dashboardPort = emulatorDashboardPort(); - const backendPort = emulatorBackendPort(); - const minioPort = emulatorMinioPort(); - const inbucketPort = emulatorInbucketPort(); - const mockOAuthPort = emulatorMockOAuthPort(); - - const runtimeEnv = [ - `STACK_EMULATOR_PORT_PREFIX=${portPrefix}`, - `STACK_EMULATOR_DASHBOARD_HOST_PORT=${dashboardPort}`, - `STACK_EMULATOR_BACKEND_HOST_PORT=${backendPort}`, - `STACK_EMULATOR_MINIO_HOST_PORT=${minioPort}`, - `STACK_EMULATOR_INBUCKET_HOST_PORT=${inbucketPort}`, - `STACK_EMULATOR_MOCK_OAUTH_HOST_PORT=${mockOAuthPort}`, - `STACK_EMULATOR_VM_DIR_HOST=${vmDir}`, - "", - ].join("\n"); - const baseEnv = readFileSync(baseEnvPath()); - writeIso(join(vmDir, "runtime-config.iso"), "STACKCFG", [ - { name: "runtime.env", data: Buffer.from(runtimeEnv, "utf-8") }, - { name: "base.env", data: baseEnv }, - ]); -} - -function runEmulator(action: string, env?: Record): Promise { - const scriptsDir = emulatorScriptsDir(); - mkdirSync(emulatorRunDir(), { recursive: true }); - mkdirSync(emulatorImageDir(), { recursive: true }); - return new Promise((resolvePromise, reject) => { - const child = spawn(join(scriptsDir, "run-emulator.sh"), [action], { - stdio: "inherit", - env: emulatorSpawnEnv(env), - cwd: scriptsDir, - }); - child.on("close", (code) => code === 0 ? resolvePromise() : reject(new CliError(`run-emulator.sh ${action} exited with code ${code}`))); - child.on("error", (err) => reject(new CliError(`Failed to run run-emulator.sh: ${err.message}`))); - }); -} - -function isEmulatorRunning(): boolean { - const scriptsDir = emulatorScriptsDir(); - try { - execFileSync(join(scriptsDir, "run-emulator.sh"), ["status"], { - stdio: "pipe", - cwd: scriptsDir, - env: emulatorSpawnEnv(), - }); - return true; - } catch { - return false; - } -} - -async function startEmulator(arch: "arm64" | "amd64"): Promise { - const img = join(emulatorImageDir(), `stack-emulator-${arch}.qcow2`); - if (!existsSync(img)) { - console.log("No emulator image found. Pulling latest..."); - await pullRelease(arch); - // Capture now so this and all subsequent starts resume fast. Skipping it - // would cold-boot today plus every future start (we never auto-capture). - await captureLocalSnapshot(arch); - } - prepareRuntimeConfigIso(); - // Signal to run-emulator.sh that runtime-config.iso was written by the CLI - // via lib/iso.ts; the shell's ensure_runtime_config_iso should trust it and - // skip its own regeneration (which would otherwise require the - // hdiutil/mkisofs/genisoimage host dep the TS writer replaces). - await runEmulator("start", { EMULATOR_ARCH: arch, STACK_EMULATOR_CLI_WROTE_ISO: "1" }); -} - -function resolveEmulatorConfigFile(configFile: string | undefined): string | undefined { - if (configFile === undefined) { - return undefined; - } - return resolveConfigFilePathOption(configFile, { mustExist: true }); -} - -async function buildEmulatorChildEnv(resolvedConfigFile: string | undefined): Promise { - const childEnv: NodeJS.ProcessEnv = { ...process.env }; - if (resolvedConfigFile === undefined) { - return childEnv; - } - - const pck = await readInternalPck(); - const backendPort = emulatorBackendPort(); - const creds = await fetchEmulatorCredentials(pck, backendPort, resolvedConfigFile); - maybeOpenOnboardingPage(creds); - const apiUrl = `http://127.0.0.1:${backendPort}`; - childEnv.STACK_PROJECT_ID = creds.project_id; - childEnv.NEXT_PUBLIC_STACK_PROJECT_ID = creds.project_id; - childEnv.VITE_STACK_PROJECT_ID = creds.project_id; - childEnv.EXPO_PUBLIC_STACK_PROJECT_ID = creds.project_id; - childEnv.STACK_PUBLISHABLE_CLIENT_KEY = creds.publishable_client_key; - childEnv.NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY = creds.publishable_client_key; - childEnv.VITE_STACK_PUBLISHABLE_CLIENT_KEY = creds.publishable_client_key; - childEnv.EXPO_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY = creds.publishable_client_key; - childEnv.STACK_SECRET_SERVER_KEY = creds.secret_server_key; - childEnv.STACK_API_URL = apiUrl; - childEnv.NEXT_PUBLIC_STACK_API_URL = apiUrl; - childEnv.VITE_STACK_API_URL = apiUrl; - childEnv.EXPO_PUBLIC_STACK_API_URL = apiUrl; - return childEnv; -} - -function runChildProcess(command: string, args: string[], env: NodeJS.ProcessEnv): Promise { - return new Promise((resolvePromise, reject) => { - const child = spawn(command, args, { stdio: "inherit", env }); - - const forward = (signal: NodeJS.Signals) => () => child.kill(signal); - const onSigint = forward("SIGINT"); - const onSigterm = forward("SIGTERM"); - const cleanup = () => { - process.off("SIGINT", onSigint); - process.off("SIGTERM", onSigterm); - }; - - process.on("SIGINT", onSigint); - process.on("SIGTERM", onSigterm); - - child.on("close", (code) => { - cleanup(); - resolvePromise(code ?? 1); - }); - child.on("error", (err) => { - cleanup(); - reject(new CliError(`Failed to run ${command}: ${err.message}`)); - }); - }); -} - -async function stopEmulatorAfterChild(): Promise { - console.log("\nStopping emulator..."); - try { - await runEmulator("stop"); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - process.stderr.write(`Failed to stop emulator cleanly: ${msg}\n`); - } -} - -async function runWithLocalEmulator( - commandName: string, - opts: EmulatorChildOptions, - runChild: (env: NodeJS.ProcessEnv) => Promise, -): Promise { - const arch = resolveArch(opts.arch); - preflightForVmStart(commandName, arch); - const resolvedConfigFile = resolveEmulatorConfigFile(opts.configFile); - - let startedByThisCommand = false; - const exitCode = await (async () => { - try { - if (isEmulatorRunning()) { - console.log("Emulator already running, reusing existing instance."); - } else { - await startEmulator(arch); - startedByThisCommand = true; - } - - const childEnv = await buildEmulatorChildEnv(resolvedConfigFile); - return await runChild(childEnv); - } finally { - if (startedByThisCommand) { - await stopEmulatorAfterChild(); - } - } - })(); - - process.exit(exitCode); -} - -function printEmulatorWelcome(): void { - const dashboardPort = emulatorDashboardPort(); - const backendPort = emulatorBackendPort(); - const inbucketPort = emulatorInbucketPort(); - - console.log("\nEmulator is up.\n"); - console.log("The Hexclave emulator runs a full local Hexclave stack (backend, dashboard,"); - console.log("Postgres, Redis, MinIO, and a test mail server) inside a VM on your machine."); - console.log("It gives you an offline, disposable Hexclave you can develop against — no"); - console.log("cloud account needed, and you can reset it any time.\n"); - console.log("Services:"); - console.log(` • Local dashboard http://localhost:${dashboardPort}`); - console.log(` • Backend API http://localhost:${backendPort}`); - console.log(` • Test inbox http://localhost:${inbucketPort} (catches all outbound email)`); - console.log(""); - console.log("Common commands:"); - console.log(" stack emulator status Check service health"); - console.log(" stack emulator stop Stop the VM (keeps data)"); - console.log(" stack emulator reset Wipe all state and start fresh"); - console.log(" stack emulator run -- Start the emulator, run , stop on exit"); - console.log(""); -} - -export function isEmulatorImageInstalled(arch?: "arm64" | "amd64"): boolean { - try { - const resolvedArch = arch ?? resolveArch(); - return existsSync(join(emulatorImageDir(), `stack-emulator-${resolvedArch}.qcow2`)); - } catch { - return false; - } -} - -export function resolveArch(raw?: string): "arm64" | "amd64" { - const arch = raw ?? (process.arch === "arm64" ? "arm64" : process.arch === "x64" ? "amd64" : null); - if (arch === "arm64" || arch === "amd64") return arch; - throw new CliError(`Invalid architecture: ${raw ?? process.arch}. Expected arm64 or amd64.`); -} - -type ReleaseAsset = { name: string, url: string, size: number }; -type ReleaseResponse = { assets: ReleaseAsset[] }; - -async function pullRelease(arch: "arm64" | "amd64", opts: { repo?: string, branch?: string, tag?: string } = {}) { - const repo = opts.repo ?? DEFAULT_REPO; - const branch = opts.branch ?? "dev"; - const tag = opts.tag ?? `emulator-${branch}-latest`; - const imageDir = emulatorImageDir(); - mkdirSync(imageDir, { recursive: true }); - - const diskAsset = `stack-emulator-${arch}.qcow2`; - - const release = await ghApi(`/repos/${repo}/releases/tags/${tag}`); - const diskMatch = release.assets.find((a) => a.name === diskAsset); - if (!diskMatch) { - throw new CliError(`Asset ${diskAsset} not found in release ${tag}. Run 'stack emulator list-releases' to see available releases.`); - } - const token = githubToken(); - await downloadReleaseAsset(diskMatch, imageDir, diskAsset, token, tag); -} - -// Cold-boot the VM, wait for services, capture a snapshot via QMP, compress, -// stop. Runs once per qcow2 download so subsequent `stack emulator start`s -// resume in ~3-8s. Snapshots are always captured on the user's own machine -// because QEMU migration state isn't portable across accelerators -// (KVM/HVF/TCG) or `-cpu max` feature sets. -async function captureLocalSnapshot(arch: "arm64" | "amd64"): Promise { - preflightForVmStart("pull", arch); - prepareRuntimeConfigIso(); - console.log("Capturing local snapshot (first-time, ~1-3 min cold boot + capture)..."); - await runEmulator("capture", { EMULATOR_ARCH: arch }); -} - -async function downloadReleaseAsset( - match: ReleaseAsset, - imageDir: string, - asset: string, - token: string | undefined, - tag: string, -): Promise { - const dest = join(imageDir, asset); - const tmpDest = `${dest}.download`; - console.log(`Pulling ${asset} from release ${tag}...`); - const headers: Record = { Accept: "application/octet-stream" }; - if (token) headers.Authorization = `Bearer ${token}`; - try { - await downloadWithProgress(match.url, headers, tmpDest, match.size); - } catch (err) { - if (existsSync(tmpDest)) unlinkSync(tmpDest); - if (err instanceof CliError) throw err; - throw new CliError(`Failed to download ${asset} from release ${tag}: ${err instanceof Error ? err.message : err}`); - } - renameSync(tmpDest, dest); - console.log(`Downloaded: ${dest}`); -} - -async function downloadWithProgress(url: string, headers: Record, dest: string, totalBytes?: number): Promise { - const res = await fetch(url, { headers, redirect: "follow" }); - if (!res.ok || !res.body) { - throw new CliError(`Download failed (${res.status} ${res.statusText}): ${url}`); - } - const total = totalBytes ?? (Number(res.headers.get("content-length")) || 0); - const isTty = Boolean(process.stderr.isTTY); - const startedAt = Date.now(); - let downloaded = 0; - let lastRender = 0; - - const render = (final: boolean) => { - const now = Date.now(); - if (!final && now - lastRender < 100) return; - lastRender = now; - const elapsed = Math.max(0.001, (now - startedAt) / 1000); - const speed = downloaded / elapsed; - const line = renderProgressLine(downloaded, total, speed); - if (isTty) { - process.stderr.write(`\r\x1b[2K${line}`); - } else if (final) { - process.stderr.write(`${line}\n`); - } - }; - - const body = Readable.fromWeb(res.body as Parameters[0]); - body.on("data", (chunk: Buffer) => { - downloaded += chunk.byteLength; - render(false); - }); - await pipeline(body, createWriteStream(dest)); - render(true); - if (isTty) process.stderr.write("\n"); -} - -export function renderProgressLine(downloaded: number, total: number, bytesPerSec: number): string { - const barWidth = 30; - const pct = total > 0 ? Math.min(100, (downloaded / total) * 100) : 0; - const filled = total > 0 ? Math.round((downloaded / total) * barWidth) : 0; - const bar = "█".repeat(filled) + "░".repeat(Math.max(0, barWidth - filled)); - const pctStr = total > 0 ? `${pct.toFixed(1).padStart(5)}%` : " ? "; - const sizeStr = total > 0 ? `${formatBytes(downloaded)}/${formatBytes(total)}` : formatBytes(downloaded); - const speedStr = `${formatBytes(bytesPerSec)}/s`; - const etaStr = total > 0 && bytesPerSec > 0 ? ` eta ${formatDuration((total - downloaded) / bytesPerSec)}` : ""; - return ` [${bar}] ${pctStr} ${sizeStr} ${speedStr}${etaStr}`; -} - -export function formatBytes(bytes: number): string { - if (!Number.isFinite(bytes) || bytes < 0) return "?"; - const units = ["B", "KB", "MB", "GB", "TB"]; - let v = bytes; - let i = 0; - while (v >= 1024 && i < units.length - 1) { - v /= 1024; - i++; - } - return `${v.toFixed(v < 10 && i > 0 ? 1 : 0)} ${units[i]}`; -} - -export function formatDuration(seconds: number): string { - if (!Number.isFinite(seconds) || seconds < 0) return "?"; - const s = Math.round(seconds); - if (s < 60) return `${s}s`; - const m = Math.floor(s / 60); - const rs = s % 60; - if (m < 60) return `${m}m${rs.toString().padStart(2, "0")}s`; - const h = Math.floor(m / 60); - const rm = m % 60; - return `${h}h${rm.toString().padStart(2, "0")}m`; -} - -// --- Dependency preflight --------------------------------------------------- - -type BinarySpec = { name: string, linuxPkg: string, macPkg: string }; - -function commandExists(bin: string): boolean { - try { - execFileSync(process.platform === "win32" ? "where" : "which", [bin], { stdio: "pipe" }); - return true; - } catch { - return false; - } -} - -export function platformInstallHint(linuxPkg: string, macPkg: string): string { - switch (process.platform) { - case "darwin": { - return `brew install ${macPkg}`; - } - case "linux": { - return `apt install ${linuxPkg} (or your distro's equivalent)`; - } - default: { - return `install ${macPkg}`; - } - } -} - -function bin(name: string, linuxPkg: string, macPkg: string): BinarySpec { - return { name, linuxPkg, macPkg }; -} - -function installHint(b: BinarySpec): string { - return platformInstallHint(b.linuxPkg, b.macPkg); -} - -function requireBinaries(commandName: string, bins: BinarySpec[]): void { - const missing = bins.filter((b) => !commandExists(b.name)); - if (missing.length === 0) return; - const lines = missing.map((b) => ` - ${b.name} → ${installHint(b)}`); - throw new CliError( - `\`stack emulator ${commandName}\` requires the following missing binaries:\n${lines.join("\n")}`, - ); -} - -function warnIfMissing(commandName: string, bins: BinarySpec[]): void { - const missing = bins.filter((b) => !commandExists(b.name)); - if (missing.length === 0) return; - for (const b of missing) { - console.warn(`[stack emulator ${commandName}] optional dep '${b.name}' missing — feature degraded. Install: ${installHint(b)}`); - } -} - -async function confirmPrompt(question: string): Promise { - if (!process.stdin.isTTY) { - throw new CliError("Cannot prompt for confirmation: stdin is not a TTY. Install the missing dependencies manually and retry."); - } - const rl = createInterface({ input: process.stdin, output: process.stdout }); - return await new Promise((resolvePromise) => { - rl.question(`${question} [y/N] `, (answer) => { - rl.close(); - resolvePromise(/^y(es)?$/i.test(answer.trim())); - }); - }); -} - -async function ensureDepsForPull(arch: "arm64" | "amd64"): Promise { - const allBins = [archSpecificQemuBin(arch), ...commonVmBins(), bin("zstd", "zstd", "zstd")]; - const missingBins = allBins.filter((b) => !commandExists(b.name)); - const firmwareMissing = arch === "arm64" && !aarch64FirmwareAvailable(); - if (missingBins.length === 0 && !firmwareMissing) return; - - const platform = process.platform; - // Auto-install targets macOS (brew) and Debian/Ubuntu-family Linux - // (apt-get). On other distros or platforms, fall back to the standard - // per-binary install hints. - const linuxHasApt = platform === "linux" && commandExists("apt-get"); - if (platform !== "darwin" && !linuxHasApt) { - preflightForVmStart("pull", arch); - return; - } - - // In non-interactive environments (CI, piped stdin) we cannot prompt, so - // surface the standard per-binary install hints instead of erroring with - // only a TTY complaint. - if (!process.stdin.isTTY) { - preflightForVmStart("pull", arch); - return; - } - - console.log("The emulator needs the following dependencies that aren't installed:"); - for (const b of missingBins) console.log(` - ${b.name}`); - if (firmwareMissing) console.log(" - aarch64 UEFI firmware"); - console.log(); - - const pkgs = new Set(); - for (const b of missingBins) { - pkgs.add(platform === "darwin" ? b.macPkg : b.linuxPkg); - } - // macOS qemu formula bundles the aarch64 firmware; Linux needs a separate package. - if (firmwareMissing && platform === "linux") pkgs.add("qemu-efi-aarch64"); - // Edge case: on macOS arm64, firmware can be missing while all binaries - // are present (e.g. a partial qemu install). Reinstalling `qemu` recreates - // the bundled firmware files. - if (firmwareMissing && platform === "darwin") pkgs.add("qemu"); - const pkgList = Array.from(pkgs).sort(); - if (pkgList.length === 0) { - preflightForVmStart("pull", arch); - return; - } - - const brewMissing = platform === "darwin" && !commandExists("brew"); - console.log("Proposed install plan:"); - if (brewMissing) { - console.log(" - install Homebrew by running the official installer:"); - console.log(" /bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\""); - console.log(" (executes remote code from raw.githubusercontent.com — review https://brew.sh if unsure)"); - } - if (platform === "darwin") console.log(` - brew install ${pkgList.join(" ")}`); - else console.log(` - sudo apt-get update && sudo apt-get install -y ${pkgList.join(" ")}`); - console.log(); - - const ok = await confirmPrompt("Proceed with install?"); - if (!ok) { - throw new CliError("Dependency install declined. Install the missing packages manually and retry."); - } - - if (brewMissing) { - console.log("\nInstalling Homebrew..."); - execSync('/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"', { - stdio: "inherit", - }); - } - - console.log("\nInstalling packages..."); - if (platform === "darwin") { - // After a fresh Homebrew bootstrap, `brew` lives at /opt/homebrew/bin - // (Apple Silicon) or /usr/local/bin (Intel); the installer only updates - // shell profiles, not the current process's PATH, so resolve it by - // absolute path when needed. - const brewBin = commandExists("brew") - ? "brew" - : existsSync("/opt/homebrew/bin/brew") - ? "/opt/homebrew/bin/brew" - : "/usr/local/bin/brew"; - execFileSync(brewBin, ["install", ...pkgList], { stdio: "inherit" }); - } else { - execFileSync("sudo", ["apt-get", "update"], { stdio: "inherit" }); - execFileSync("sudo", ["apt-get", "install", "-y", ...pkgList], { stdio: "inherit" }); - } - console.log(); -} - -function aarch64FirmwareAvailable(): boolean { - return AARCH64_FIRMWARE_PATHS.some((p) => existsSync(p)); -} - -function commonVmBins(): BinarySpec[] { - return [ - bin("qemu-img", "qemu-utils", "qemu"), - bin("socat", "socat", "socat"), - bin("curl", "curl", "curl"), - bin("nc", "ncat", "netcat"), - bin("lsof", "lsof", "lsof"), - bin("openssl", "openssl", "openssl"), - ]; -} - -function archSpecificQemuBin(arch: "arm64" | "amd64"): BinarySpec { - if (arch === "arm64") { - return bin("qemu-system-aarch64", "qemu-system-arm", "qemu"); - } - return bin("qemu-system-x86_64", "qemu-system-x86", "qemu"); -} - -function preflightForVmStart(commandName: string, arch: "arm64" | "amd64"): void { - requireBinaries(commandName, [archSpecificQemuBin(arch), ...commonVmBins()]); - warnIfMissing(commandName, [bin("zstd", "zstd", "zstd")]); - if (arch === "arm64" && !aarch64FirmwareAvailable()) { - throw new CliError( - `aarch64 UEFI firmware not found. Looked in:\n${AARCH64_FIRMWARE_PATHS.map((p) => ` - ${p}`).join("\n")}\n` + - `Install: ${platformInstallHint("qemu-efi-aarch64", "qemu")}`, - ); - } -} - -// --- Workflow run / artifact downloads (replaces `gh run download`) --------- - -type WorkflowRunsResponse = { workflow_runs: { id: number }[] }; -type ArtifactsResponse = { artifacts: { id: number, name: string, size_in_bytes: number }[] }; -type PullResponse = { head: { ref: string } }; - -async function downloadArtifactByName(repo: string, runId: string, name: string, destDir: string): Promise { - const token = githubToken(); - if (!token) { - throw new CliError( - "Downloading workflow run artifacts requires authentication. Set GITHUB_TOKEN or run `gh auth login`.", - ); - } - const list = await ghApi(`/repos/${repo}/actions/runs/${runId}/artifacts?per_page=100`); - const match = list.artifacts.find((a) => a.name === name); - if (!match) return false; - const zipPath = join(destDir, `${name}.zip`); - console.log(`Downloading artifact '${name}' from run ${runId}...`); - await downloadWithProgress( - `${GITHUB_API}/repos/${repo}/actions/artifacts/${match.id}/zip`, - { Accept: "application/vnd.github+json", Authorization: `Bearer ${token}` }, - zipPath, - match.size_in_bytes, - ); - await extract(zipPath, { dir: destDir }); - unlinkSync(zipPath); - return true; -} - -export function registerEmulatorCommand(program: Command) { - const emulator = program.command("emulator").description("Manage the QEMU local emulator"); - - emulator - .command("pull") - .description("Download an emulator image from GitHub Releases or a PR build, then capture a local fast-start snapshot") - .option("--arch ", "Target architecture (default: current system arch)") - .option("--branch ", "Release branch (default: dev)") - .option("--tag ", "Specific release tag (default: latest)") - .option("--repo ", "GitHub repository (default: stack-auth/stack-auth)") - .option("--pr ", "Pull from a PR's CI artifacts") - .option("--run ", "Pull from a specific workflow run's artifacts") - .option("--skip-snapshot", "Download only the qcow2; skip the one-time local snapshot capture") - .action(async (opts: { arch?: string, repo?: string, branch?: string, tag?: string, pr?: string, run?: string, skipSnapshot?: boolean }) => { - const arch = resolveArch(opts.arch); - if (!opts.skipSnapshot) { - await ensureDepsForPull(arch); - } - const repo = opts.repo ?? DEFAULT_REPO; - - if (opts.run || opts.pr) { - let runId = opts.run; - if (!runId) { - console.log(`Finding latest successful build for PR #${opts.pr}...`); - const pr = await ghApi(`/repos/${repo}/pulls/${opts.pr}`); - const headRefName = pr.head.ref; - const runs = await ghApi( - `/repos/${repo}/actions/workflows/qemu-emulator-build.yaml/runs?branch=${encodeURIComponent(headRefName)}&status=success&per_page=1`, - ); - if (runs.workflow_runs.length === 0) { - throw new CliError(`No successful build found for PR #${opts.pr} (branch: ${headRefName}).`); - } - runId = String(runs.workflow_runs[0].id); - } - - const imageDir = emulatorImageDir(); - mkdirSync(imageDir, { recursive: true }); - const dest = join(imageDir, `stack-emulator-${arch}.qcow2`); - const snapshotDest = join(imageDir, `stack-emulator-${arch}.savevm.zst`); - const snapshotRawDest = join(imageDir, `stack-emulator-${arch}.savevm.raw`); - if (existsSync(dest)) unlinkSync(dest); - // Stale snapshots from a previous pull would resume against the new - // qcow2 and crash; wipe them so capture rebuilds cleanly. - if (existsSync(snapshotDest)) unlinkSync(snapshotDest); - if (existsSync(snapshotRawDest)) unlinkSync(snapshotRawDest); - const downloaded = await downloadArtifactByName(repo, runId, `qemu-emulator-${arch}`, imageDir); - if (!downloaded) { - throw new CliError(`Artifact qemu-emulator-${arch} not found in workflow run ${runId}.`); - } - if (!existsSync(dest)) throw new CliError(`Expected image not found at ${dest} after download.`); - console.log(`Downloaded: ${dest}`); - } else { - // Same stale-snapshot concern as the PR branch above. - const imageDir = emulatorImageDir(); - const snapshotDest = join(imageDir, `stack-emulator-${arch}.savevm.zst`); - const snapshotRawDest = join(imageDir, `stack-emulator-${arch}.savevm.raw`); - if (existsSync(snapshotDest)) unlinkSync(snapshotDest); - if (existsSync(snapshotRawDest)) unlinkSync(snapshotRawDest); - await pullRelease(arch, { repo, branch: opts.branch, tag: opts.tag }); - } - - if (opts.skipSnapshot) { - console.log("--skip-snapshot: not capturing a local snapshot. First `stack emulator start` will cold-boot."); - } else { - await captureLocalSnapshot(arch); - } - }); - - emulator - .command("start") - .description("Start the emulator in the background (auto-pulls the latest image if none exists)") - .option("--arch ", "Target architecture (default: current system arch). Non-native uses software emulation and is significantly slower.") - .option("--config-file ", "Path to a config file; when set, credentials for this project are printed to stdout as JSON") - .action(async (opts: { arch?: string, configFile?: string }) => { - const arch = resolveArch(opts.arch); - preflightForVmStart("start", arch); - - let resolvedConfigFile: string | undefined; - if (opts.configFile) { - resolvedConfigFile = resolveConfigFilePathOption(opts.configFile, { mustExist: true }); - } - - let freshlyStarted = false; - if (isEmulatorRunning()) { - console.warn("Emulator already running, reusing existing instance."); - } else { - await startEmulator(arch); - freshlyStarted = true; - } - - if (resolvedConfigFile) { - const pck = await readInternalPck(); - const creds = await fetchEmulatorCredentials(pck, emulatorBackendPort(), resolvedConfigFile); - maybeOpenOnboardingPage(creds); - console.log(JSON.stringify({ - project_id: creds.project_id, - publishable_client_key: creds.publishable_client_key, - secret_server_key: creds.secret_server_key, - }, null, 2)); - return; - } - - if (freshlyStarted) { - printEmulatorWelcome(); - } - }); - - emulator - .command("run") - .usage("[options] -- [args...]") - .description("Start the emulator, run a command, and stop the emulator when the command exits") - .argument("", "Command and arguments to run after -- (e.g. -- npm run dev)") - .option("--arch ", "Target architecture") - .option("--config-file ", "Path to a config file; fetches credentials and injects STACK_PROJECT_ID / STACK_PUBLISHABLE_CLIENT_KEY / STACK_SECRET_SERVER_KEY into the child") - .action(async (commandArgs: string[], opts: EmulatorChildOptions) => { - const childCommand = splitEmulatorCommandArgs(commandArgs); - await runWithLocalEmulator("run", opts, (env) => runChildProcess(childCommand.command, childCommand.args, env)); - }); - - emulator - .command("stop") - .description("Stop the emulator (data preserved; use 'reset' to clear)") - .action(() => { - requireBinaries("stop", [bin("socat", "socat", "socat")]); - return runEmulator("stop"); - }); - - emulator - .command("reset") - .description("Reset emulator state for a fresh boot") - .action(() => { - requireBinaries("reset", [bin("socat", "socat", "socat")]); - return runEmulator("reset"); - }); - - emulator - .command("status") - .description("Show emulator and service health") - .action(() => { - requireBinaries("status", [ - bin("curl", "curl", "curl"), - bin("nc", "ncat", "netcat"), - ]); - return runEmulator("status"); - }); - - emulator - .command("list-releases") - .description("List available emulator releases") - .option("--repo ", "GitHub repository (default: stack-auth/stack-auth)") - .action(async (opts) => { - const repo = opts.repo ?? DEFAULT_REPO; - console.log(`Available emulator releases from ${repo}:\n`); - type Release = { tag_name: string, name: string | null, published_at: string | null, draft: boolean, prerelease: boolean }; - const releases = await ghApi(`/repos/${repo}/releases?per_page=50`); - const lines = releases - .filter((r) => (r.tag_name + " " + (r.name ?? "")).toLowerCase().includes("emulator")) - .slice(0, 20) - .map((r) => { - const status = r.draft ? "Draft" : r.prerelease ? "Pre-release" : "Latest"; - const date = r.published_at ? r.published_at.slice(0, 10) : ""; - return `${r.tag_name}\t${status}\t${date}`; - }); - if (lines.length === 0) console.log("No emulator releases found."); - else for (const line of lines) console.log(line); - }); -} diff --git a/packages/stack-cli/src/commands/exec.ts b/packages/stack-cli/src/commands/exec.ts index 97192bed2..0ce8cd5f9 100644 --- a/packages/stack-cli/src/commands/exec.ts +++ b/packages/stack-cli/src/commands/exec.ts @@ -39,7 +39,7 @@ export function parseExecTarget(opts: ExecTargetOpts): ExecTarget { 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 Hexclave cloud API, or --config-file for the local emulator."); + throw new CliError("Specify a target: pass --cloud-project-id for the Hexclave cloud API, or --config-file for the development environment."); } if (hasCloud) { return { kind: "cloud", projectId: opts.cloudProjectId as string }; @@ -50,9 +50,9 @@ export function parseExecTarget(opts: ExecTargetOpts): ExecTarget { export function registerExecCommand(program: Command) { program .command("exec [javascript]") - .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)") + .description("Execute JavaScript with a pre-configured StackServerApp as `stackServerApp`. Pass --cloud-project-id for the cloud API, or --config-file for the development environment.") + .option("--cloud-project-id ", "Cloud project ID to run against (use --config-file instead for the development environment)") + .option("--config-file ", "Path to a development-environment stack.config.ts (use --cloud-project-id instead for the cloud API)") .addHelpText("after", "\nFor available API methods, see: https://docs.hexclave.com/docs/sdk") .action(async (javascript: string | undefined, opts: ExecTargetOpts) => { if (javascript === undefined) { diff --git a/packages/stack-cli/src/commands/init.ts b/packages/stack-cli/src/commands/init.ts index 8e095fead..4b545e66c 100644 --- a/packages/stack-cli/src/commands/init.ts +++ b/packages/stack-cli/src/commands/init.ts @@ -13,7 +13,6 @@ import { createInitPrompt } from "../lib/init-prompt.js"; import { createProjectInteractively } from "../lib/create-project.js"; import { runClaudeAgent } from "../lib/claude-agent.js"; import { resolveConfigFilePathOption } from "../lib/config-file-path.js"; -import { isEmulatorImageInstalled } from "./emulator.js"; import { detectImportPackageFromDir, renderConfigFileContent } from "@stackframe/stack-shared/dist/config-rendering"; import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; @@ -110,14 +109,11 @@ async function runInit(program: Command, opts: InitOptions) { mode = "link-config"; } else { console.log("Creating a new Hexclave project.\n"); - const localLabel = isEmulatorImageInstalled() - ? "Local (emulator already installed)" - : "Local (requires local emulator installation, ~1.3gb storage required)"; const location = await select({ message: "Where would you like to create the project?", choices: [ { name: "Hexclave Cloud", value: "hosted" as const }, - { name: localLabel, value: "local" as const }, + { name: "Local config file", value: "local" as const }, ], }); mode = location === "local" ? "create" : "create-cloud"; @@ -169,11 +165,7 @@ function printNextSteps(args: { mode: string, projectId?: string, dashboardUrl: console.log(" • Start your dev server, then visit /handler/sign-up to create a test user"); console.log(" (and /handler/sign-in to log in). Drop into a page to see the session."); - if (args.mode === "create") { - console.log(" • You're wired up to the local emulator. Start it in another terminal:"); - console.log(" npx @stackframe/stack-cli emulator start"); - console.log(" Local dashboard: http://localhost:26700"); - } else if (args.projectId) { + if (args.projectId != null) { console.log(" • Manage this project in the dashboard:"); console.log(` ${args.dashboardUrl}/projects/${encodeURIComponent(args.projectId)}`); } @@ -229,7 +221,6 @@ async function ensureLoggedInSession() { async function writeProjectKeysToEnv( project: { id: string, app: { createInternalApiKey: (opts: { description: string, expiresAt: Date, hasPublishableClientKey: boolean, hasSecretServerKey: boolean, hasSuperSecretAdminKey: boolean }) => Promise<{ publishableClientKey?: string | null, secretServerKey?: string | null }> } }, outputDir: string, - variant: "cloud" | "local" = "cloud", ) { const apiKey = await project.app.createInternalApiKey({ description: "Created by CLI init script", @@ -242,16 +233,8 @@ async function writeProjectKeysToEnv( const publishableClientKey = apiKey.publishableClientKey ?? throwErr("createInternalApiKey returned no publishableClientKey despite hasPublishableClientKey=true"); const secretServerKey = apiKey.secretServerKey ?? throwErr("createInternalApiKey returned no secretServerKey despite hasSecretServerKey=true"); - const header = variant === "local" - ? [ - "# Hexclave — local emulator keys", - "# These credentials point at your local Hexclave emulator, not a cloud project.", - "# They are only valid while the emulator is running (`stack emulator start`).", - ] - : ["# Hexclave"]; - const envLines = [ - ...header, + "# Hexclave", `NEXT_PUBLIC_STACK_PROJECT_ID=${project.id}`, `NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=${publishableClientKey}`, `STACK_SECRET_SERVER_KEY=${secretServerKey}`, diff --git a/packages/stack-cli/src/commands/project.ts b/packages/stack-cli/src/commands/project.ts index 2b5228c50..b6d0e0adb 100644 --- a/packages/stack-cli/src/commands/project.ts +++ b/packages/stack-cli/src/commands/project.ts @@ -45,9 +45,9 @@ export function registerProjectCommand(program: Command) { project .command("list") - .description("List your projects (defaults to both cloud and local emulator)") + .description("List your projects (defaults to both cloud and development-environment projects)") .option("--cloud", "Only list cloud projects") - .option("--dev", "Only list local emulator (dev) projects") + .option("--dev", "Only list development-environment projects") .action(async (opts: ProjectListFlags) => { const sources = resolveProjectListSources(opts); const results: ProjectListEntry[] = []; @@ -75,7 +75,7 @@ export function registerProjectCommand(program: Command) { 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\`.`); + console.error(`warning: skipping dev projects — development environment not reachable (${message}).`); } } @@ -89,7 +89,7 @@ export function registerProjectCommand(program: Command) { project .command("create") .description("Create a new cloud project") - .option("--cloud", "Confirm that this creates a cloud (not local emulator) project") + .option("--cloud", "Confirm that this creates a cloud project") .option("--display-name ", "Project display name") .action(async (opts) => { if (!opts.cloud) { diff --git a/packages/stack-cli/src/index.ts b/packages/stack-cli/src/index.ts index fdd5e2875..50e4a25b5 100644 --- a/packages/stack-cli/src/index.ts +++ b/packages/stack-cli/src/index.ts @@ -14,7 +14,6 @@ import { registerExecCommand } from "./commands/exec.js"; import { registerConfigCommand } from "./commands/config-file.js"; import { registerInitCommand } from "./commands/init.js"; import { registerProjectCommand } from "./commands/project.js"; -import { registerEmulatorCommand } from "./commands/emulator.js"; import { registerDevCommand } from "./commands/dev.js"; import { registerFixCommand } from "./commands/fix.js"; import { registerDoctorCommand } from "./commands/doctor.js"; @@ -38,7 +37,6 @@ registerExecCommand(program); registerConfigCommand(program); registerInitCommand(program); registerProjectCommand(program); -registerEmulatorCommand(program); registerDevCommand(program); registerWhoamiCommand(program); registerFixCommand(program); diff --git a/packages/stack-cli/src/lib/auth.ts b/packages/stack-cli/src/lib/auth.ts index 3ff15aeba..03b4a0b9c 100644 --- a/packages/stack-cli/src/lib/auth.ts +++ b/packages/stack-cli/src/lib/auth.ts @@ -120,11 +120,11 @@ 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). +// Per-phase budget for waiting until the development environment is ready. +// 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; @@ -143,7 +143,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\`.`); + throw new AuthError(`Development environment publishable client key not found at ${internalPckPath()} (waited ${timeoutMs}ms). Start your development environment and try again.`); } return contents; } @@ -193,7 +193,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\`.`); + throw new AuthError(`Cannot reach development environment at ${apiUrl} (after ${totalTimeoutMs}ms): ${message}. Start your development environment and try again.`); } const remaining = deadline - performance.now(); await new Promise((r) => setTimeout(r, Math.min(delay, remaining))); @@ -219,9 +219,9 @@ export async function resolveLocalEmulatorAuth(projectId: string): Promise { const pckPath = internalPckPath(); const deadline = performance.now() + timeoutMs; diff --git a/packages/stack-cli/src/lib/local-emulator-client.ts b/packages/stack-cli/src/lib/local-emulator-client.ts index 68f76254e..57b408f7c 100644 --- a/packages/stack-cli/src/lib/local-emulator-client.ts +++ b/packages/stack-cli/src/lib/local-emulator-client.ts @@ -13,7 +13,7 @@ export type LocalEmulatorProjectListEntry = { 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\`.`); + throw new AuthError(`Development environment publishable client key not found at ${internalPckPath()} (waited ${timeoutMs}ms). Start your development environment and try again.`); } return contents; } @@ -33,7 +33,7 @@ async function fetchWithRetry(url: string, init: RequestInit, totalTimeoutMs: nu } 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\`.`); + throw new AuthError(`Cannot reach development environment at ${url} (after ${totalTimeoutMs}ms): ${message}. Start your development environment and try again.`); } const remaining = deadline - performance.now(); await new Promise((r) => setTimeout(r, Math.min(delay, remaining))); @@ -87,9 +87,9 @@ export async function listLocalEmulatorProjects(): Promise ({ @@ -122,7 +122,7 @@ export async function lookupLocalEmulatorProjectIdByPath(absolutePath: string): 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.`); + throw new CliError(`No development-environment project registered for ${absolutePath}. Open it in the dashboard or run \`stack init\` from that directory first.`); } return match.projectId; } diff --git a/packages/stack/package.json b/packages/stack/package.json index a98d19bc1..b6d912877 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -2,7 +2,7 @@ "//": "THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY, INSTEAD EDIT THE CORRESPONDING FILE IN packages/template (FOR package.json FILES, PLEASE EDIT package-template.json)", "name": "@stackframe/stack", "version": "2.8.109", - "repository": "https://github.com/hexclave/stack-auth", + "repository": "https://github.com/hexclave/hexclave", "sideEffects": false, "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/tanstack-start/package.json b/packages/tanstack-start/package.json index 8a86175f1..946d9f805 100644 --- a/packages/tanstack-start/package.json +++ b/packages/tanstack-start/package.json @@ -2,7 +2,7 @@ "//": "THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY, INSTEAD EDIT THE CORRESPONDING FILE IN packages/template (FOR package.json FILES, PLEASE EDIT package-template.json)", "name": "@stackframe/tanstack-start", "version": "2.8.109", - "repository": "https://github.com/hexclave/stack-auth", + "repository": "https://github.com/hexclave/hexclave", "sideEffects": false, "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/template/package.json b/packages/template/package.json index b57b69e13..9b9f1f373 100644 --- a/packages/template/package.json +++ b/packages/template/package.json @@ -3,7 +3,7 @@ "name": "@stackframe/template", "private": true, "version": "2.8.109", - "repository": "https://github.com/hexclave/stack-auth", + "repository": "https://github.com/hexclave/hexclave", "sideEffects": false, "main": "./dist/index.js", "types": "./dist/index.d.ts",