@stackframe/stack-cli 2.8.82 → 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 +167 -24
- 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";
|
|
@@ -99,7 +100,7 @@ function resolveAuth(flags) {
|
|
|
99
100
|
//#endregion
|
|
100
101
|
//#region src/commands/login.ts
|
|
101
102
|
function registerLoginCommand(program) {
|
|
102
|
-
program.command("login").description("Log in to Stack Auth via browser").action(async () => {
|
|
103
|
+
program.command("login").description("Log in to Stack Auth via browser. To attach this login to an existing anonymous session, set STACK_CLI_ANON_REFRESH_TOKEN (env var) or the same key in the CLI credentials file before running; login does not write that value.").action(async () => {
|
|
103
104
|
const config = resolveLoginConfig(program.opts());
|
|
104
105
|
const app = new StackClientApp({
|
|
105
106
|
projectId: "internal",
|
|
@@ -108,10 +109,18 @@ function registerLoginCommand(program) {
|
|
|
108
109
|
tokenStore: "memory",
|
|
109
110
|
noAutomaticPrefetch: true
|
|
110
111
|
});
|
|
112
|
+
const anonRefreshToken = process.env.STACK_CLI_ANON_REFRESH_TOKEN ?? readConfigValue("STACK_CLI_ANON_REFRESH_TOKEN");
|
|
111
113
|
console.log("Waiting for browser authentication...");
|
|
112
|
-
const result = await app.promptCliLogin({
|
|
114
|
+
const result = await app.promptCliLogin({
|
|
115
|
+
appUrl: config.dashboardUrl,
|
|
116
|
+
anonRefreshToken,
|
|
117
|
+
promptLink: (url) => {
|
|
118
|
+
console.log(`\nPlease visit the following URL to authenticate:\n${url}`);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
113
121
|
if (result.status === "error") throw new CliError(`Login failed: ${result.error.message}`);
|
|
114
122
|
writeConfigValue("STACK_CLI_REFRESH_TOKEN", result.data);
|
|
123
|
+
if (anonRefreshToken) removeConfigValue("STACK_CLI_ANON_REFRESH_TOKEN");
|
|
115
124
|
console.log("Login successful!");
|
|
116
125
|
});
|
|
117
126
|
}
|
|
@@ -715,6 +724,60 @@ function registerProjectCommand(program) {
|
|
|
715
724
|
|
|
716
725
|
//#endregion
|
|
717
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
|
+
}
|
|
718
781
|
function gh(args) {
|
|
719
782
|
try {
|
|
720
783
|
return execFileSync("gh", args, {
|
|
@@ -730,28 +793,57 @@ function gh(args) {
|
|
|
730
793
|
throw new CliError("GitHub CLI (gh) is required. Install: https://cli.github.com/");
|
|
731
794
|
}
|
|
732
795
|
}
|
|
733
|
-
function
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
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
|
+
};
|
|
739
811
|
}
|
|
740
812
|
function runEmulator(action, env) {
|
|
741
|
-
const
|
|
742
|
-
|
|
743
|
-
|
|
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], {
|
|
744
818
|
stdio: "inherit",
|
|
745
|
-
env:
|
|
746
|
-
|
|
747
|
-
...env
|
|
748
|
-
},
|
|
749
|
-
cwd: qemuDir
|
|
819
|
+
env: emulatorSpawnEnv(env),
|
|
820
|
+
cwd: scriptsDir
|
|
750
821
|
});
|
|
751
|
-
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}`)));
|
|
752
823
|
child.on("error", (err) => reject(new CliError(`Failed to run run-emulator.sh: ${err.message}`)));
|
|
753
824
|
});
|
|
754
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
|
+
}
|
|
755
847
|
function resolveArch(raw) {
|
|
756
848
|
const arch = raw ?? (process.arch === "arm64" ? "arm64" : process.arch === "x64" ? "amd64" : null);
|
|
757
849
|
if (arch === "arm64" || arch === "amd64") return arch;
|
|
@@ -762,7 +854,7 @@ function pullRelease(arch, opts = {}) {
|
|
|
762
854
|
const branch = opts.branch ?? "dev";
|
|
763
855
|
const tag = opts.tag ?? `emulator-${branch}-latest`;
|
|
764
856
|
const asset = `stack-emulator-${arch}.qcow2`;
|
|
765
|
-
const imageDir =
|
|
857
|
+
const imageDir = emulatorImageDir();
|
|
766
858
|
mkdirSync(imageDir, { recursive: true });
|
|
767
859
|
const dest = join(imageDir, asset);
|
|
768
860
|
const tmpDest = `${dest}.download`;
|
|
@@ -824,7 +916,7 @@ function registerEmulatorCommand(program) {
|
|
|
824
916
|
if (runs.length === 0) throw new CliError(`No successful build found for PR #${opts.pr} (branch: ${headRefName}).`);
|
|
825
917
|
runId = String(runs[0].databaseId);
|
|
826
918
|
}
|
|
827
|
-
const imageDir =
|
|
919
|
+
const imageDir = emulatorImageDir();
|
|
828
920
|
mkdirSync(imageDir, { recursive: true });
|
|
829
921
|
const dest = join(imageDir, `stack-emulator-${arch}.qcow2`);
|
|
830
922
|
if (existsSync(dest)) unlinkSync(dest);
|
|
@@ -852,13 +944,64 @@ function registerEmulatorCommand(program) {
|
|
|
852
944
|
tag: opts.tag
|
|
853
945
|
});
|
|
854
946
|
});
|
|
855
|
-
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) => {
|
|
856
948
|
const arch = resolveArch(opts.arch);
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
949
|
+
let resolvedConfigFile;
|
|
950
|
+
if (opts.configFile) {
|
|
951
|
+
resolvedConfigFile = resolve(opts.configFile);
|
|
952
|
+
if (!existsSync(resolvedConfigFile)) throw new CliError(`Config file not found: ${resolvedConfigFile}`);
|
|
860
953
|
}
|
|
861
|
-
|
|
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));
|
|
959
|
+
}
|
|
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
|
+
});
|
|
862
1005
|
});
|
|
863
1006
|
emulator.command("stop").description("Stop the emulator (data preserved; use 'reset' to clear)").action(() => runEmulator("stop"));
|
|
864
1007
|
emulator.command("reset").description("Reset emulator state for a fresh boot").action(() => runEmulator("reset"));
|