Remove stack emulator CLI commands (#1522)

This commit is contained in:
Konsti Wohlwend 2026-05-29 13:12:44 -07:00 committed by Madison
parent 241524c3f7
commit 222b6151fa
16 changed files with 42 additions and 1294 deletions

View File

@ -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";
<HexclaveAgentReminders />
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
<Warning>
`stack exec` has server-level access to the selected project. It requires `stack login` and intentionally rejects `STACK_SECRET_SERVER_KEY` auth.
</Warning>
## 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.

View File

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

View File

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

View File

@ -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();

View File

@ -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<string, string | undefined> = {};
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 -- <command>/);
});
});
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();
}
});
});

View File

@ -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<string> {
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<EmulatorCredentials> {
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} -- <command> [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<T>(path: string): Promise<T> {
const token = githubToken();
const headers: Record<string, string> = {
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<T>);
}
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<string, string>): 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<string, string>): Promise<void> {
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<void> {
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<NodeJS.ProcessEnv> {
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<number> {
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<void> {
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<number>,
): Promise<void> {
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 -- <cmd> Start the emulator, run <cmd>, 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<ReleaseResponse>(`/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<void> {
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<void> {
const dest = join(imageDir, asset);
const tmpDest = `${dest}.download`;
console.log(`Pulling ${asset} from release ${tag}...`);
const headers: Record<string, string> = { 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<string, string>, dest: string, totalBytes?: number): Promise<void> {
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<typeof Readable.fromWeb>[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<boolean> {
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<void> {
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<string>();
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<boolean> {
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<ArtifactsResponse>(`/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 <arch>", "Target architecture (default: current system arch)")
.option("--branch <branch>", "Release branch (default: dev)")
.option("--tag <tag>", "Specific release tag (default: latest)")
.option("--repo <repo>", "GitHub repository (default: stack-auth/stack-auth)")
.option("--pr <number>", "Pull from a PR's CI artifacts")
.option("--run <id>", "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<PullResponse>(`/repos/${repo}/pulls/${opts.pr}`);
const headRefName = pr.head.ref;
const runs = await ghApi<WorkflowRunsResponse>(
`/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 <arch>", "Target architecture (default: current system arch). Non-native uses software emulation and is significantly slower.")
.option("--config-file <path>", "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] -- <command> [args...]")
.description("Start the emulator, run a command, and stop the emulator when the command exits")
.argument("<command...>", "Command and arguments to run after -- (e.g. -- npm run dev)")
.option("--arch <arch>", "Target architecture")
.option("--config-file <path>", "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 <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<Release[]>(`/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);
});
}

View File

@ -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 <id> for the Hexclave cloud API, or --config-file <path> for the local emulator.");
throw new CliError("Specify a target: pass --cloud-project-id <id> for the Hexclave cloud API, or --config-file <path> 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 <id> for the cloud API, or --config-file <path> for the local emulator.")
.option("--cloud-project-id <id>", "Cloud project ID to run against (use --config-file instead for the local emulator)")
.option("--config-file <path>", "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 <id> for the cloud API, or --config-file <path> for the development environment.")
.option("--cloud-project-id <id>", "Cloud project ID to run against (use --config-file instead for the development environment)")
.option("--config-file <path>", "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) {

View File

@ -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 <UserButton /> 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}`,

View File

@ -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 <name>", "Project display name")
.action(async (opts) => {
if (!opts.cloud) {

View File

@ -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);

View File

@ -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<string> {
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<Proje
body = await res.text();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new AuthError(`Local emulator sign-in failed (${res.status} ${res.statusText}). Failed to read response body: ${message}. Make sure the emulator is running with NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR=true.`);
throw new AuthError(`Development-environment sign-in failed (${res.status} ${res.statusText}). Failed to read response body: ${message}. Make sure the development environment is running with NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR=true.`);
}
throw new AuthError(`Local emulator sign-in failed (${res.status} ${res.statusText})${body ? `: ${body}` : ""}. Make sure the emulator is running with NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR=true.`);
throw new AuthError(`Development-environment sign-in failed (${res.status} ${res.statusText})${body ? `: ${body}` : ""}. Make sure the development environment is running with NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR=true.`);
}
let data: unknown;
@ -229,10 +229,10 @@ export async function resolveLocalEmulatorAuth(projectId: string): Promise<Proje
data = await res.json();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new AuthError(`Local emulator sign-in returned a non-JSON response: ${message}.`);
throw new AuthError(`Development-environment sign-in returned a non-JSON response: ${message}.`);
}
if (data === null || typeof data !== "object" || typeof (data as { refresh_token?: unknown }).refresh_token !== "string") {
throw new AuthError("Local emulator sign-in response was missing a refresh token.");
throw new AuthError("Development-environment sign-in response was missing a refresh token.");
}
const refreshToken = (data as { refresh_token: string }).refresh_token;

View File

@ -5,9 +5,6 @@ import { CliError } from "./errors.js";
export const DEFAULT_EMULATOR_BACKEND_PORT = 26701;
export const DEFAULT_EMULATOR_DASHBOARD_PORT = 26700;
export const DEFAULT_EMULATOR_MINIO_PORT = 26702;
export const DEFAULT_EMULATOR_INBUCKET_PORT = 26703;
export const DEFAULT_EMULATOR_MOCK_OAUTH_PORT = 26704;
export function envPort(name: string, fallback: number): number {
const raw = process.env[name];
@ -36,10 +33,6 @@ export function emulatorRunDir(): string {
return join(emulatorHome(), "run");
}
export function emulatorImageDir(): string {
return join(emulatorHome(), "images");
}
export function internalPckPath(): string {
return join(emulatorRunDir(), "vm", "internal-pck");
}
@ -52,28 +45,10 @@ export function emulatorDashboardPort(): number {
return envPortFirstSet(["STACK_EMULATOR_DASHBOARD_PORT", "EMULATOR_DASHBOARD_PORT"], DEFAULT_EMULATOR_DASHBOARD_PORT);
}
export function emulatorMinioPort(): number {
return envPortFirstSet(["STACK_EMULATOR_MINIO_PORT", "EMULATOR_MINIO_PORT"], DEFAULT_EMULATOR_MINIO_PORT);
}
export function emulatorInbucketPort(): number {
return envPortFirstSet(["STACK_EMULATOR_INBUCKET_PORT", "EMULATOR_INBUCKET_PORT"], DEFAULT_EMULATOR_INBUCKET_PORT);
}
export function emulatorMockOAuthPort(): number {
return envPortFirstSet(["STACK_EMULATOR_MOCK_OAUTH_PORT", "EMULATOR_MOCK_OAUTH_PORT"], DEFAULT_EMULATOR_MOCK_OAUTH_PORT);
}
// Polls the emulator runtime dir for the internal PCK file with exponential
// backoff. Returns the trimmed contents on success, or `null` if the file is
// still missing/empty when the deadline elapses. Non-ENOENT read errors throw.
//
// Two callers care about this race:
// - `stack emulator start --config-file` waits up to ~60s for the VM to come
// up after a fresh boot.
// - `stack exec` (local default) waits a much shorter window so we still
// surface "emulator not running" quickly while absorbing a typical race
// between `stack emulator start` and the next CLI invocation.
// Polls the development-environment runtime dir for the internal PCK file with
// exponential backoff. Returns the trimmed contents on success, or `null` if the
// file is still missing/empty when the deadline elapses. Non-ENOENT read errors
// throw.
export async function pollInternalPck(timeoutMs: number): Promise<string | null> {
const pckPath = internalPckPath();
const deadline = performance.now() + timeoutMs;

View File

@ -13,7 +13,7 @@ export type LocalEmulatorProjectListEntry = {
async function getInternalPck(timeoutMs: number): Promise<string> {
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<LocalEmulatorProjectL
body = await res.text();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new AuthError(`Local emulator project list failed (${res.status} ${res.statusText}). Failed to read response body: ${message}. Make sure the emulator is running with NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR=true.`);
throw new AuthError(`Development-environment project list failed (${res.status} ${res.statusText}). Failed to read response body: ${message}. Make sure the development environment is running with NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR=true.`);
}
throw new AuthError(`Local emulator project list failed (${res.status} ${res.statusText})${body ? `: ${body}` : ""}. Make sure the emulator is running with NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR=true.`);
throw new AuthError(`Development-environment project list failed (${res.status} ${res.statusText})${body ? `: ${body}` : ""}. Make sure the development environment is running with NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR=true.`);
}
let data: unknown;
@ -97,10 +97,10 @@ export async function listLocalEmulatorProjects(): Promise<LocalEmulatorProjectL
data = await res.json();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new AuthError(`Local emulator project list returned a non-JSON response: ${message}.`);
throw new AuthError(`Development-environment project list returned a non-JSON response: ${message}.`);
}
if (!isListResponseBody(data)) {
throw new AuthError("Local emulator project list response had an unexpected shape.");
throw new AuthError("Development-environment project list response had an unexpected shape.");
}
return data.projects.map((p) => ({
@ -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;
}

View File

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

View File

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

View File

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