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",