@stackframe/stack-cli 2.8.83 → 2.8.84

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,402 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
5
+ # shellcheck source=common.sh
6
+ source "$SCRIPT_DIR/common.sh"
7
+
8
+ IMAGE_DIR="${EMULATOR_IMAGE_DIR:-$HOME/.stack/emulator/images}"
9
+ RUN_DIR="${EMULATOR_RUN_DIR:-$HOME/.stack/emulator/run}"
10
+
11
+ VM_RAM="${EMULATOR_RAM:-4096}"
12
+ VM_CPUS="${EMULATOR_CPUS:-4}"
13
+ PORT_PREFIX="${PORT_PREFIX:-${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}}"
14
+ READY_TIMEOUT="${EMULATOR_READY_TIMEOUT:-240}"
15
+
16
+ # Fixed host-side ports for the QEMU emulator (267xx range).
17
+ # Only user-facing services are exposed; internal deps stay inside the VM.
18
+ EMULATOR_DASHBOARD_PORT="${EMULATOR_DASHBOARD_PORT:-26700}"
19
+ EMULATOR_BACKEND_PORT="${EMULATOR_BACKEND_PORT:-26701}"
20
+ EMULATOR_MINIO_PORT="${EMULATOR_MINIO_PORT:-26702}"
21
+ EMULATOR_INBUCKET_PORT="${EMULATOR_INBUCKET_PORT:-26703}"
22
+
23
+ RED='\033[0;31m'
24
+ GREEN='\033[0;32m'
25
+ YELLOW='\033[0;33m'
26
+ CYAN='\033[0;36m'
27
+ NC='\033[0m'
28
+
29
+ log() { echo -e "${GREEN}[emulator]${NC} $*"; }
30
+ warn() { echo -e "${YELLOW}[emulator]${NC} $*"; }
31
+ err() { echo -e "${RED}[emulator]${NC} $*" >&2; }
32
+ info() { echo -e "${CYAN}[emulator]${NC} $*"; }
33
+
34
+
35
+ detect_host
36
+ ARCH="${EMULATOR_ARCH:-$HOST_ARCH}"
37
+
38
+ select_accelerator() {
39
+ local accel="tcg"
40
+ if [ "$ARCH" = "$HOST_ARCH" ]; then
41
+ case "$HOST_OS" in
42
+ darwin)
43
+ if "$(qemu_binary_for_arch "$ARCH")" -accel help 2>&1 | grep -q hvf; then
44
+ accel="hvf"
45
+ fi
46
+ ;;
47
+ linux)
48
+ if [ -w /dev/kvm ]; then
49
+ accel="kvm"
50
+ fi
51
+ ;;
52
+ esac
53
+ fi
54
+ ACCEL="$accel"
55
+ }
56
+
57
+ select_accelerator
58
+
59
+ VM_DIR="$RUN_DIR/vm"
60
+
61
+ image_path() {
62
+ echo "$IMAGE_DIR/stack-emulator-$ARCH.qcow2"
63
+ }
64
+
65
+ runtime_iso_path() {
66
+ echo "$VM_DIR/runtime-config.iso"
67
+ }
68
+
69
+ # Returns a fast fingerprint (size:mtime) of the base QEMU image.
70
+ # Used to detect whether the image has changed since the overlay was created.
71
+ base_image_fingerprint() {
72
+ local img="$1"
73
+ case "$HOST_OS" in
74
+ darwin) stat -f "%z:%m" "$img" 2>/dev/null ;;
75
+ linux) stat -c "%s:%Y" "$img" 2>/dev/null ;;
76
+ *) stat -f "%z:%m" "$img" 2>/dev/null || stat -c "%s:%Y" "$img" 2>/dev/null ;;
77
+ esac
78
+ }
79
+
80
+ prepare_runtime_config_iso() {
81
+ local cfg_dir="$VM_DIR/runtime-config"
82
+ local cfg_iso
83
+ cfg_iso="$(runtime_iso_path)"
84
+ rm -rf "$cfg_dir"
85
+ mkdir -p "$cfg_dir"
86
+ {
87
+ printf "STACK_EMULATOR_PORT_PREFIX=%s\n" "$PORT_PREFIX"
88
+ printf "STACK_EMULATOR_DASHBOARD_HOST_PORT=%s\n" "$EMULATOR_DASHBOARD_PORT"
89
+ printf "STACK_EMULATOR_BACKEND_HOST_PORT=%s\n" "$EMULATOR_BACKEND_PORT"
90
+ printf "STACK_EMULATOR_MINIO_HOST_PORT=%s\n" "$EMULATOR_MINIO_PORT"
91
+ printf "STACK_EMULATOR_INBUCKET_HOST_PORT=%s\n" "$EMULATOR_INBUCKET_PORT"
92
+ printf "STACK_EMULATOR_VM_DIR_HOST=%s\n" "$VM_DIR"
93
+ } > "$cfg_dir/runtime.env"
94
+ cp "$SCRIPT_DIR/../.env.development" "$cfg_dir/base.env"
95
+ make_iso_from_dir "$cfg_iso" "STACKCFG" "$cfg_dir"
96
+ }
97
+
98
+ service_is_up() {
99
+ local port="$1"
100
+ local proto="$2"
101
+ local path="${3:-/}"
102
+ local expected_codes="${4:-200}"
103
+
104
+ if [ "$proto" = "tcp" ]; then
105
+ nc -z -w2 127.0.0.1 "$port" 2>/dev/null
106
+ return $?
107
+ fi
108
+
109
+ local code
110
+ code="$(curl -s -o /dev/null -w "%{http_code}" --max-time 2 "http://127.0.0.1:${port}${path}" 2>/dev/null || true)"
111
+ local expected
112
+ for expected in ${expected_codes//,/ }; do
113
+ if [ "$code" = "$expected" ]; then
114
+ return 0
115
+ fi
116
+ done
117
+ return 1
118
+ }
119
+
120
+ deps_ready() {
121
+ service_is_up "$EMULATOR_MINIO_PORT" http /minio/health/live &&
122
+ service_is_up "$EMULATOR_INBUCKET_PORT" http /
123
+ }
124
+
125
+ app_ready() {
126
+ service_is_up "$EMULATOR_BACKEND_PORT" http "/health?db=1" &&
127
+ service_is_up "$EMULATOR_DASHBOARD_PORT" http /handler/sign-in
128
+ }
129
+
130
+ all_ready() {
131
+ deps_ready && app_ready
132
+ }
133
+
134
+ wait_for_condition() {
135
+ local label="$1"
136
+ local timeout="$2"
137
+ local check_fn="$3"
138
+ local started=$SECONDS
139
+ local elapsed=0
140
+
141
+ log "Waiting for ${label}..."
142
+ while [ "$elapsed" -lt "$timeout" ]; do
143
+ if "$check_fn"; then
144
+ echo ""
145
+ log "${label} ready in ${elapsed}s"
146
+ return 0
147
+ fi
148
+ sleep 1
149
+ elapsed=$((SECONDS - started))
150
+ printf "\r [%3ds] %s..." "$elapsed" "$label"
151
+ done
152
+ echo ""
153
+ return 1
154
+ }
155
+
156
+ build_qemu_cmd() {
157
+ local base_img
158
+ base_img="$(image_path)"
159
+
160
+ if [ ! -f "$base_img" ]; then
161
+ err "Missing QEMU image: $base_img"
162
+ err "Run docker/local-emulator/qemu/build-image.sh $ARCH first."
163
+ exit 1
164
+ fi
165
+
166
+ mkdir -p "$VM_DIR"
167
+ local fingerprint_file="$VM_DIR/base-image.fingerprint"
168
+ local current_fp
169
+ current_fp="$(base_image_fingerprint "$base_img")"
170
+ if [ -f "$VM_DIR/disk.qcow2" ]; then
171
+ if [ -f "$fingerprint_file" ] && [ "$(cat "$fingerprint_file")" = "$current_fp" ]; then
172
+ log "Reusing existing overlay disk (changes persist)"
173
+ else
174
+ warn "QEMU base image has changed — recreating overlay."
175
+ rm -f "$VM_DIR/disk.qcow2" "$fingerprint_file"
176
+ fi
177
+ fi
178
+ if [ ! -f "$VM_DIR/disk.qcow2" ]; then
179
+ qemu-img create -f qcow2 -b "$base_img" -F qcow2 "$VM_DIR/disk.qcow2" >/dev/null
180
+ base_image_fingerprint "$base_img" > "$fingerprint_file"
181
+ fi
182
+
183
+ local qemu_bin machine cpu firmware_args=()
184
+ qemu_bin="$(qemu_binary_for_arch "$ARCH")"
185
+ case "$ARCH" in
186
+ arm64)
187
+ machine="virt"
188
+ cpu="max"
189
+ local firmware
190
+ firmware="$(find_aarch64_firmware)"
191
+ firmware_args=(-bios "$firmware")
192
+ ;;
193
+ amd64)
194
+ machine="q35"
195
+ if [ "$ACCEL" = "tcg" ] && [ "$HOST_ARCH" != "amd64" ]; then
196
+ cpu="qemu64"
197
+ else
198
+ cpu="max"
199
+ fi
200
+ ;;
201
+ esac
202
+
203
+ local netdev="user,id=net0"
204
+ # Only expose user-facing services; internal deps stay inside the VM.
205
+ # Bind to 127.0.0.1 so the emulator is not reachable from the LAN.
206
+ netdev+=",hostfwd=tcp:127.0.0.1:${EMULATOR_DASHBOARD_PORT}-:${PORT_PREFIX}01"
207
+ netdev+=",hostfwd=tcp:127.0.0.1:${EMULATOR_BACKEND_PORT}-:${PORT_PREFIX}02"
208
+ netdev+=",hostfwd=tcp:127.0.0.1:${EMULATOR_MINIO_PORT}-:9090"
209
+ netdev+=",hostfwd=tcp:127.0.0.1:${EMULATOR_INBUCKET_PORT}-:9001"
210
+ # Mock OAuth server: browser redirects land on `localhost:${PORT_PREFIX}14`
211
+ # (backend sets STACK_OAUTH_MOCK_URL to that value), so we forward host:port
212
+ # ↔ VM:port on the same number. Collides with pnpm dev, but the two modes
213
+ # are mutually exclusive.
214
+ netdev+=",hostfwd=tcp:127.0.0.1:${PORT_PREFIX}14-:${PORT_PREFIX}14"
215
+
216
+ QEMU_CMD=(
217
+ "$qemu_bin"
218
+ -machine "$machine"
219
+ -accel "$ACCEL"
220
+ -cpu "$cpu"
221
+ "${firmware_args[@]}"
222
+ -boot order=c
223
+ -m "$VM_RAM"
224
+ -smp "$VM_CPUS"
225
+ -drive "file=$VM_DIR/disk.qcow2,format=qcow2,if=virtio"
226
+ -drive "file=$(runtime_iso_path),format=raw,if=virtio,readonly=on"
227
+ -netdev "$netdev"
228
+ -device virtio-net-pci,netdev=net0
229
+ -device virtio-balloon-pci
230
+ -virtfs "local,path=/,mount_tag=hostfs,security_model=none"
231
+ -chardev "socket,id=monitor,path=$VM_DIR/monitor.sock,server=on,wait=off"
232
+ -mon "chardev=monitor,mode=control"
233
+ -serial "file:$VM_DIR/serial.log"
234
+ -display none
235
+ -daemonize
236
+ -pidfile "$VM_DIR/qemu.pid"
237
+ )
238
+
239
+ }
240
+
241
+ is_running() {
242
+ if [ ! -f "$VM_DIR/qemu.pid" ]; then
243
+ return 1
244
+ fi
245
+ local pid
246
+ pid="$(cat "$VM_DIR/qemu.pid")"
247
+ kill -0 "$pid" 2>/dev/null
248
+ }
249
+
250
+ tail_vm_logs() {
251
+ if [ -f "$VM_DIR/serial.log" ]; then
252
+ echo ""
253
+ warn "Last serial log lines:"
254
+ tail -40 "$VM_DIR/serial.log" || true
255
+ fi
256
+ }
257
+
258
+ ensure_ports_free() {
259
+ local ports=("$EMULATOR_DASHBOARD_PORT" "$EMULATOR_BACKEND_PORT" "$EMULATOR_MINIO_PORT" "$EMULATOR_INBUCKET_PORT" "${PORT_PREFIX}14")
260
+ local port
261
+ for port in "${ports[@]}"; do
262
+ if lsof -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1; then
263
+ err "Port $port is already in use. Stop any conflicting services first."
264
+ exit 1
265
+ fi
266
+ done
267
+ }
268
+
269
+ start_vm() {
270
+ mkdir -p "$VM_DIR"
271
+ : > "$VM_DIR/serial.log"
272
+ prepare_runtime_config_iso
273
+ build_qemu_cmd
274
+ "${QEMU_CMD[@]}"
275
+ }
276
+
277
+ stop_vm() {
278
+ if [ ! -f "$VM_DIR/qemu.pid" ]; then
279
+ return 0
280
+ fi
281
+ local pid
282
+ pid="$(cat "$VM_DIR/qemu.pid")"
283
+ if kill -0 "$pid" 2>/dev/null; then
284
+ if [ -S "$VM_DIR/monitor.sock" ]; then
285
+ echo '{"execute":"qmp_capabilities"}' | socat - UNIX-CONNECT:"$VM_DIR/monitor.sock" >/dev/null 2>&1 || true
286
+ echo '{"execute":"system_powerdown"}' | socat - UNIX-CONNECT:"$VM_DIR/monitor.sock" >/dev/null 2>&1 || true
287
+ sleep 3
288
+ fi
289
+ if kill -0 "$pid" 2>/dev/null; then
290
+ kill "$pid" 2>/dev/null || true
291
+ sleep 1
292
+ kill -9 "$pid" 2>/dev/null || true
293
+ fi
294
+ fi
295
+ rm -f "$VM_DIR/qemu.pid" "$VM_DIR/monitor.sock" "$VM_DIR/serial.log"
296
+ rm -rf "$VM_DIR/runtime-config"
297
+ rm -f "$VM_DIR/runtime-config.iso"
298
+ }
299
+
300
+ cmd_start() {
301
+ ensure_ports_free
302
+ mkdir -p "$RUN_DIR"
303
+
304
+ info "Starting QEMU local emulator"
305
+ info "Arch: $ARCH | Accel: $ACCEL"
306
+ info "Ports: Dashboard=$EMULATOR_DASHBOARD_PORT Backend=$EMULATOR_BACKEND_PORT MinIO=$EMULATOR_MINIO_PORT Inbucket=$EMULATOR_INBUCKET_PORT"
307
+
308
+ start_vm
309
+
310
+ info "VM: ${VM_RAM}MB / ${VM_CPUS} CPUs"
311
+
312
+ if ! wait_for_condition "deps services" "$READY_TIMEOUT" deps_ready; then
313
+ tail_vm_logs
314
+ exit 1
315
+ fi
316
+
317
+ if ! wait_for_condition "dashboard/backend" "$READY_TIMEOUT" app_ready; then
318
+ tail_vm_logs
319
+ exit 1
320
+ fi
321
+
322
+ log "All services are green."
323
+ info "Dashboard: http://localhost:${EMULATOR_DASHBOARD_PORT}"
324
+ info "Backend: http://localhost:${EMULATOR_BACKEND_PORT}"
325
+ }
326
+
327
+ cmd_stop() {
328
+ stop_vm
329
+ log "QEMU emulator stopped."
330
+ }
331
+
332
+ cmd_reset() {
333
+ cmd_stop 2>/dev/null || true
334
+ rm -rf "$RUN_DIR"
335
+ log "Emulator state reset. Next start will be a fresh boot."
336
+ }
337
+
338
+ STATUS_FAILED=0
339
+
340
+ print_service_status() {
341
+ local name="$1"
342
+ local port="$2"
343
+ local proto="$3"
344
+ local path="${4:-/}"
345
+ local expected_codes="${5:-200}"
346
+ if service_is_up "$port" "$proto" "$path" "$expected_codes"; then
347
+ echo -e " ${GREEN}●${NC} $name (:$port)"
348
+ else
349
+ echo -e " ${RED}●${NC} $name (:$port)"
350
+ STATUS_FAILED=1
351
+ fi
352
+ }
353
+
354
+ cmd_status() {
355
+ STATUS_FAILED=0
356
+ echo "VM:"
357
+ if is_running; then
358
+ echo -e " ${GREEN}●${NC} emulator"
359
+ else
360
+ echo -e " ${RED}●${NC} emulator"
361
+ STATUS_FAILED=1
362
+ fi
363
+ echo ""
364
+ echo "Services:"
365
+ print_service_status "Dashboard" "$EMULATOR_DASHBOARD_PORT" http /handler/sign-in
366
+ print_service_status "Backend" "$EMULATOR_BACKEND_PORT" http "/health?db=1"
367
+ print_service_status "MinIO" "$EMULATOR_MINIO_PORT" http /minio/health/live
368
+ print_service_status "Inbucket HTTP" "$EMULATOR_INBUCKET_PORT" http /
369
+ exit "$STATUS_FAILED"
370
+ }
371
+
372
+ cmd_bench() {
373
+ local elapsed
374
+ cmd_stop >/dev/null 2>&1 || true
375
+ SECONDS=0
376
+ cmd_start
377
+ elapsed="$SECONDS"
378
+ printf "Startup time: %.1fs\n" "$elapsed"
379
+ }
380
+
381
+ ACTION="start"
382
+
383
+ while [[ $# -gt 0 ]]; do
384
+ case "$1" in
385
+ start|stop|reset|status|bench)
386
+ ACTION="$1"
387
+ shift
388
+ ;;
389
+ *)
390
+ echo "Usage: $0 [start|stop|reset|status|bench]"
391
+ exit 1
392
+ ;;
393
+ esac
394
+ done
395
+
396
+ case "$ACTION" in
397
+ start) cmd_start ;;
398
+ stop) cmd_stop ;;
399
+ reset) cmd_reset ;;
400
+ status) cmd_status ;;
401
+ bench) cmd_bench ;;
402
+ esac
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@ import * as path from "path";
7
7
  import { dirname, join, resolve } from "path";
8
8
  import { StackClientApp } from "@stackframe/js";
9
9
  import * as os from "os";
10
+ import { homedir } from "os";
10
11
  import { detectImportPackageFromDir, renderConfigFileContent } from "@stackframe/stack-shared/dist/config-rendering";
11
12
  import { checkbox, confirm, input, select } from "@inquirer/prompts";
12
13
  import { ALL_APPS } from "@stackframe/stack-shared/dist/apps/apps-config";
@@ -723,6 +724,60 @@ function registerProjectCommand(program) {
723
724
 
724
725
  //#endregion
725
726
  //#region src/commands/emulator.ts
727
+ const DEFAULT_EMULATOR_BACKEND_PORT = 26701;
728
+ function emulatorBackendPort() {
729
+ const raw = process.env.EMULATOR_BACKEND_PORT;
730
+ if (!raw) return DEFAULT_EMULATOR_BACKEND_PORT;
731
+ const parsed = Number(raw);
732
+ if (!Number.isInteger(parsed) || parsed <= 0) throw new CliError(`Invalid EMULATOR_BACKEND_PORT: ${raw}`);
733
+ return parsed;
734
+ }
735
+ function emulatorHome() {
736
+ return process.env.STACK_EMULATOR_HOME ?? join(homedir(), ".stack", "emulator");
737
+ }
738
+ function emulatorRunDir() {
739
+ return join(emulatorHome(), "run");
740
+ }
741
+ function emulatorImageDir() {
742
+ return join(emulatorHome(), "images");
743
+ }
744
+ function internalPckPath() {
745
+ return join(emulatorRunDir(), "vm", "internal-pck");
746
+ }
747
+ async function readInternalPck(timeoutMs = 6e4) {
748
+ const path = internalPckPath();
749
+ const deadline = Date.now() + timeoutMs;
750
+ let delay = 250;
751
+ while (Date.now() < deadline) {
752
+ if (existsSync(path)) {
753
+ const contents = readFileSync(path, "utf-8").trim();
754
+ if (contents) return contents;
755
+ }
756
+ await new Promise((r) => setTimeout(r, delay));
757
+ delay = Math.min(delay * 2, 2e3);
758
+ }
759
+ throw new CliError(`Timed out waiting for emulator internal publishable client key at ${path}`);
760
+ }
761
+ async function fetchEmulatorCredentials(pck, backendPort, configFile) {
762
+ const url = `http://127.0.0.1:${backendPort}/api/v1/internal/local-emulator/project`;
763
+ const res = await fetch(url, {
764
+ method: "POST",
765
+ headers: {
766
+ "Content-Type": "application/json",
767
+ "X-Stack-Project-Id": "internal",
768
+ "X-Stack-Access-Type": "client",
769
+ "X-Stack-Publishable-Client-Key": pck
770
+ },
771
+ body: JSON.stringify({ absolute_file_path: configFile })
772
+ });
773
+ if (!res.ok) throw new CliError(`Failed to initialize local emulator project (${res.status}): ${await res.text()}`);
774
+ const data = await res.json();
775
+ return {
776
+ project_id: data.project_id,
777
+ publishable_client_key: data.publishable_client_key,
778
+ secret_server_key: data.secret_server_key
779
+ };
780
+ }
726
781
  function gh(args) {
727
782
  try {
728
783
  return execFileSync("gh", args, {
@@ -738,28 +793,57 @@ function gh(args) {
738
793
  throw new CliError("GitHub CLI (gh) is required. Install: https://cli.github.com/");
739
794
  }
740
795
  }
741
- function findQemuDir() {
742
- for (const rel of ["docker/local-emulator/qemu", "../docker/local-emulator/qemu"]) {
743
- const dir = resolve(process.cwd(), rel);
744
- if (existsSync(join(dir, "run-emulator.sh"))) return dir;
745
- }
746
- throw new CliError("Could not find QEMU emulator directory. Run this from the stack-auth repo root.");
796
+ function emulatorScriptsDir() {
797
+ const here = dirname(fileURLToPath(import.meta.url));
798
+ const bundled = join(here, "emulator");
799
+ if (existsSync(join(bundled, "run-emulator.sh"))) return bundled;
800
+ const repo = resolve(here, "../../../docker/local-emulator/qemu");
801
+ if (existsSync(join(repo, "run-emulator.sh"))) return repo;
802
+ throw new CliError("Emulator scripts not found in CLI bundle.");
803
+ }
804
+ function emulatorSpawnEnv(extra) {
805
+ return {
806
+ ...process.env,
807
+ EMULATOR_RUN_DIR: emulatorRunDir(),
808
+ EMULATOR_IMAGE_DIR: emulatorImageDir(),
809
+ ...extra
810
+ };
747
811
  }
748
812
  function runEmulator(action, env) {
749
- const qemuDir = findQemuDir();
750
- return new Promise((resolve, reject) => {
751
- const child = spawn(join(qemuDir, "run-emulator.sh"), [action], {
813
+ const scriptsDir = emulatorScriptsDir();
814
+ mkdirSync(emulatorRunDir(), { recursive: true });
815
+ mkdirSync(emulatorImageDir(), { recursive: true });
816
+ return new Promise((resolvePromise, reject) => {
817
+ const child = spawn(join(scriptsDir, "run-emulator.sh"), [action], {
752
818
  stdio: "inherit",
753
- env: {
754
- ...process.env,
755
- ...env
756
- },
757
- cwd: qemuDir
819
+ env: emulatorSpawnEnv(env),
820
+ cwd: scriptsDir
758
821
  });
759
- child.on("close", (code) => code === 0 ? resolve() : reject(new CliError(`run-emulator.sh ${action} exited with code ${code}`)));
822
+ child.on("close", (code) => code === 0 ? resolvePromise() : reject(new CliError(`run-emulator.sh ${action} exited with code ${code}`)));
760
823
  child.on("error", (err) => reject(new CliError(`Failed to run run-emulator.sh: ${err.message}`)));
761
824
  });
762
825
  }
826
+ function isEmulatorRunning() {
827
+ const scriptsDir = emulatorScriptsDir();
828
+ try {
829
+ execFileSync(join(scriptsDir, "run-emulator.sh"), ["status"], {
830
+ stdio: "pipe",
831
+ cwd: scriptsDir,
832
+ env: emulatorSpawnEnv()
833
+ });
834
+ return true;
835
+ } catch {
836
+ return false;
837
+ }
838
+ }
839
+ async function startEmulator(arch) {
840
+ mkdirSync(emulatorImageDir(), { recursive: true });
841
+ if (!existsSync(join(emulatorImageDir(), `stack-emulator-${arch}.qcow2`))) {
842
+ console.log("No emulator image found. Pulling latest...");
843
+ pullRelease(arch);
844
+ }
845
+ await runEmulator("start", { EMULATOR_ARCH: arch });
846
+ }
763
847
  function resolveArch(raw) {
764
848
  const arch = raw ?? (process.arch === "arm64" ? "arm64" : process.arch === "x64" ? "amd64" : null);
765
849
  if (arch === "arm64" || arch === "amd64") return arch;
@@ -770,7 +854,7 @@ function pullRelease(arch, opts = {}) {
770
854
  const branch = opts.branch ?? "dev";
771
855
  const tag = opts.tag ?? `emulator-${branch}-latest`;
772
856
  const asset = `stack-emulator-${arch}.qcow2`;
773
- const imageDir = join(findQemuDir(), "images");
857
+ const imageDir = emulatorImageDir();
774
858
  mkdirSync(imageDir, { recursive: true });
775
859
  const dest = join(imageDir, asset);
776
860
  const tmpDest = `${dest}.download`;
@@ -832,7 +916,7 @@ function registerEmulatorCommand(program) {
832
916
  if (runs.length === 0) throw new CliError(`No successful build found for PR #${opts.pr} (branch: ${headRefName}).`);
833
917
  runId = String(runs[0].databaseId);
834
918
  }
835
- const imageDir = join(findQemuDir(), "images");
919
+ const imageDir = emulatorImageDir();
836
920
  mkdirSync(imageDir, { recursive: true });
837
921
  const dest = join(imageDir, `stack-emulator-${arch}.qcow2`);
838
922
  if (existsSync(dest)) unlinkSync(dest);
@@ -860,13 +944,64 @@ function registerEmulatorCommand(program) {
860
944
  tag: opts.tag
861
945
  });
862
946
  });
863
- 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.").action(async (opts) => {
947
+ 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) => {
864
948
  const arch = resolveArch(opts.arch);
865
- if (!existsSync(join(findQemuDir(), "images", `stack-emulator-${arch}.qcow2`))) {
866
- console.log("No emulator image found. Pulling latest...");
867
- pullRelease(arch);
949
+ let resolvedConfigFile;
950
+ if (opts.configFile) {
951
+ resolvedConfigFile = resolve(opts.configFile);
952
+ if (!existsSync(resolvedConfigFile)) throw new CliError(`Config file not found: ${resolvedConfigFile}`);
953
+ }
954
+ if (isEmulatorRunning()) console.warn("Emulator already running, reusing existing instance.");
955
+ else await startEmulator(arch);
956
+ if (resolvedConfigFile) {
957
+ const creds = await fetchEmulatorCredentials(await readInternalPck(), emulatorBackendPort(), resolvedConfigFile);
958
+ console.log(JSON.stringify(creds, null, 2));
868
959
  }
869
- await runEmulator("start", { EMULATOR_ARCH: arch });
960
+ });
961
+ 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, opts) => {
962
+ const arch = resolveArch(opts.arch);
963
+ let resolvedConfigFile;
964
+ if (opts.configFile) {
965
+ resolvedConfigFile = resolve(opts.configFile);
966
+ if (!existsSync(resolvedConfigFile)) throw new CliError(`Config file not found: ${resolvedConfigFile}`);
967
+ }
968
+ const alreadyRunning = isEmulatorRunning();
969
+ if (alreadyRunning) console.log("Emulator already running, reusing existing instance.");
970
+ else await startEmulator(arch);
971
+ const childEnv = { ...process.env };
972
+ if (resolvedConfigFile) {
973
+ const pck = await readInternalPck();
974
+ const backendPort = emulatorBackendPort();
975
+ const creds = await fetchEmulatorCredentials(pck, backendPort, resolvedConfigFile);
976
+ const apiUrl = `http://127.0.0.1:${backendPort}`;
977
+ childEnv.STACK_PROJECT_ID = creds.project_id;
978
+ childEnv.NEXT_PUBLIC_STACK_PROJECT_ID = creds.project_id;
979
+ childEnv.STACK_PUBLISHABLE_CLIENT_KEY = creds.publishable_client_key;
980
+ childEnv.NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY = creds.publishable_client_key;
981
+ childEnv.STACK_SECRET_SERVER_KEY = creds.secret_server_key;
982
+ childEnv.STACK_API_URL = apiUrl;
983
+ childEnv.NEXT_PUBLIC_STACK_API_URL = apiUrl;
984
+ }
985
+ const child = spawn(cmd, {
986
+ shell: true,
987
+ stdio: "inherit",
988
+ env: childEnv
989
+ });
990
+ const forward = (signal) => () => child.kill(signal);
991
+ const onSigint = forward("SIGINT");
992
+ const onSigterm = forward("SIGTERM");
993
+ process.on("SIGINT", onSigint);
994
+ process.on("SIGTERM", onSigterm);
995
+ child.on("close", (code) => {
996
+ process.off("SIGINT", onSigint);
997
+ process.off("SIGTERM", onSigterm);
998
+ const exitCode = code ?? 1;
999
+ if (alreadyRunning) process.exit(exitCode);
1000
+ else {
1001
+ console.log("\nStopping emulator...");
1002
+ runEmulator("stop").catch(() => {}).finally(() => process.exit(exitCode));
1003
+ }
1004
+ });
870
1005
  });
871
1006
  emulator.command("stop").description("Stop the emulator (data preserved; use 'reset' to clear)").action(() => runEmulator("stop"));
872
1007
  emulator.command("reset").description("Reset emulator state for a fresh boot").action(() => runEmulator("reset"));