mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
Remove stack emulator CLI commands (#1522)
This commit is contained in:
parent
241524c3f7
commit
222b6151fa
@ -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.
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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}`,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user