mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
## Summary
`stack emulator start` now resumes a fully-warm VM snapshot instead of
cold-booting, bringing startup from 30–120s down to ~5–8s with
per-install secret rotation, or ~2.5s with rotation opt-out. The
snapshot is captured **locally on first `stack emulator pull`**, not
shipped from CI — QEMU migration state isn't portable across
accelerators (KVM/HVF/TCG) or `-cpu max` feature sets, so a CI-captured
snapshot couldn't resume reliably on arbitrary user hardware.
Also bundles a pile of CLI QoL fixes (progress bars, PR/run artifact
pulls, PR-build download, native-TS ISO writer replacing
`hdiutil`/`mkisofs`/`genisoimage` host dep, unit tests).
| Scenario | Before | After |
|---|---|---|
| Cold boot (no snapshot) | 30–120s | same, works as fallback |
| `stack emulator pull` (one-time, includes local snapshot capture) |
~30s download | ~30s download + ~1–3 min cold-boot capture |
| Snapshot resume, normal start | — | **~5–8s** |
| Snapshot resume, `EMULATOR_NO_ROTATION=1` | — | **~2.5s** |
Backend (`/health?db=1`) and dashboard (`/handler/sign-in`) return 200
on all paths. Two successive snapshot resumes produce different rotated
PCK/SSK/SAK/CRON_SECRET values per install.
## How it works
**Build (CI)** — `docker/local-emulator/qemu/build-image.sh`:
1. Cloud-init provisioning runs to completion (migrations, seed,
slim-image) producing `stack-emulator-<arch>.qcow2`.
2. Image is built with a topology compatible with later snapshot capture
(pinned SMP=4, phantom seed/bundle ISOs, STACKCFG runtime ISO mounted at
build time, qemu-guest-agent running, placeholder hex secrets baked in
under `STACK_EMULATOR_BUILD_SNAPSHOT=1`).
3. CI publishes **only the qcow2** — no `.savevm.zst` ships.
**Pull (user's machine)** —
`packages/stack-cli/src/commands/emulator.ts` + `run-emulator.sh
capture`:
1. `stack emulator pull` downloads the qcow2 with a progress bar (or
from a PR / workflow run via `--pr` / `--run`).
2. CLI invokes `run-emulator.sh capture`: cold-boots the qcow2 with a
matching device layout (phantom ISOs, fsdev, pcie-root-port, virtfs
detached — migration-incompatible), waits for backend+dashboard health,
then drives QMP: `stop` → set `mapped-ram` + `multifd` caps → `migrate
file:state.raw` → poll `query-migrate` → `quit`. Raw mapped-ram file is
zstd-compressed to `stack-emulator-<arch>.savevm.zst` in the images dir.
3. `--skip-snapshot` opts out (first `start` will then cold-boot).
**Runtime** — `run-emulator.sh start`:
1. Launch QEMU with `-incoming defer` when a `.savevm.zst` is present;
decompress on first use, keep the `.raw` cached for subsequent starts.
2. QMP: same `mapped-ram` + `multifd` caps → `migrate-incoming
file:<.raw>` → poll for `paused` → `cont`.
3. Generate fresh per-install secrets on the host; pipe them
base64-encoded through QGA `guest-exec input-data` →
`trigger-fast-rotate` in the guest → `docker exec -e … rotate-secrets`.
4. `rotate-secrets` in the container: validate keys (hex-only), targeted
`sed` on the placeholder PCK across built JS, `UPDATE ApiKeySet`,
`supervisorctl restart stack-app cron-jobs` (with
`stopasgroup`/`killasgroup` so the Node children actually die and
release their ports).
5. Poll backend+dashboard health; if anything fails, clean up and fall
back to cold boot transparently.
**Security model**: placeholder hex values are baked into the snapshot
(`00…ff` PCK, `00…ee` SSK, `00…dd` SAK, `00…cc` CRON_SECRET). They are
non-secret by construction. Real per-install secrets are generated at
each `emulator start` and never leave the host.
## CLI changes (`packages/stack-cli`)
- **`src/lib/iso.ts`** (new): native TypeScript ISO 9660 + Joliet
writer, replacing the host-side `hdiutil`/`mkisofs`/`genisoimage`
dependency for generating the STACKCFG runtime config disk. Unit tests
in `src/lib/iso.test.ts`.
- **`src/commands/emulator.ts`**:
- `pull`: streamed downloads with progress bar + ETA; `--pr <number>`
and `--run <id>` to pull from a PR build's CI artifacts (uses
`extract-zip` for the nested zip); `--skip-snapshot` to opt out of the
one-time local capture.
- `start` (existing, extended): auto-pulls AND auto-captures when no
image exists, so first-ever `start` is self-bootstrapping; emits
`STACK_EMULATOR_CLI_WROTE_ISO=1` so the shell helper skips its own ISO
regen (avoids the genisoimage host dep).
- `capture` (new, invoked by `pull` and the auto-pull path of `start`):
drives the local snapshot capture via `run-emulator.sh`.
- `status`, `stop`, `reset`, `list-releases`: preflight +
path-resolution tightening (`STACK_EMULATOR_HOME` → images/run dirs).
- Unit tests in `src/commands/emulator.test.ts`.
- **`EMULATOR_NO_ROTATION=1`** env var skips the post-resume rotation
(intended for tests/CI where the placeholder secrets are fine — comes
with a loud warning).
## CI (`.github/workflows/qemu-emulator-build.yaml`)
- Builds **QEMU 10.2.2 from source** (cached), because
`mapped-ram`/`multifd` migration capabilities aren't available in the
distro's QEMU. Enables KVM on ubicloud runners so amd64 boots at
hardware speed.
- amd64 + arm64 both build on the same amd64 matrix
(`ubicloud-standard-8`); arm64 runs under cross-arch TCG (provisioning
only — boot/verify smoke test is amd64-only).
- Verification now runs through the CLI: `emulator start` → `emulator
status` → `emulator stop` against the freshly-built qcow2 (via
`STACK_EMULATOR_HOME` pointing at the workspace, so the CLI doesn't
silently auto-pull a prior release).
- Packages **only** the qcow2. No `.savevm.zst` upload / publish.
- Release notes updated.
## Key files
**Shell / guest:**
- `docker/local-emulator/qemu/build-image.sh` — snapshot-compatible
device topology + STACKCFG runtime ISO at build time
- `docker/local-emulator/qemu/run-emulator.sh` — `start`, `capture`,
`stop`, `reset`, `status`; `-incoming defer`, `.raw` cache, QGA-driven
rotation, cold-boot fallback
- `docker/local-emulator/qemu/common.sh` (new) — shared `qmp_session` +
`capture_vm_state` (factored out so build-image.sh and run-emulator.sh
share the capture path)
- `docker/local-emulator/qemu/cloud-init/emulator/user-data` —
placeholder secrets in snapshot mode, `wait-for-stack-ready`,
`trigger-fast-rotate`, qemu-guest-agent enabled
- `docker/local-emulator/rotate-secrets.sh` (new) — in-container
rotation (sed + UPDATE + supervisorctl)
- `docker/local-emulator/supervisord.conf` — `stopasgroup`/`killasgroup`
on `stack-app` and `cron-jobs`
- `docker/local-emulator/entrypoint.sh` — only mint CRON_SECRET if unset
(placeholder supplied in snapshot mode via --env-file)
- `docker/local-emulator/Dockerfile` — ships `rotate-secrets` to
`/usr/local/bin`
- `docker/server/entrypoint.sh` — source
`/run/stack-auth/rotated-secrets.env`; skip full-tree sentinel scan on
warm restarts via marker
**CLI:**
- `packages/stack-cli/src/lib/iso.ts` (new) + `iso.test.ts` (new)
- `packages/stack-cli/src/commands/emulator.ts` + `emulator.test.ts`
(new)
- `packages/stack-cli/vitest.config.ts` (new)
**CI:**
- `.github/workflows/qemu-emulator-build.yaml`
## Test plan
- [x] `docker/local-emulator/qemu/build-image.sh {amd64,arm64}` produces
`stack-emulator-<arch>.qcow2` with snapshot-compatible topology
- [x] `stack emulator pull` downloads qcow2 with progress, then captures
locally (~1–3 min) and writes `stack-emulator-<arch>.savevm.zst` in the
images dir
- [x] `stack emulator pull --skip-snapshot` stops after download
- [x] `stack emulator pull --pr <n>` / `--run <id>` pull from PR /
workflow run artifacts
- [x] `stack emulator start` on a fresh dir auto-pulls **and**
auto-captures, then starts; subsequent starts fast-resume in ~5–8s;
backend + dashboard return 200
- [x] `EMULATOR_NO_ROTATION=1 stack emulator start` completes in ~2.5s;
backend + dashboard return 200 with warning printed
- [x] Two consecutive `emulator start` invocations produce different PCK
values in the internal `ApiKeySet` row
- [x] `stack emulator status` / `stop` / `reset` resolve paths from
`STACK_EMULATOR_HOME`
- [x] Verified end-to-end on arm64 macOS under HVF (capture ~50s,
fast-resume ~6.5s)
- [x] `pnpm lint` and `pnpm typecheck` pass; stack-cli unit tests (iso +
emulator) pass
- [ ] CI green on this PR (qemu-emulator-build matrix, smoke test)
- [ ] `gh release download emulator-<branch>-latest` contains only
`stack-emulator-<arch>.qcow2` once this PR merges and publish runs
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* Snapshot fast-start/resume with optional warm-snapshot assets, runtime
ISO generation, and a cached QEMU build to speed emulator setup.
* CLI: streamed artifact downloads with progress, improved release/asset
handling, stronger preflight checks, and start/status/stop emulator
commands.
* Automated secret rotation and ability to apply rotated secrets at
container startup; supervisor control socket enabled.
* **Bug Fixes**
* More robust start/stop/resume flows with automatic fallback to cold
boot and improved process-group shutdown behavior.
* **Tests**
* New tests for CLI utilities and ISO image generation.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
713 lines
28 KiB
TypeScript
713 lines
28 KiB
TypeScript
import { Command } from "commander";
|
|
import { execFileSync, spawn } from "child_process";
|
|
import extract from "extract-zip";
|
|
import { chmodSync, createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync } from "fs";
|
|
import { homedir } from "os";
|
|
import { dirname, join, resolve } from "path";
|
|
import { Readable } from "stream";
|
|
import { pipeline } from "stream/promises";
|
|
import { fileURLToPath } from "url";
|
|
import { CliError } from "../lib/errors.js";
|
|
import { writeIso } from "../lib/iso.js";
|
|
|
|
const DEFAULT_EMULATOR_BACKEND_PORT = 26701;
|
|
const DEFAULT_EMULATOR_DASHBOARD_PORT = 26700;
|
|
const DEFAULT_EMULATOR_MINIO_PORT = 26702;
|
|
const DEFAULT_EMULATOR_INBUCKET_PORT = 26703;
|
|
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",
|
|
];
|
|
|
|
export function envPort(name: string, fallback: number): number {
|
|
const raw = process.env[name];
|
|
if (!raw) return fallback;
|
|
const parsed = Number(raw);
|
|
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
throw new CliError(`Invalid ${name}: ${raw}`);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function emulatorBackendPort(): number {
|
|
return envPort("EMULATOR_BACKEND_PORT", DEFAULT_EMULATOR_BACKEND_PORT);
|
|
}
|
|
|
|
function emulatorHome(): string {
|
|
return process.env.STACK_EMULATOR_HOME ?? join(homedir(), ".stack", "emulator");
|
|
}
|
|
|
|
function emulatorRunDir(): string {
|
|
return join(emulatorHome(), "run");
|
|
}
|
|
|
|
function emulatorImageDir(): string {
|
|
return join(emulatorHome(), "images");
|
|
}
|
|
|
|
function internalPckPath(): string {
|
|
return join(emulatorRunDir(), "vm", "internal-pck");
|
|
}
|
|
|
|
async function readInternalPck(timeoutMs = 60_000): Promise<string> {
|
|
const path = internalPckPath();
|
|
const deadline = Date.now() + timeoutMs;
|
|
let delay = 50;
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const contents = readFileSync(path, "utf-8").trim();
|
|
if (contents) return contents;
|
|
} catch (e) {
|
|
if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e;
|
|
}
|
|
await new Promise((r) => setTimeout(r, delay));
|
|
delay = Math.min(delay * 2, 2000);
|
|
}
|
|
throw new CliError(`Timed out waiting for emulator internal publishable client key at ${path}`);
|
|
}
|
|
|
|
type EmulatorCredentials = {
|
|
project_id: string,
|
|
publishable_client_key: string,
|
|
secret_server_key: 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,
|
|
};
|
|
return {
|
|
project_id: data.project_id,
|
|
publishable_client_key: data.publishable_client_key,
|
|
secret_server_key: data.secret_server_key,
|
|
};
|
|
}
|
|
|
|
// 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 {
|
|
return {
|
|
...process.env,
|
|
EMULATOR_RUN_DIR: emulatorRunDir(),
|
|
EMULATOR_IMAGE_DIR: emulatorImageDir(),
|
|
...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_STACK_PORT_PREFIX ?? DEFAULT_PORT_PREFIX;
|
|
const dashboardPort = envPort("EMULATOR_DASHBOARD_PORT", DEFAULT_EMULATOR_DASHBOARD_PORT);
|
|
const backendPort = envPort("EMULATOR_BACKEND_PORT", DEFAULT_EMULATOR_BACKEND_PORT);
|
|
const minioPort = envPort("EMULATOR_MINIO_PORT", DEFAULT_EMULATOR_MINIO_PORT);
|
|
const inbucketPort = envPort("EMULATOR_INBUCKET_PORT", DEFAULT_EMULATOR_INBUCKET_PORT);
|
|
|
|
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_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" });
|
|
}
|
|
|
|
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, install: 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, install: platformInstallHint(linuxPkg, 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} → ${b.install}`);
|
|
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: ${b.install}`);
|
|
}
|
|
}
|
|
|
|
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);
|
|
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 = resolve(opts.configFile);
|
|
if (!existsSync(resolvedConfigFile)) {
|
|
throw new CliError(`Config file not found: ${resolvedConfigFile}`);
|
|
}
|
|
}
|
|
|
|
if (isEmulatorRunning()) {
|
|
console.warn("Emulator already running, reusing existing instance.");
|
|
} else {
|
|
await startEmulator(arch);
|
|
}
|
|
|
|
if (resolvedConfigFile) {
|
|
const pck = await readInternalPck();
|
|
const creds = await fetchEmulatorCredentials(pck, emulatorBackendPort(), resolvedConfigFile);
|
|
console.log(JSON.stringify(creds, null, 2));
|
|
}
|
|
});
|
|
|
|
emulator
|
|
.command("run")
|
|
.description("Start the emulator, run a command, and stop the emulator when the command exits")
|
|
.argument("<cmd>", "Command to run (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 (cmd: string, opts: { arch?: string, configFile?: string }) => {
|
|
const arch = resolveArch(opts.arch);
|
|
preflightForVmStart("run", arch);
|
|
|
|
let resolvedConfigFile: string | undefined;
|
|
if (opts.configFile) {
|
|
resolvedConfigFile = resolve(opts.configFile);
|
|
if (!existsSync(resolvedConfigFile)) {
|
|
throw new CliError(`Config file not found: ${resolvedConfigFile}`);
|
|
}
|
|
}
|
|
|
|
const alreadyRunning = isEmulatorRunning();
|
|
if (alreadyRunning) {
|
|
console.log("Emulator already running, reusing existing instance.");
|
|
} else {
|
|
await startEmulator(arch);
|
|
}
|
|
|
|
const childEnv: Record<string, string> = { ...process.env as Record<string, string> };
|
|
if (resolvedConfigFile) {
|
|
const pck = await readInternalPck();
|
|
const backendPort = emulatorBackendPort();
|
|
const creds = await fetchEmulatorCredentials(pck, backendPort, resolvedConfigFile);
|
|
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;
|
|
}
|
|
|
|
const child = spawn(cmd, { shell: true, stdio: "inherit", env: childEnv });
|
|
|
|
const forward = (signal: NodeJS.Signals) => () => child.kill(signal);
|
|
const onSigint = forward("SIGINT");
|
|
const onSigterm = forward("SIGTERM");
|
|
process.on("SIGINT", onSigint);
|
|
process.on("SIGTERM", onSigterm);
|
|
|
|
child.on("close", (code) => {
|
|
process.off("SIGINT", onSigint);
|
|
process.off("SIGTERM", onSigterm);
|
|
const exitCode = code ?? 1;
|
|
if (alreadyRunning) {
|
|
process.exit(exitCode);
|
|
} else {
|
|
console.log("\nStopping emulator...");
|
|
const warnStopFailed = (e: unknown) => {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
process.stderr.write(`Failed to stop emulator cleanly: ${msg}\n`);
|
|
};
|
|
runEmulator("stop")
|
|
.catch(warnStopFailed)
|
|
.finally(() => process.exit(exitCode));
|
|
}
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
}
|