Merge remote-tracking branch 'origin/dev' into cl/hexclave-pr3

# Conflicts:
#	apps/dashboard/src/lib/remote-development-environment/config-file.ts
#	packages/stack-cli/src/commands/emulator.ts
#	packages/stack-cli/src/commands/init.ts
#	packages/template/package-template.json
This commit is contained in:
Bilal Godil 2026-05-29 13:45:18 -07:00
commit e770d4075d
21 changed files with 289 additions and 1300 deletions

View File

@ -565,3 +565,6 @@ A: Do not rely on a fixed `wait(1500)` after setup. The mock onboarding path fli
## Q: How should Microsoft OAuth callback token exchange include scopes?
A: Microsoft Entra ID's v2 token endpoint can reject authorization-code exchanges with `AADSTS70011` if the token request omits `scope`. Keep scope emission opt-in at the provider layer (`includeScopeInCallbackTokenExchange`) and pass the same merged base/provider scopes to `openid-client` via the callback `extras.exchangeBody.scope` parameter. The callback route must forward stored `providerScope` from the outer OAuth info so custom Microsoft provider scopes are included in the token exchange.
## Q: How should the development-environment dashboard load local config files?
A: Use `jiti` to import the user's config module, matching `stack config push`, so helper functions such as `defineStackConfig(...)` or `makeConfig()` work. Disable `moduleCache` for this reader because the development-environment file watcher may import the same config path repeatedly after edits, and cached modules would otherwise hide changes.

View File

@ -119,6 +119,7 @@ To see all development ports, refer to the index.html of `apps/dev-launchpad/pub
- When adding code to the `private` part of the backend, put the actual implementation into `implementation` (if the submodule is checked out), and implement a simple fallback in `implementation-fallback` for self-hosters. `implementation.generated.ts` will automatically be generated, which you can then import from `index.ts`. (See the existing code as an example.) If the submodule isn't checked out, but you need to add code to the `private` part of the backend, let the user know.
- Security-sensitive code on the backend that shouldn't be public should be in the `private` part of the backend.
- When you fix some obscure bug, or otherwise make a small change that is the result of a complex thought, add a concise comment explaining the thought in detail. Your mental model should be that we want to keep track of all the tiny decisions that are not clearly visible in the code, such that when/if we rewrite the code in the future, we don't have to re-learn all the tiny decisions that were made iteratively.
- If you create any temporary files, always suffix them with `.untracked` or `.untracked.<ext>` so they don't get committed to Git.
### Code-related
- Use ES6 maps instead of records wherever you can.

View File

@ -83,6 +83,7 @@
"export-to-csv": "^1.4.0",
"geist": "^1",
"input-otp": "^1.4.1",
"jiti": "^2.4.2",
"jose": "^6.1.3",
"libsodium-wrappers": "^0.8.2",
"lodash": "^4.17.21",

View File

@ -0,0 +1,211 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
import { join } from "path";
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("server-only", () => ({}));
let tempDir: string | undefined;
function writeTempConfig(content: string): string {
tempDir ??= mkdtempSync(join(process.cwd(), ".stack-rde-config-test-"));
const configPath = join(tempDir, "stack.config.ts");
writeFileSync(configPath, content, "utf-8");
return configPath;
}
afterEach(() => {
vi.resetModules();
if (tempDir != null) {
rmSync(tempDir, { recursive: true, force: true });
tempDir = undefined;
}
});
describe("remote development environment config file", () => {
it("loads config exports wrapped in defineStackConfig", async () => {
const configPath = writeTempConfig(`
import { defineStackConfig } from "@stackframe/stack-shared/config";
export const config = defineStackConfig({
auth: {
allowSignUp: true,
},
});
`);
const { readConfigFile } = await import("./config-file");
await expect(readConfigFile(configPath)).resolves.toMatchInlineSnapshot(`
{
"config": {
"auth": {
"allowSignUp": true,
},
},
"showOnboarding": false,
}
`);
});
it("loads config exports wrapped in defineHexclaveConfig", async () => {
const configPath = writeTempConfig(`
import { defineHexclaveConfig } from "@stackframe/stack-shared/config";
export const config = defineHexclaveConfig({
auth: {
allowSignUp: false,
},
});
`);
const { readConfigFile } = await import("./config-file");
await expect(readConfigFile(configPath)).resolves.toMatchInlineSnapshot(`
{
"config": {
"auth": {
"allowSignUp": false,
},
},
"showOnboarding": false,
}
`);
});
it("loads config exports produced by TypeScript function calls", async () => {
const configPath = writeTempConfig(`
function makeConfig() {
return {
auth: {
allowSignUp: true,
},
};
}
export const config = makeConfig();
`);
const { readConfigFile } = await import("./config-file");
await expect(readConfigFile(configPath)).resolves.toMatchInlineSnapshot(`
{
"config": {
"auth": {
"allowSignUp": true,
},
},
"showOnboarding": false,
}
`);
});
it("reloads the config module after the file changes", async () => {
const configPath = writeTempConfig(`
export const config = {
auth: {
allowSignUp: true,
},
};
`);
const { readConfigFile } = await import("./config-file");
await expect(readConfigFile(configPath)).resolves.toMatchInlineSnapshot(`
{
"config": {
"auth": {
"allowSignUp": true,
},
},
"showOnboarding": false,
}
`);
writeFileSync(configPath, `
export const config = {
auth: {
allowSignUp: false,
},
};
`, "utf-8");
await expect(readConfigFile(configPath)).resolves.toMatchInlineSnapshot(`
{
"config": {
"auth": {
"allowSignUp": false,
},
},
"showOnboarding": false,
}
`);
});
it("treats the onboarding placeholder as an empty config", async () => {
const configPath = writeTempConfig(`
export const config = "show-onboarding";
`);
const { readConfigFile } = await import("./config-file");
await expect(readConfigFile(configPath)).resolves.toMatchInlineSnapshot(`
{
"config": {},
"showOnboarding": true,
}
`);
});
it("rejects modules without a valid config export", async () => {
const configPath = writeTempConfig(`
export const config = () => ({ auth: { allowSignUp: true } });
`);
const { readConfigFile } = await import("./config-file");
await expect(readConfigFile(configPath)).rejects.toThrow(`Invalid config in ${configPath}.`);
});
it("can rewrite a dynamic config into the rendered static format", async () => {
const configPath = writeTempConfig(`
export const config = {
auth: {
allowSignUp: false,
},
};
`);
const { readConfigFile, writeConfigObject } = await import("./config-file");
const current = await readConfigFile(configPath);
writeConfigObject(configPath, {
...current.config,
"payments.testMode": true,
});
expect(readFileSync(configPath, "utf-8")).toMatchInlineSnapshot(`
"import type { StackConfig } from "@stackframe/js";
export const config: StackConfig = {
"auth": {
"allowSignUp": false
},
"payments": {
"testMode": true
}
};
"
`);
await expect(readConfigFile(configPath)).resolves.toMatchInlineSnapshot(`
{
"config": {
"auth": {
"allowSignUp": false,
},
"payments": {
"testMode": true,
},
},
"showOnboarding": false,
}
`);
});
});

View File

@ -3,11 +3,21 @@ import "server-only";
import { showOnboardingStackConfigValue } from "@hexclave/shared/dist/config-authoring";
import { Config, isValidConfig } from "@hexclave/shared/dist/config/format";
import { detectImportPackageFromDir, renderConfigFileContent } from "@hexclave/shared/dist/config-rendering";
import { parseStackConfigFileContent } from "@hexclave/shared/dist/stack-config-file";
import { createHash } from "crypto";
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
import { createJiti } from "jiti";
import path from "path";
const jiti = createJiti(import.meta.url, { moduleCache: false });
type ConfigModule = {
config?: unknown,
};
function isConfigModule(value: unknown): value is ConfigModule {
return value !== null && typeof value === "object";
}
export function sha256String(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
@ -38,14 +48,26 @@ export function ensureConfigFileExists(configFilePath: string): void {
writeConfigObject(configFilePath, {});
}
export function readConfigObject(configFilePath: string): Config {
return readConfigFile(configFilePath).config;
export async function readConfigObject(configFilePath: string): Promise<Config> {
return (await readConfigFile(configFilePath)).config;
}
export function readConfigFile(configFilePath: string): { config: Config, showOnboarding: boolean } {
export async function readConfigFile(configFilePath: string): Promise<{ config: Config, showOnboarding: boolean }> {
ensureConfigFileExists(configFilePath);
const content = readFileSync(configFilePath, "utf-8");
const config = parseStackConfigFileContent(content, configFilePath);
if (content.trim() === "") {
return {
config: {},
showOnboarding: false,
};
}
const configModule = await jiti.import<unknown>(configFilePath);
if (!isConfigModule(configModule)) {
throw new Error(`Invalid config in ${configFilePath}. The file must export a plain \`config\` object or "show-onboarding".`);
}
const config = configModule.config;
if (config === showOnboardingStackConfigValue) {
return {
config: {},

View File

@ -351,7 +351,7 @@ async function syncConfigToRemote(configFilePath: string): Promise<ProjectOnboar
return undefined;
}
const { config, showOnboarding } = readConfigFile(configFilePath);
const { config, showOnboarding } = await readConfigFile(configFilePath);
const configHash = sha256String(JSON.stringify({ config, showOnboarding, syncFormatVersion: CONFIG_SYNC_FORMAT_VERSION }));
const app = createInternalApp(project.apiBaseUrl, state.anonymousRefreshToken);
const user = await app.getUser({ or: "anonymous" });
@ -637,7 +637,7 @@ export async function applyRemoteDevelopmentEnvironmentConfigUpdate(options: {
projectId: options.projectId,
configFilePath,
});
const currentConfig = readConfigFile(configFilePath).config;
const currentConfig = (await readConfigFile(configFilePath)).config;
if (options.waitForSync === false) {
writeConfigObject(configFilePath, override(currentConfig, options.configUpdate));
scheduleSync(configFilePath);

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

@ -92,7 +92,7 @@ The MCP server lives at https://mcp.hexclave.com. If you need to answer a specif
## Using the Hexclave CLI
The CLI (`hexclave`) is the fastest path for anything project-level. It is installed on demand via `npx` — no global install required. Every command below can be invoked as `npx @hexclave/cli@latest <command>`.
The CLI is the fastest path for anything project-level. It is installed on demand via `npx` — no global install required. Every command below can be invoked as `npx @hexclave/cli@latest <command>`.
This part of the AI documentation is currently being written. Please run the Hexclave CLI's `help` command to get the latest help: `npx @hexclave/cli help`.

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 = "hexclave/hexclave";
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: hexclave/hexclave)")
.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: hexclave/hexclave)")
.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 "@hexclave/shared/dist/config-rendering";
import { throwErr } from "@hexclave/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 @hexclave/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

@ -27,7 +27,7 @@ export function buildSkillSitePrompt(docsIndexPromptValue = docsIndexPrompt) {
## Using the Hexclave CLI
The CLI (\`hexclave\`) is the fastest path for anything project-level. It is installed on demand via \`npx\` — no global install required. Every command below can be invoked as \`npx @hexclave/cli@latest <command>\`.
The CLI is the fastest path for anything project-level. It is installed on demand via \`npx\` — no global install required. Every command below can be invoked as \`npx @hexclave/cli@latest <command>\`.
${cliHelpPrompt}

View File

@ -13,8 +13,8 @@
"//": "NEXT_LINE_PLATFORM template",
"private": true,
"version": "1.0.0",
"repository": "https://github.com/hexclave/hexclave",
"version": "2.8.109",
"repository": "https://github.com/hexclave/stack-auth",
"sideEffects": false,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",

View File

@ -550,6 +550,9 @@ importers:
input-otp:
specifier: ^1.4.1
version: 1.4.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
jiti:
specifier: ^2.4.2
version: 2.6.1
jose:
specifier: ^6.1.3
version: 6.1.3