@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.
- package/dist/.env.development +89 -0
- package/dist/emulator/cloud-init/emulator/meta-data +2 -0
- package/dist/emulator/cloud-init/emulator/user-data +615 -0
- package/dist/emulator/common.sh +70 -0
- package/dist/emulator/run-emulator.sh +402 -0
- package/dist/index.js +157 -22
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
|
@@ -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
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
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
|
|
750
|
-
|
|
751
|
-
|
|
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
|
-
|
|
755
|
-
...env
|
|
756
|
-
},
|
|
757
|
-
cwd: qemuDir
|
|
819
|
+
env: emulatorSpawnEnv(env),
|
|
820
|
+
cwd: scriptsDir
|
|
758
821
|
});
|
|
759
|
-
child.on("close", (code) => code === 0 ?
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
866
|
-
|
|
867
|
-
|
|
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
|
-
|
|
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"));
|