faster snapshot resume via mapped-ram + rotation opt-out

Snapshot resume drops from ~14s to ~5-7s with rotation, ~2.5s without.

Build uses QEMU's mapped-ram + multifd migration capability so the RAM
state is written at page-aligned offsets in a sparse file. Runtime
decompresses the shipped .savevm.zst once to a local .raw cache and
reloads via -incoming file: + migrate-incoming on subsequent starts,
avoiding the per-start zstd decode.

Adds EMULATOR_NO_ROTATION=1 for tests/CI that don't mind the placeholder
secrets; saves the full ~3s rotation window.

Misc runtime cleanups: tighter QMP/QGA poll intervals (1s → 0.2s),
shorter socat keep-alive windows, 1s settle before the post-rotation
health-check to avoid racing old Node processes, fallback path preserves
the CLI-generated runtime-config.iso instead of blowing away VM_DIR.

Build-time qmp_session keeps stdin open briefly after the caller's
commands so migrate-set-capabilities is actually processed before
socat closes — without this, mapped-ram was silently a no-op.

CI workflow publishes .savevm.zst alongside the .qcow2 (optional asset;
CLI falls back to cold boot when missing). Test + verify steps go
through the CLI now that ISO generation is owned by packages/stack-cli.
This commit is contained in:
Bilal Godil 2026-04-15 13:04:15 -07:00
parent a65022b8f7
commit 30dbdffc4a
10 changed files with 1444 additions and 312 deletions

View File

@ -55,10 +55,21 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- uses: pnpm/action-setup@v4
if: matrix.arch == 'amd64'
with:
version: 10.23.0
- uses: actions/setup-node@v4
if: matrix.arch == 'amd64'
with:
node-version: 22
cache: pnpm
- name: Install QEMU dependencies
run: |
sudo apt-get update
sudo apt-get install -y qemu-system-x86 qemu-system-arm qemu-kvm qemu-utils genisoimage socat qemu-efi-aarch64
sudo apt-get install -y qemu-system-x86 qemu-system-arm qemu-kvm qemu-utils genisoimage socat qemu-efi-aarch64 zstd
- name: Enable KVM access
run: |
@ -82,41 +93,61 @@ jobs:
- name: Generate emulator env
run: node docker/local-emulator/generate-env-development.mjs
# arm64 runs under cross-arch TCG on an amd64 runner; the backend's
# V8 TurboFan JIT re-triggers the SIGTRAPs we dodge in migrations
# with --no-opt, and even if it didn't, boot is too slow under TCG
# to verify in any sane window. amd64 KVM already exercises the
# service stack; real arm64 hosts have KVM for end-users.
- name: Start emulator and verify
# amd64 runs under KVM on the runner so we can boot the newly-built
# image to verify it works end-to-end before publishing. arm64 runs
# under cross-arch TCG on an amd64 host, which can't reliably boot
# Next.js within any sane window — skipped.
- name: Build stack-cli (for emulator CLI)
if: matrix.arch == 'amd64'
run: |
chmod +x docker/local-emulator/qemu/run-emulator.sh
EMULATOR_ARCH=${{ matrix.arch }} \
EMULATOR_READY_TIMEOUT=3200 \
docker/local-emulator/qemu/run-emulator.sh start
pnpm install --frozen-lockfile --filter @stackframe/stack-cli...
pnpm --filter @stackframe/stack-cli run build
- name: Start emulator and verify
if: matrix.arch == 'amd64'
env:
EMULATOR_ARCH: ${{ matrix.arch }}
EMULATOR_READY_TIMEOUT: 3200
EMULATOR_IMAGE_DIR: ${{ env.EMULATOR_IMAGE_DIR }}
EMULATOR_RUN_DIR: ${{ env.EMULATOR_RUN_DIR }}
run: node packages/stack-cli/dist/index.js emulator start
- name: Verify services are healthy
if: matrix.arch == 'amd64'
run: |
EMULATOR_ARCH=${{ matrix.arch }} \
docker/local-emulator/qemu/run-emulator.sh status
env:
EMULATOR_ARCH: ${{ matrix.arch }}
EMULATOR_IMAGE_DIR: ${{ env.EMULATOR_IMAGE_DIR }}
EMULATOR_RUN_DIR: ${{ env.EMULATOR_RUN_DIR }}
run: node packages/stack-cli/dist/index.js emulator status
- name: Stop emulator
if: always() && matrix.arch == 'amd64'
run: |
EMULATOR_ARCH=${{ matrix.arch }} \
docker/local-emulator/qemu/run-emulator.sh stop
env:
EMULATOR_ARCH: ${{ matrix.arch }}
EMULATOR_IMAGE_DIR: ${{ env.EMULATOR_IMAGE_DIR }}
EMULATOR_RUN_DIR: ${{ env.EMULATOR_RUN_DIR }}
run: node packages/stack-cli/dist/index.js emulator stop
- name: Package image
run: |
BASE_IMG="docker/local-emulator/qemu/images/stack-emulator-${{ matrix.arch }}.qcow2"
SAVEVM="docker/local-emulator/qemu/images/stack-emulator-${{ matrix.arch }}.savevm.zst"
cp "$BASE_IMG" "stack-emulator-${{ matrix.arch }}.qcow2"
if [ -f "$SAVEVM" ]; then
cp "$SAVEVM" "stack-emulator-${{ matrix.arch }}.savevm.zst"
ls -lh "stack-emulator-${{ matrix.arch }}.savevm.zst"
else
echo "NOTE: no savevm snapshot was produced; fast-start will be unavailable for this arch."
fi
- name: Upload image artifact
uses: actions/upload-artifact@v4
with:
name: qemu-emulator-${{ matrix.arch }}
path: stack-emulator-${{ matrix.arch }}.qcow2
path: |
stack-emulator-${{ matrix.arch }}.qcow2
stack-emulator-${{ matrix.arch }}.savevm.zst
if-no-files-found: warn
retention-days: 30
compression-level: 0
@ -137,28 +168,48 @@ jobs:
- name: Install QEMU dependencies
run: |
sudo apt-get update
sudo apt-get install -y qemu-system-x86 qemu-utils genisoimage socat
sudo apt-get install -y qemu-system-x86 qemu-utils socat zstd
- uses: pnpm/action-setup@v4
with:
version: 10.23.0
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install stack-cli deps + build
run: |
pnpm install --frozen-lockfile --filter @stackframe/stack-cli...
pnpm --filter @stackframe/stack-cli run build
- name: Download built image
uses: actions/download-artifact@v4
with:
name: qemu-emulator-${{ matrix.arch }}
path: docker/local-emulator/qemu/images/
path: ${{ github.workspace }}/.stack-emulator-images/
- name: Generate emulator env
run: node docker/local-emulator/generate-env-development.mjs
- name: Start emulator from artifact
- name: Place images into STACK_EMULATOR_HOME layout
run: |
mkdir -p "$HOME/.stack/emulator/images"
cp "${{ github.workspace }}/.stack-emulator-images/stack-emulator-${{ matrix.arch }}.qcow2" "$HOME/.stack/emulator/images/"
if [ -f "${{ github.workspace }}/.stack-emulator-images/stack-emulator-${{ matrix.arch }}.savevm.zst" ]; then
cp "${{ github.workspace }}/.stack-emulator-images/stack-emulator-${{ matrix.arch }}.savevm.zst" "$HOME/.stack/emulator/images/"
echo "Snapshot present — will test snapshot-resume path."
else
echo "No snapshot — will test cold-boot path."
fi
ls -lh "$HOME/.stack/emulator/images/"
- name: Start emulator via CLI
run: |
chmod +x docker/local-emulator/qemu/run-emulator.sh docker/local-emulator/qemu/common.sh
EMULATOR_ARCH=${{ matrix.arch }} \
EMULATOR_READY_TIMEOUT=600 \
docker/local-emulator/qemu/run-emulator.sh start
node packages/stack-cli/dist/index.js emulator start
- name: Verify services are healthy
run: |
EMULATOR_ARCH=${{ matrix.arch }} \
docker/local-emulator/qemu/run-emulator.sh status
run: node packages/stack-cli/dist/index.js emulator status
- name: Smoke test — backend health
run: curl -sf http://localhost:26701/health?db=1
@ -174,13 +225,11 @@ jobs:
- name: Stop emulator
if: always()
run: |
EMULATOR_ARCH=${{ matrix.arch }} \
docker/local-emulator/qemu/run-emulator.sh stop
run: node packages/stack-cli/dist/index.js emulator stop
- name: Print serial log on failure
if: failure()
run: tail -100 docker/local-emulator/qemu/run/vm/serial.log 2>/dev/null || true
run: tail -100 $HOME/.stack/emulator/run/vm/serial.log 2>/dev/null || true
publish:
name: Publish to GitHub Releases
@ -211,6 +260,11 @@ jobs:
for f in artifacts/qemu-emulator-*/*.qcow2; do
cp "$f" release/
done
# savevm.zst is optional — older branches may not produce it. Skip
# missing files rather than failing the publish.
for f in artifacts/qemu-emulator-*/*.savevm.zst; do
[ -f "$f" ] && cp "$f" release/
done
cat > release-notes.md <<EOF
## QEMU Emulator Images
@ -220,8 +274,13 @@ jobs:
### Images
| File | Description |
|------|-------------|
| \`stack-emulator-arm64.qcow2\` | ARM64 emulator image |
| \`stack-emulator-amd64.qcow2\` | AMD64 emulator image |
| \`stack-emulator-arm64.qcow2\` | ARM64 disk image |
| \`stack-emulator-amd64.qcow2\` | AMD64 disk image |
| \`stack-emulator-arm64.savevm.zst\` | ARM64 warm VM snapshot (fast-start) |
| \`stack-emulator-amd64.savevm.zst\` | AMD64 warm VM snapshot (fast-start) |
\`emulator pull\` downloads both; \`emulator start\` uses the snapshot
when present and falls back to cold-boot otherwise.
### Usage
\`\`\`bash

View File

@ -255,9 +255,13 @@ persist_provision_logs() {
# object per line); responses are written to stdout. Uses socat's bidirectional
# pipe so we can interleave request/response in one connection — QMP requires
# qmp_capabilities to come first and keeps state across commands.
# Keeps stdin open briefly after caller's input ends so QEMU has time to
# process the last command before socat closes.
qmp_session() {
local sock="$1"
socat -t30 - "UNIX-CONNECT:${sock}"
local payload
payload="$(cat)"
( printf '%s\n' "$payload"; sleep 0.5 ) | socat -t30 - "UNIX-CONNECT:${sock}"
}
# Drive the snapshot capture over QMP:
@ -284,9 +288,32 @@ capture_vm_state() {
return 1
}
log " QMP: enabling mapped-ram + multifd for fast resume..."
# mapped-ram: writes each RAM page to a fixed offset in the output file
# (vs the legacy streamed format). This lets the target QEMU mmap the file
# and fault pages lazily — and combined with multifd, load RAM in parallel.
# multifd-channels=4 matches our pinned SMP so the channels don't starve
# each other on the target's 4 vCPUs.
local caps_cmd params_cmd
caps_cmd='{"execute":"migrate-set-capabilities","arguments":{"capabilities":[{"capability":"mapped-ram","state":true},{"capability":"multifd","state":true}]}}'
params_cmd='{"execute":"migrate-set-parameters","arguments":{"multifd-channels":4}}'
local setup_resp
setup_resp=$({
printf '%s\n' '{"execute":"qmp_capabilities"}'
printf '%s\n' "$caps_cmd"
printf '%s\n' "$params_cmd"
} | qmp_session "$sock") || {
err "QMP capabilities setup failed"
return 1
}
if printf '%s' "$setup_resp" | grep -q '"error"[[:space:]]*:'; then
err "QMP capabilities returned error: $setup_resp"
return 1
fi
log " QMP: migrating RAM state to ${guest_path}..."
# Use file: migration (native QEMU) instead of exec: to avoid relying on a
# spawned shell finding zstd in PATH. We compress as a separate host step
# spawned shell finding zstd in PATH. Compressed as a separate host step
# after migrate completes.
local migrate_cmd
migrate_cmd=$(printf '{"execute":"migrate","arguments":{"uri":"file:%s"}}' "$guest_path")
@ -583,8 +610,10 @@ build_one() {
exit 1
fi
# zstd -1 trades ~30% larger file for ~40% faster decompression at resume.
# For shipping-and-decompress-once-per-start, that's the right balance.
log "Compressing VM state with zstd..."
zstd -3 -T0 --rm -o "$savevm_tmp" "$savevm_raw"
zstd -1 -T0 --rm -o "$savevm_tmp" "$savevm_raw"
mv "$savevm_tmp" "$savevm_file"
local savevm_size

View File

@ -17,6 +17,10 @@ READY_TIMEOUT="${EMULATOR_READY_TIMEOUT:-240}"
SNAPSHOT_READY_TIMEOUT="${EMULATOR_SNAPSHOT_READY_TIMEOUT:-45}"
# Set to 1 to force a cold boot and ignore any shipped savevm file.
EMULATOR_NO_SNAPSHOT="${EMULATOR_NO_SNAPSHOT:-0}"
# Skip the post-resume secret rotation. Keeps the baked placeholder secrets
# in place — acceptable for tests and CI that don't reach the emulator over
# a shared network. Shaves ~2-3s off `emulator start`.
EMULATOR_NO_ROTATION="${EMULATOR_NO_ROTATION:-0}"
# Fixed host-side ports for the QEMU emulator (267xx range).
# Only user-facing services are exposed; internal deps stay inside the VM.
@ -71,6 +75,13 @@ savevm_path() {
echo "$IMAGE_DIR/stack-emulator-$ARCH.savevm.zst"
}
# Cached, decompressed mapped-ram file. Created on first resume from the .zst
# and reused on subsequent resumes — mapped-ram format requires a seekable
# file, so we can't stream through zstd and use multifd at the same time.
savevm_raw_path() {
echo "$IMAGE_DIR/stack-emulator-$ARCH.savevm.raw"
}
runtime_iso_path() {
echo "$VM_DIR/runtime-config.iso"
}
@ -79,6 +90,40 @@ snapshot_available() {
[ "$EMULATOR_NO_SNAPSHOT" != "1" ] && [ -s "$(savevm_path)" ]
}
# Ensure the decompressed mapped-ram cache is up-to-date with the shipped
# .zst. Compares mtime: if .raw is older or missing, re-decompress.
ensure_savevm_raw() {
local zst raw
zst="$(savevm_path)"
raw="$(savevm_raw_path)"
local zst_ts raw_ts
case "$HOST_OS" in
darwin)
zst_ts="$(stat -f '%m' "$zst" 2>/dev/null || echo 0)"
raw_ts="$(stat -f '%m' "$raw" 2>/dev/null || echo 0)"
;;
*)
zst_ts="$(stat -c '%Y' "$zst" 2>/dev/null || echo 0)"
raw_ts="$(stat -c '%Y' "$raw" 2>/dev/null || echo 0)"
;;
esac
if [ -s "$raw" ] && [ "$raw_ts" -ge "$zst_ts" ]; then
return 0
fi
log "Decompressing snapshot cache (one-time; ~2-3GB sparse)..."
local tmp="${raw}.tmp"
rm -f "$tmp"
if ! zstd -dc "$zst" > "$tmp"; then
err "Failed to decompress $zst"
rm -f "$tmp"
return 1
fi
mv "$tmp" "$raw"
}
# Returns a fast fingerprint (size:mtime) of the base QEMU image.
# Used to detect whether the image has changed since the overlay was created.
base_image_fingerprint() {
@ -107,22 +152,14 @@ runtime_fingerprint() {
printf '%s|%s\n' "$base_fp" "$savevm_fp"
}
prepare_runtime_config_iso() {
local cfg_dir="$VM_DIR/runtime-config"
ensure_runtime_config_iso() {
local cfg_iso
cfg_iso="$(runtime_iso_path)"
rm -rf "$cfg_dir"
mkdir -p "$cfg_dir"
{
printf "STACK_EMULATOR_PORT_PREFIX=%s\n" "$PORT_PREFIX"
printf "STACK_EMULATOR_DASHBOARD_HOST_PORT=%s\n" "$EMULATOR_DASHBOARD_PORT"
printf "STACK_EMULATOR_BACKEND_HOST_PORT=%s\n" "$EMULATOR_BACKEND_PORT"
printf "STACK_EMULATOR_MINIO_HOST_PORT=%s\n" "$EMULATOR_MINIO_PORT"
printf "STACK_EMULATOR_INBUCKET_HOST_PORT=%s\n" "$EMULATOR_INBUCKET_PORT"
printf "STACK_EMULATOR_VM_DIR_HOST=%s\n" "$VM_DIR"
} > "$cfg_dir/runtime.env"
cp "$SCRIPT_DIR/../.env.development" "$cfg_dir/base.env"
make_iso_from_dir "$cfg_iso" "STACKCFG" "$cfg_dir"
if [ ! -s "$cfg_iso" ]; then
err "Runtime config ISO missing at $cfg_iso."
err "The CLI normally generates this; if you're invoking run-emulator.sh directly, run via 'stack emulator start' instead."
exit 1
fi
}
service_is_up() {
@ -274,7 +311,10 @@ build_qemu_cmd() {
local snapshot_args=() runtime_only_args=() snapshot_smp="$VM_CPUS"
if snapshot_available; then
log "Snapshot found at $savevm_file — fast-resume enabled."
snapshot_args+=(-incoming "exec:zstd -dc $savevm_file")
# -incoming defer: QEMU starts, waits for a QMP migrate-incoming command.
# We use that to set mapped-ram + multifd capabilities before loading,
# which enables parallel RAM restore (~2-3x faster than streamed decode).
snapshot_args+=(-incoming defer)
snapshot_smp="${EMULATOR_SNAPSHOT_CPUS:-4}"
if [ "$snapshot_smp" != "$VM_CPUS" ]; then
log "Pinning SMP to ${snapshot_smp} for snapshot resume (build-time value)."
@ -389,7 +429,7 @@ ensure_ports_free() {
start_vm() {
mkdir -p "$VM_DIR"
: > "$VM_DIR/serial.log"
prepare_runtime_config_iso
ensure_runtime_config_iso
build_qemu_cmd
"${QEMU_CMD[@]}"
}
@ -411,12 +451,34 @@ qmp_send() {
} | socat -t5 - "UNIX-CONNECT:$VM_DIR/monitor.sock" 2>/dev/null
}
# After -incoming, QEMU is in "inmigrate" until the entire migration stream has
# been received. Sending `cont` mid-migration would abort it (the host-side
# decompressor / pipe gets killed). Wait for the VM to reach a runnable state
# (paused / postmigrate / prelaunch / running) before continuing.
qmp_wait_for_paused_and_continue() {
local deadline=$((SECONDS + 120))
# After -incoming defer, QEMU waits for a migrate-incoming command. This sets
# up mapped-ram + multifd capabilities and kicks off the RAM load from the
# decompressed cache file. Returns once the VM is running.
qmp_incoming_and_cont() {
local raw_file="$1"
# Set caps + parameters before migrate-incoming, same as source.
local setup_resp
setup_resp=$( {
printf '%s\n' '{"execute":"migrate-set-capabilities","arguments":{"capabilities":[{"capability":"mapped-ram","state":true},{"capability":"multifd","state":true}]}}'
printf '%s\n' '{"execute":"migrate-set-parameters","arguments":{"multifd-channels":4}}'
} | qmp_send)
if printf '%s' "$setup_resp" | grep -q '"error"'; then
err "QMP caps setup failed: $setup_resp"
return 1
fi
# Kick off the incoming migration from the mapped-ram file.
local inc_cmd inc_resp
inc_cmd=$(printf '{"execute":"migrate-incoming","arguments":{"uri":"file:%s"}}' "$raw_file")
inc_resp=$(printf '%s\n' "$inc_cmd" | qmp_send)
if printf '%s' "$inc_resp" | grep -q '"error"'; then
err "QMP migrate-incoming failed: $inc_resp"
return 1
fi
# Poll until status reaches a runnable state, then cont.
local deadline=$((SECONDS + 60))
while [ "$SECONDS" -lt "$deadline" ]; do
local out status
out=$(printf '%s\n' '{"execute":"query-status"}' | qmp_send || true)
@ -430,7 +492,6 @@ qmp_wait_for_paused_and_continue() {
return 0
;;
inmigrate|"")
# still loading migration data
;;
*)
log "unexpected QMP status: $status"
@ -539,7 +600,6 @@ stop_vm() {
fi
fi
rm -f "$VM_DIR/qemu.pid" "$VM_DIR/monitor.sock" "$VM_DIR/serial.log"
rm -rf "$VM_DIR/runtime-config"
rm -f "$VM_DIR/runtime-config.iso"
}
@ -553,6 +613,11 @@ cmd_start() {
local using_snapshot=0
if snapshot_available; then
if ! ensure_savevm_raw; then
warn "Snapshot decompression failed — falling back to cold boot."
snapshot_fallback_to_cold_boot
return
fi
using_snapshot=1
fi
@ -561,8 +626,8 @@ cmd_start() {
info "VM: ${VM_RAM}MB / ${VM_CPUS} CPUs"
if [ "$using_snapshot" = "1" ]; then
log "Resuming from snapshot..."
if ! qmp_wait_for_paused_and_continue; then
log "Resuming from snapshot (mapped-ram + multifd)..."
if ! qmp_incoming_and_cont "$(savevm_raw_path)"; then
warn "Snapshot resume did not reach a runnable state — falling back to cold boot."
snapshot_fallback_to_cold_boot
return
@ -575,23 +640,33 @@ cmd_start() {
return
fi
log "Generating fresh secrets + triggering rotation..."
if ! qga_trigger_fast_rotate; then
warn "Failed to trigger rotate-secrets — falling back to cold boot."
snapshot_fallback_to_cold_boot
return
fi
if [ "$EMULATOR_NO_ROTATION" = "1" ]; then
warn "EMULATOR_NO_ROTATION=1: snapshot's placeholder secrets are in effect — do not expose this instance."
if ! wait_for_condition "services" "$SNAPSHOT_READY_TIMEOUT" all_ready; then
warn "Services did not respond after resume — falling back to cold boot."
tail_vm_logs
snapshot_fallback_to_cold_boot
return
fi
else
log "Generating fresh secrets + triggering rotation..."
if ! qga_trigger_fast_rotate; then
warn "Failed to trigger rotate-secrets — falling back to cold boot."
snapshot_fallback_to_cold_boot
return
fi
# Wait for the *new* backend (post-supervisor-restart) to actually be
# listening. all_ready may briefly return true against the OLD Node
# processes between when supervisor sends SIGTERM and when the children
# die; sleep a beat so we measure the real readiness.
sleep 1
if ! wait_for_condition "rotated services" "$SNAPSHOT_READY_TIMEOUT" all_ready; then
warn "Services did not recover after rotation — falling back to cold boot."
tail_vm_logs
snapshot_fallback_to_cold_boot
return
# Wait for the *new* backend (post-supervisor-restart) to actually be
# listening. all_ready may briefly return true against the OLD Node
# processes between when supervisor sends SIGTERM and when the children
# die; sleep a beat so we measure the real readiness.
sleep 1
if ! wait_for_condition "rotated services" "$SNAPSHOT_READY_TIMEOUT" all_ready; then
warn "Services did not recover after rotation — falling back to cold boot."
tail_vm_logs
snapshot_fallback_to_cold_boot
return
fi
fi
else
if ! wait_for_condition "deps services" "$READY_TIMEOUT" deps_ready; then
@ -616,7 +691,11 @@ cmd_start() {
snapshot_fallback_to_cold_boot() {
warn "Retrying with cold boot (EMULATOR_NO_SNAPSHOT=1)..."
stop_vm
rm -rf "$VM_DIR"
# Wipe the overlay + fingerprint so build_qemu_cmd re-creates a fresh one,
# but keep the CLI-generated runtime-config.iso (we can't regenerate it
# from shell — the CLI owns that).
rm -f "$VM_DIR/disk.qcow2" "$VM_DIR/base-image.fingerprint" \
"$VM_DIR/seed.phantom" "$VM_DIR/bundle.phantom"
EMULATOR_NO_SNAPSHOT=1
cmd_start
}

View File

@ -13,7 +13,8 @@
"build": "tsdown && node scripts/copy-emulator-assets.mjs",
"dev": "tsdown --watch",
"lint": "eslint --ext .tsx,.ts .",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"files": [
"README.md",
@ -31,6 +32,7 @@
"@stackframe/js": "workspace:*",
"@stackframe/stack-shared": "workspace:*",
"commander": "^13.1.0",
"extract-zip": "^2.0.1",
"jiti": "^2.4.2"
},
"devDependencies": {

View File

@ -0,0 +1,166 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
envPort,
formatBytes,
formatDuration,
platformInstallHint,
renderProgressLine,
resolveArch,
} from "./emulator.js";
describe("formatBytes", () => {
it("renders B / KB / MB / GB across unit boundaries", () => {
expect(formatBytes(0)).toBe("0 B");
expect(formatBytes(1)).toBe("1 B");
expect(formatBytes(1023)).toBe("1023 B");
expect(formatBytes(1024)).toBe("1.0 KB");
expect(formatBytes(1536)).toBe("1.5 KB");
expect(formatBytes(1024 * 1024)).toBe("1.0 MB");
expect(formatBytes(1024 * 1024 * 1024)).toBe("1.0 GB");
expect(formatBytes(1024 * 1024 * 1024 * 1024)).toBe("1.0 TB");
});
it("switches precision at v>=10 within a unit", () => {
expect(formatBytes(1024 * 10)).toBe("10 KB");
expect(formatBytes(1024 * 9.5)).toBe("9.5 KB");
});
it("returns '?' for non-finite and negative values", () => {
expect(formatBytes(NaN)).toBe("?");
expect(formatBytes(Infinity)).toBe("?");
expect(formatBytes(-1)).toBe("?");
});
it("caps at TB for very large values", () => {
// Even if we exceed TB, we don't walk off the end of the units array.
const huge = 1024 ** 6; // exabyte-scale
expect(formatBytes(huge)).toMatch(/ TB$/);
});
});
describe("formatDuration", () => {
it("uses s/m/h units at the right boundaries", () => {
expect(formatDuration(0)).toBe("0s");
expect(formatDuration(59)).toBe("59s");
expect(formatDuration(60)).toBe("1m00s");
expect(formatDuration(61)).toBe("1m01s");
expect(formatDuration(3599)).toBe("59m59s");
expect(formatDuration(3600)).toBe("1h00m");
expect(formatDuration(3660)).toBe("1h01m");
});
it("rounds seconds to integers", () => {
expect(formatDuration(59.4)).toBe("59s");
expect(formatDuration(59.9)).toBe("1m00s");
});
it("returns '?' for non-finite and negative values", () => {
expect(formatDuration(NaN)).toBe("?");
expect(formatDuration(Infinity)).toBe("?");
expect(formatDuration(-1)).toBe("?");
});
});
describe("renderProgressLine", () => {
it("renders a known-size progress bar with percent, size, speed, and ETA", () => {
const line = renderProgressLine(1024, 2048, 512);
expect(line).toContain("50.0%");
expect(line).toContain("/");
expect(line).toContain("/s");
expect(line).toContain("eta");
});
it("hides the percent / ETA fields when total size is unknown (total=0)", () => {
const line = renderProgressLine(1024, 0, 512);
expect(line).not.toContain("%");
expect(line).not.toContain("eta");
expect(line).toContain("/s");
});
it("clamps percent at 100 if downloaded overshoots total (rounding)", () => {
const line = renderProgressLine(2050, 2048, 100);
expect(line).toContain("100.0%");
});
it("handles bytesPerSec = 0 by suppressing ETA", () => {
const line = renderProgressLine(512, 2048, 0);
expect(line).not.toContain("eta");
});
});
describe("envPort", () => {
const SAVED = process.env.__TEST_PORT;
beforeEach(() => {
delete process.env.__TEST_PORT;
});
afterEach(() => {
if (SAVED === undefined) delete process.env.__TEST_PORT;
else process.env.__TEST_PORT = SAVED;
});
it("returns the fallback when the env var is not set", () => {
expect(envPort("__TEST_PORT", 1234)).toBe(1234);
});
it("parses a valid integer value", () => {
process.env.__TEST_PORT = "9876";
expect(envPort("__TEST_PORT", 1234)).toBe(9876);
});
it("rejects zero and negative values", () => {
process.env.__TEST_PORT = "0";
expect(() => envPort("__TEST_PORT", 1234)).toThrow(/Invalid __TEST_PORT/);
process.env.__TEST_PORT = "-5";
expect(() => envPort("__TEST_PORT", 1234)).toThrow(/Invalid __TEST_PORT/);
});
it("rejects non-integer and non-numeric values", () => {
process.env.__TEST_PORT = "3.14";
expect(() => envPort("__TEST_PORT", 1234)).toThrow(/Invalid __TEST_PORT/);
process.env.__TEST_PORT = "not-a-port";
expect(() => envPort("__TEST_PORT", 1234)).toThrow(/Invalid __TEST_PORT/);
});
it("treats empty string as not set (returns fallback)", () => {
// Regression target: earlier versions sometimes parsed "" as 0 and threw.
process.env.__TEST_PORT = "";
expect(envPort("__TEST_PORT", 1234)).toBe(1234);
});
});
describe("resolveArch", () => {
it("accepts explicit arm64 / amd64", () => {
expect(resolveArch("arm64")).toBe("arm64");
expect(resolveArch("amd64")).toBe("amd64");
});
it("throws on unsupported explicit arch", () => {
expect(() => resolveArch("mips")).toThrow(/Invalid architecture/);
expect(() => resolveArch("x86")).toThrow(/Invalid architecture/);
});
it("maps the current process arch when raw is undefined", () => {
const expected = process.arch === "arm64" ? "arm64" : process.arch === "x64" ? "amd64" : null;
if (expected === null) {
expect(() => resolveArch()).toThrow(/Invalid architecture/);
} else {
expect(resolveArch()).toBe(expected);
}
});
});
describe("platformInstallHint", () => {
it("uses brew on darwin and apt on linux", () => {
const spy = vi.spyOn(process, "platform", "get");
try {
spy.mockReturnValue("darwin");
expect(platformInstallHint("foo-linux", "foo-mac")).toContain("brew install foo-mac");
spy.mockReturnValue("linux");
expect(platformInstallHint("foo-linux", "foo-mac")).toContain("apt install foo-linux");
spy.mockReturnValue("win32");
expect(platformInstallHint("foo-linux", "foo-mac")).toContain("install foo-mac");
} finally {
spy.mockRestore();
}
});
});

View File

@ -1,5 +1,6 @@
import { Command } from "commander";
import { execFileSync, spawn } from "child_process";
import extract from "extract-zip";
import { createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync } from "fs";
import { homedir } from "os";
import { dirname, join, resolve } from "path";
@ -7,19 +8,36 @@ 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",
];
function emulatorBackendPort(): number {
const raw = process.env.EMULATOR_BACKEND_PORT;
if (!raw) return DEFAULT_EMULATOR_BACKEND_PORT;
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 EMULATOR_BACKEND_PORT: ${raw}`);
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");
}
@ -84,17 +102,42 @@ async function fetchEmulatorCredentials(pck: string, backendPort: number, config
};
}
function gh(args: string[]): string {
// 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 {
return execFileSync("gh", args, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
} catch (err: unknown) {
if (err instanceof Error && "stderr" in err && typeof err.stderr === "string") {
throw new CliError(`GitHub CLI error: ${err.stderr}`);
}
throw new CliError("GitHub CLI (gh) is required. Install: https://cli.github.com/");
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");
@ -104,6 +147,16 @@ function emulatorScriptsDir(): string {
throw new CliError("Emulator scripts not found in CLI bundle.");
}
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,
@ -113,6 +166,33 @@ function emulatorSpawnEnv(extra?: Record<string, string>): NodeJS.ProcessEnv {
};
}
// 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 });
@ -149,17 +229,21 @@ async function startEmulator(arch: "arm64" | "amd64"): Promise<void> {
console.log("No emulator image found. Pulling latest...");
await pullRelease(arch);
}
prepareRuntimeConfigIso();
await runEmulator("start", { EMULATOR_ARCH: arch });
}
function resolveArch(raw?: string): "arm64" | "amd64" {
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 ?? "stack-auth/stack-auth";
const repo = opts.repo ?? DEFAULT_REPO;
const branch = opts.branch ?? "dev";
const tag = opts.tag ?? `emulator-${branch}-latest`;
const imageDir = emulatorImageDir();
@ -171,39 +255,36 @@ async function pullRelease(arch: "arm64" | "amd64", opts: { repo?: string, branc
// back to a cold boot.
const snapshotAsset = `stack-emulator-${arch}.savevm.zst`;
const assets = JSON.parse(gh(["release", "view", tag, "--repo", repo, "--json", "assets"])) as {
assets: { name: string, apiUrl: string, size: number }[],
};
const diskMatch = assets.assets.find((a) => a.name === diskAsset);
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 snapshotMatch = assets.assets.find((a) => a.name === snapshotAsset);
const token = gh(["auth", "token"]);
const snapshotMatch = release.assets.find((a) => a.name === snapshotAsset);
const token = githubToken();
await downloadAsset(diskMatch, imageDir, diskAsset, token, tag);
await downloadReleaseAsset(diskMatch, imageDir, diskAsset, token, tag);
if (snapshotMatch) {
await downloadAsset(snapshotMatch, imageDir, snapshotAsset, token, tag);
await downloadReleaseAsset(snapshotMatch, imageDir, snapshotAsset, token, tag);
} else {
console.log(`Snapshot asset ${snapshotAsset} not available in release ${tag}; fast-start disabled for this image.`);
}
}
async function downloadAsset(
match: { name: string, apiUrl: string, size: number },
async function downloadReleaseAsset(
match: ReleaseAsset,
imageDir: string,
asset: string,
token: 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.apiUrl, {
Authorization: `Bearer ${token}`,
Accept: "application/octet-stream",
}, tmpDest, match.size);
await downloadWithProgress(match.url, headers, tmpDest, match.size);
} catch (err) {
if (existsSync(tmpDest)) unlinkSync(tmpDest);
if (err instanceof CliError) throw err;
@ -248,7 +329,7 @@ async function downloadWithProgress(url: string, headers: Record<string, string>
if (isTty) process.stderr.write("\n");
}
function renderProgressLine(downloaded: number, total: number, bytesPerSec: number): string {
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;
@ -260,7 +341,7 @@ function renderProgressLine(downloaded: number, total: number, bytesPerSec: numb
return ` [${bar}] ${pctStr} ${sizeStr} ${speedStr}${etaStr}`;
}
function formatBytes(bytes: number): string {
export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return "?";
const units = ["B", "KB", "MB", "GB", "TB"];
let v = bytes;
@ -272,7 +353,7 @@ function formatBytes(bytes: number): string {
return `${v.toFixed(v < 10 && i > 0 ? 1 : 0)} ${units[i]}`;
}
function formatDuration(seconds: number): string {
export function formatDuration(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return "?";
const s = Math.round(seconds);
if (s < 60) return `${s}s`;
@ -284,6 +365,116 @@ function formatDuration(seconds: number): string {
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/octet-stream", 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");
@ -298,16 +489,21 @@ export function registerEmulatorCommand(program: Command) {
.option("--run <id>", "Pull from a specific workflow run's artifacts")
.action(async (opts) => {
const arch = resolveArch(opts.arch);
const repo = opts.repo ?? "stack-auth/stack-auth";
const repo = opts.repo ?? DEFAULT_REPO;
if (opts.run || opts.pr) {
let runId = opts.run as string | undefined;
if (!runId) {
console.log(`Finding latest successful build for PR #${opts.pr}...`);
const { headRefName } = JSON.parse(gh(["pr", "view", opts.pr, "--repo", repo, "--json", "headRefName"]));
const runs = JSON.parse(gh(["run", "list", "--repo", repo, "--workflow", "qemu-emulator-build.yaml", "--branch", headRefName, "--status", "success", "--limit", "1", "--json", "databaseId"]));
if (runs.length === 0) throw new CliError(`No successful build found for PR #${opts.pr} (branch: ${headRefName}).`);
runId = String(runs[0].databaseId);
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();
@ -316,21 +512,22 @@ export function registerEmulatorCommand(program: Command) {
const snapshotDest = join(imageDir, `stack-emulator-${arch}.savevm.zst`);
if (existsSync(dest)) unlinkSync(dest);
if (existsSync(snapshotDest)) unlinkSync(snapshotDest);
console.log(`Downloading qemu-emulator-${arch} from workflow run ${runId}...`);
try {
execFileSync("gh", ["run", "download", runId, "--repo", repo, "--name", `qemu-emulator-${arch}`, "--dir", imageDir], { stdio: "inherit" });
} catch (err) {
throw new CliError(`Failed to download artifact from run ${runId}: ${err instanceof Error ? err.message : err}`);
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}`);
// Snapshot artifact is optional — older CI builds may not produce it.
let snapshotDownloaded = false;
try {
execFileSync("gh", ["run", "download", runId, "--repo", repo, "--name", `qemu-emulator-${arch}-savevm`, "--dir", imageDir], { stdio: "pipe" });
if (existsSync(snapshotDest)) {
console.log(`Downloaded: ${snapshotDest}`);
}
} catch {
snapshotDownloaded = await downloadArtifactByName(repo, runId, `qemu-emulator-${arch}-savevm`, imageDir);
} catch (err) {
console.log(`Snapshot artifact unavailable for run ${runId}: ${err instanceof Error ? err.message : err}`);
}
if (snapshotDownloaded && existsSync(snapshotDest)) {
console.log(`Downloaded: ${snapshotDest}`);
} else if (!snapshotDownloaded) {
console.log(`Snapshot artifact not available for run ${runId}; fast-start disabled.`);
}
} else {
@ -345,6 +542,7 @@ export function registerEmulatorCommand(program: Command) {
.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) {
@ -375,6 +573,7 @@ export function registerEmulatorCommand(program: Command) {
.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) {
@ -429,18 +628,50 @@ export function registerEmulatorCommand(program: Command) {
});
});
emulator.command("stop").description("Stop the emulator (data preserved; use 'reset' to clear)").action(() => runEmulator("stop"));
emulator.command("reset").description("Reset emulator state for a fresh boot").action(() => runEmulator("reset"));
emulator.command("status").description("Show emulator and service health").action(() => runEmulator("status"));
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((opts) => {
const repo = opts.repo ?? "stack-auth/stack-auth";
.action(async (opts) => {
const repo = opts.repo ?? DEFAULT_REPO;
console.log(`Available emulator releases from ${repo}:\n`);
const lines = gh(["release", "list", "--repo", repo, "--limit", "20"]).split("\n").filter((l) => l.toLowerCase().includes("emulator"));
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);
});

View File

@ -0,0 +1,259 @@
import { describe, expect, it } from "vitest";
import { buildIso, type IsoFile } from "./iso.js";
const SECTOR = 2048;
// --- Test helpers: a minimal ISO 9660 parser, just enough to walk the
// directory records we produce so tests can assert the bytes we emitted really
// are addressable at the offsets claimed in the directory records.
function readSector(iso: Buffer, sector: number): Buffer {
return iso.subarray(sector * SECTOR, (sector + 1) * SECTOR);
}
function readVolumeDescriptor(iso: Buffer, sector: number): { type: number, id: string } {
const buf = readSector(iso, sector);
return { type: buf[0], id: buf.toString("ascii", 1, 6) };
}
type DirRecord = {
lenDr: number,
extentSector: number,
dataLength: number,
isDir: boolean,
fileId: Buffer,
};
function parseDirRecords(sector: Buffer): DirRecord[] {
const records: DirRecord[] = [];
let offset = 0;
while (offset < sector.length) {
const lenDr = sector[offset];
if (lenDr === 0) break;
const extentSector = sector.readUInt32LE(offset + 2);
const dataLength = sector.readUInt32LE(offset + 10);
const flags = sector[offset + 25];
const lenFi = sector[offset + 32];
const fileId = sector.subarray(offset + 33, offset + 33 + lenFi);
records.push({
lenDr,
extentSector,
dataLength,
isDir: (flags & 0x02) !== 0,
fileId: Buffer.from(fileId),
});
offset += lenDr;
}
return records;
}
// Follow PVD → root dir → pull file bytes by ISO-9660 name ("NAME.EXT;1").
function readIsoFile(iso: Buffer, isoName: string): Buffer | null {
const pvd = readSector(iso, 16);
const rootSector = pvd.readUInt32LE(156 + 2);
const rootRecords = parseDirRecords(readSector(iso, rootSector));
const match = rootRecords.find((r) => r.fileId.toString("ascii") === isoName);
if (!match) return null;
const start = match.extentSector * SECTOR;
return iso.subarray(start, start + match.dataLength);
}
// Same, but follow the Joliet SVD (so names are UCS-2 BE).
function readJolietFile(iso: Buffer, name: string): Buffer | null {
const svd = readSector(iso, 17);
if (svd[0] !== 2) return null;
const rootSector = svd.readUInt32LE(156 + 2);
const rootRecords = parseDirRecords(readSector(iso, rootSector));
const expected = Buffer.alloc(name.length * 2);
for (let i = 0; i < name.length; i++) expected.writeUInt16BE(name.charCodeAt(i), i * 2);
const match = rootRecords.find((r) => r.fileId.equals(expected));
if (!match) return null;
const start = match.extentSector * SECTOR;
return iso.subarray(start, start + match.dataLength);
}
function sampleFile(name: string, size: number, byte = 0x41): IsoFile {
return { name, data: Buffer.alloc(size, byte) };
}
describe("buildIso — structural invariants", () => {
it("emits the ISO 9660 standard identifiers at sectors 16, 17, 18", () => {
const iso = buildIso("STACKCFG", [{ name: "a.txt", data: Buffer.from("hi") }]);
expect(readVolumeDescriptor(iso, 16)).toEqual({ type: 1, id: "CD001" });
expect(readVolumeDescriptor(iso, 17)).toEqual({ type: 2, id: "CD001" });
expect(readVolumeDescriptor(iso, 18)).toEqual({ type: 0xff, id: "CD001" });
});
it("stores the volume identifier verbatim in the PVD for blkid discovery", () => {
const iso = buildIso("STACKCFG", [{ name: "a.txt", data: Buffer.from("x") }]);
const pvd = readSector(iso, 16);
expect(pvd.toString("ascii", 40, 40 + 8)).toBe("STACKCFG");
});
it("stores the volume identifier in the Joliet SVD as UCS-2 BE", () => {
const iso = buildIso("STACKCFG", [{ name: "a.txt", data: Buffer.from("x") }]);
const svd = readSector(iso, 17);
const ucs = svd.subarray(40, 40 + 16);
let decoded = "";
for (let i = 0; i < ucs.length; i += 2) decoded += String.fromCharCode(ucs.readUInt16BE(i));
expect(decoded).toBe("STACKCFG");
});
it("sets the Joliet escape sequence %/E", () => {
const iso = buildIso("STACKCFG", [{ name: "a.txt", data: Buffer.from("x") }]);
const svd = readSector(iso, 17);
expect(svd[88]).toBe(0x25);
expect(svd[89]).toBe(0x2f);
expect(svd[90]).toBe(0x45);
});
it("declares a volume space size equal to the emitted sector count", () => {
const iso = buildIso("STACKCFG", [{ name: "a.txt", data: Buffer.from("hello world") }]);
const pvd = readSector(iso, 16);
const declared = pvd.readUInt32LE(80);
expect(iso.length).toBe(declared * SECTOR);
});
});
describe("buildIso — file round-trip", () => {
it("makes files readable by ISO 9660 name", () => {
const iso = buildIso("STACKCFG", [
{ name: "runtime.env", data: Buffer.from("KEY=value\n") },
{ name: "base.env", data: Buffer.from("FOO=bar\n") },
]);
expect(readIsoFile(iso, "RUNTIME.ENV;1")?.toString()).toBe("KEY=value\n");
expect(readIsoFile(iso, "BASE.ENV;1")?.toString()).toBe("FOO=bar\n");
});
it("makes files readable by Joliet (lowercase) name", () => {
const iso = buildIso("STACKCFG", [
{ name: "runtime.env", data: Buffer.from("KEY=value\n") },
{ name: "base.env", data: Buffer.from("FOO=bar\n") },
]);
expect(readJolietFile(iso, "runtime.env")?.toString()).toBe("KEY=value\n");
expect(readJolietFile(iso, "base.env")?.toString()).toBe("FOO=bar\n");
});
it("preserves exact file contents byte-for-byte", () => {
const content = Buffer.from([0x00, 0xff, 0x7f, 0x80, 0x41, 0x42, 0x43]);
const iso = buildIso("STACKCFG", [{ name: "bin.dat", data: content }]);
expect(readJolietFile(iso, "bin.dat")?.equals(content)).toBe(true);
});
it("handles files whose length is exactly one sector", () => {
const content = Buffer.alloc(SECTOR, 0x37);
const iso = buildIso("STACKCFG", [{ name: "one.bin", data: content }]);
expect(readJolietFile(iso, "one.bin")?.equals(content)).toBe(true);
});
it("handles files that span multiple sectors", () => {
const content = Buffer.alloc(SECTOR * 3 + 17, 0x55);
const iso = buildIso("STACKCFG", [{ name: "big.bin", data: content }]);
expect(readJolietFile(iso, "big.bin")?.equals(content)).toBe(true);
});
it("keeps files byte-exact at the claimed extent sector across multi-file layouts", () => {
// Fingerprint each file so we can tell them apart even if extents shift.
const files: IsoFile[] = [
{ name: "alpha.bin", data: Buffer.alloc(SECTOR + 5, 0xaa) },
{ name: "beta.bin", data: Buffer.alloc(SECTOR * 2, 0xbb) },
{ name: "gamma.bin", data: Buffer.alloc(42, 0xcc) },
];
const iso = buildIso("STACKCFG", files);
for (const f of files) {
expect(readJolietFile(iso, f.name)?.equals(f.data)).toBe(true);
}
});
});
describe("buildIso — edge cases", () => {
it("handles empty files without misaligning subsequent file extents", () => {
// Regression: `padToSector(Buffer.alloc(0))` used to return a 0-byte
// buffer, but the layout reserved 1 sector for the empty file — the next
// file was then read from the empty file's reserved slot.
const files: IsoFile[] = [
{ name: "empty.txt", data: Buffer.alloc(0) },
{ name: "after.txt", data: Buffer.from("marker\n") },
];
const iso = buildIso("STACKCFG", files);
expect(readJolietFile(iso, "empty.txt")?.length).toBe(0);
expect(readJolietFile(iso, "after.txt")?.toString()).toBe("marker\n");
// And: the declared volume space size must cover every emitted byte.
const pvd = readSector(iso, 16);
expect(iso.length).toBe(pvd.readUInt32LE(80) * SECTOR);
});
it("writes the exact file length in the directory record (not padded to sector)", () => {
const content = Buffer.from("abc");
const iso = buildIso("STACKCFG", [{ name: "tiny.txt", data: content }]);
const svd = readSector(iso, 17);
const rootSector = svd.readUInt32LE(156 + 2);
const records = parseDirRecords(readSector(iso, rootSector));
const file = records.find((r) => !r.isDir);
expect(file?.dataLength).toBe(3);
});
it("places the root directory records for . and .. pointing at the root extent", () => {
const iso = buildIso("STACKCFG", [{ name: "x.txt", data: Buffer.from("1") }]);
const svd = readSector(iso, 17);
const rootSector = svd.readUInt32LE(156 + 2);
const records = parseDirRecords(readSector(iso, rootSector));
expect(records.length).toBeGreaterThanOrEqual(2);
expect(records[0].fileId.equals(Buffer.from([0x00]))).toBe(true);
expect(records[1].fileId.equals(Buffer.from([0x01]))).toBe(true);
expect(records[0].isDir).toBe(true);
expect(records[0].extentSector).toBe(rootSector);
expect(records[1].extentSector).toBe(rootSector);
});
it("truncates volume identifiers longer than 32 bytes rather than corrupting the PVD", () => {
const longId = "A".repeat(64);
const iso = buildIso(longId, [{ name: "x.txt", data: Buffer.from("1") }]);
const pvd = readSector(iso, 16);
expect(pvd.toString("ascii", 40, 40 + 32)).toBe("A".repeat(32));
// Sector 17 should still be the Joliet SVD, not clobbered.
expect(pvd[881]).toBe(1);
expect(readVolumeDescriptor(iso, 17).type).toBe(2);
});
it("rejects an input set whose root directory record overflows one sector", () => {
// Each Joliet dir record for an N-char name is 33 + 2N + (2N even ? 1 : 0)
// ≈ 2N + 34 bytes. A sector is 2048. Thirty 30-char names → ~1860 bytes
// plus "." + ".." (68) → fits. Eighty of them → well over a sector.
const many: IsoFile[] = Array.from({ length: 80 }, (_, i) => ({
name: `file-${String(i).padStart(3, "0")}-padding-padding.bin`,
data: Buffer.from("x"),
}));
expect(() => buildIso("STACKCFG", many)).toThrow(/Root directory exceeds/);
});
it("produces a sector-aligned buffer regardless of file sizes", () => {
for (const size of [0, 1, SECTOR - 1, SECTOR, SECTOR + 1, SECTOR * 5 - 1]) {
const iso = buildIso("STACKCFG", [sampleFile("a.bin", size)]);
expect(iso.length % SECTOR).toBe(0);
}
});
});
describe("buildIso — multiple file sector layout", () => {
it("assigns non-overlapping extents to all files", () => {
const files: IsoFile[] = [
sampleFile("a.bin", 10, 0x01),
sampleFile("b.bin", SECTOR, 0x02),
sampleFile("c.bin", SECTOR * 2 + 500, 0x03),
sampleFile("d.bin", 1, 0x04),
];
const iso = buildIso("STACKCFG", files);
const svd = readSector(iso, 17);
const rootSector = svd.readUInt32LE(156 + 2);
const records = parseDirRecords(readSector(iso, rootSector)).filter((r) => !r.isDir);
// Extents must be strictly ordered and non-overlapping.
const sorted = [...records].sort((a, b) => a.extentSector - b.extentSector);
for (let i = 1; i < sorted.length; i++) {
const prev = sorted[i - 1];
const prevEndSector = prev.extentSector + Math.max(1, Math.ceil(prev.dataLength / SECTOR));
expect(sorted[i].extentSector).toBeGreaterThanOrEqual(prevEndSector);
}
});
});

View File

@ -0,0 +1,403 @@
// Minimal ISO 9660 + Joliet writer used to package the runtime config blob
// that the emulator VM mounts at boot via /dev/disk/by-label/STACKCFG.
//
// Replaces the host-side dependency on hdiutil/mkisofs/genisoimage. Only the
// subset of ECMA-119 needed for a single-level root directory of small UTF-8
// text files is implemented: PVD + Joliet SVD + path tables + root dir + file
// data. Names are emitted in both ISO 9660 ("BASE.ENV;1") and Joliet
// (lower-case UCS-2) form so Linux mounts the Joliet view by default and the
// guest's `source /mnt/stack-runtime/runtime.env` works unchanged.
import { writeFileSync } from "fs";
const SECTOR = 2048;
function bothEndian32(n: number): Buffer {
const b = Buffer.alloc(8);
b.writeUInt32LE(n, 0);
b.writeUInt32BE(n, 4);
return b;
}
function bothEndian16(n: number): Buffer {
const b = Buffer.alloc(4);
b.writeUInt16LE(n, 0);
b.writeUInt16BE(n, 2);
return b;
}
function padString(s: string, len: number, fill = " "): Buffer {
const buf = Buffer.alloc(len, fill.charCodeAt(0));
buf.write(s.slice(0, len), 0, "ascii");
return buf;
}
function ucs2BE(s: string): Buffer {
const buf = Buffer.alloc(s.length * 2);
for (let i = 0; i < s.length; i++) {
buf.writeUInt16BE(s.charCodeAt(i), i * 2);
}
return buf;
}
function padUcs2BE(s: string, byteLen: number): Buffer {
const buf = Buffer.alloc(byteLen);
const wholeChars = Math.floor(byteLen / 2);
for (let i = 0; i < wholeChars; i++) {
buf.writeUInt16BE(i < s.length ? s.charCodeAt(i) : 0x0020, i * 2);
}
// Odd-length fields (e.g. 37-byte Copyright/Abstract/Bibliographic IDs) get
// a trailing space byte; spec allows either NUL or 0x20 padding.
if (byteLen % 2 === 1) {
buf[byteLen - 1] = 0x20;
}
return buf;
}
function dirRecordingDate(d: Date): Buffer {
const buf = Buffer.alloc(7);
buf[0] = d.getUTCFullYear() - 1900;
buf[1] = d.getUTCMonth() + 1;
buf[2] = d.getUTCDate();
buf[3] = d.getUTCHours();
buf[4] = d.getUTCMinutes();
buf[5] = d.getUTCSeconds();
buf[6] = 0;
return buf;
}
function volumeDate(d: Date): Buffer {
const pad = (n: number, w: number) => String(n).padStart(w, "0");
const s =
pad(d.getUTCFullYear(), 4) +
pad(d.getUTCMonth() + 1, 2) +
pad(d.getUTCDate(), 2) +
pad(d.getUTCHours(), 2) +
pad(d.getUTCMinutes(), 2) +
pad(d.getUTCSeconds(), 2) +
"00";
const buf = Buffer.alloc(17);
buf.write(s, 0, 16, "ascii");
buf[16] = 0;
return buf;
}
const UNUSED_VOLUME_DATE = (() => {
const buf = Buffer.alloc(17, "0".charCodeAt(0));
buf[16] = 0;
return buf;
})();
// Encodes an ISO 9660 file identifier ("FILENAME.EXT;1"). Caller must pass an
// already-uppercased 8.3 name without the version suffix.
function isoFileIdentifier(name: string): Buffer {
const upper = name.toUpperCase();
return Buffer.from(`${upper};1`, "ascii");
}
// Builds a single directory record. `idBytes` is the file identifier bytes
// (ASCII for ISO, UCS-2 BE for Joliet); `idForDot` overrides with a single
// 0x00 / 0x01 byte for "." / ".." entries.
function buildDirRecord(
extentSector: number,
dataLength: number,
isDir: boolean,
recDate: Buffer,
idBytes: Buffer,
): Buffer {
const lenFi = idBytes.length;
const pad = lenFi % 2 === 0 ? 1 : 0;
const lenDr = 33 + lenFi + pad;
const buf = Buffer.alloc(lenDr);
buf[0] = lenDr;
buf[1] = 0;
bothEndian32(extentSector).copy(buf, 2);
bothEndian32(dataLength).copy(buf, 10);
recDate.copy(buf, 18);
buf[25] = isDir ? 0x02 : 0x00;
buf[26] = 0;
buf[27] = 0;
bothEndian16(1).copy(buf, 28);
buf[32] = lenFi;
idBytes.copy(buf, 33);
return buf;
}
function buildRootDirEntries(
rootSector: number,
rootSize: number,
recDate: Buffer,
files: { idBytes: Buffer, sector: number, size: number }[],
): Buffer {
const records: Buffer[] = [];
records.push(buildDirRecord(rootSector, rootSize, true, recDate, Buffer.from([0x00])));
records.push(buildDirRecord(rootSector, rootSize, true, recDate, Buffer.from([0x01])));
for (const f of files) {
records.push(buildDirRecord(f.sector, f.size, false, recDate, f.idBytes));
}
// Records may not span sector boundaries; pack them with sector padding.
const sectors: Buffer[] = [];
let current = Buffer.alloc(0);
for (const r of records) {
if (current.length + r.length > SECTOR) {
sectors.push(Buffer.concat([current, Buffer.alloc(SECTOR - current.length)]));
current = Buffer.alloc(0);
}
current = Buffer.concat([current, r]);
}
if (current.length > 0) {
sectors.push(Buffer.concat([current, Buffer.alloc(SECTOR - current.length)]));
}
return Buffer.concat(sectors);
}
// Single-entry path table for the root directory. Used for both L (LE) and M
// (BE) tables; pass writeUInt32LE/BE accordingly.
function buildPathTable(rootSector: number, byteOrder: "LE" | "BE"): Buffer {
const buf = Buffer.alloc(10);
buf[0] = 1; // LEN_DI
buf[1] = 0; // EAR length
if (byteOrder === "LE") {
buf.writeUInt32LE(rootSector, 2);
buf.writeUInt16LE(1, 6);
} else {
buf.writeUInt32BE(rootSector, 2);
buf.writeUInt16BE(1, 6);
}
buf[8] = 0; // root identifier
buf[9] = 0; // pad
return buf;
}
function padToSector(buf: Buffer): Buffer {
const rem = buf.length % SECTOR;
if (rem === 0) return buf;
return Buffer.concat([buf, Buffer.alloc(SECTOR - rem)]);
}
// Build a Volume Descriptor (PVD or Joliet SVD). `joliet` switches volume-name
// fields to UCS-2 BE and sets the Joliet escape sequence.
function buildVolumeDescriptor(opts: {
joliet: boolean,
volumeId: string,
volumeSpaceSize: number,
pathTableSize: number,
lPathSector: number,
mPathSector: number,
rootDirRecord: Buffer,
date: Buffer,
}): Buffer {
const buf = Buffer.alloc(SECTOR);
buf[0] = opts.joliet ? 2 : 1;
buf.write("CD001", 1, 5, "ascii");
buf[6] = 1;
buf[7] = 0;
// System Identifier (32 bytes)
if (opts.joliet) {
padUcs2BE("", 32).copy(buf, 8);
} else {
padString("", 32).copy(buf, 8);
}
// Volume Identifier (32 bytes) — must be "STACKCFG" so udev exposes it as
// /dev/disk/by-label/STACKCFG. blkid reads from PVD by default but Joliet
// takes precedence when both are present.
if (opts.joliet) {
padUcs2BE(opts.volumeId, 32).copy(buf, 40);
} else {
padString(opts.volumeId, 32).copy(buf, 40);
}
bothEndian32(opts.volumeSpaceSize).copy(buf, 80);
if (opts.joliet) {
// Escape sequence for UCS-2 Level 3 ("%/E") at offset 88 (32 bytes).
buf[88] = 0x25;
buf[89] = 0x2f;
buf[90] = 0x45;
}
bothEndian16(1).copy(buf, 120); // Volume Set Size
bothEndian16(1).copy(buf, 124); // Volume Sequence Number
bothEndian16(SECTOR).copy(buf, 128); // Logical Block Size
bothEndian32(opts.pathTableSize).copy(buf, 132);
buf.writeUInt32LE(opts.lPathSector, 140);
buf.writeUInt32LE(0, 144); // optional L
buf.writeUInt32BE(opts.mPathSector, 148);
buf.writeUInt32BE(0, 152); // optional M
opts.rootDirRecord.copy(buf, 156);
const padFn = opts.joliet
? (s: string, n: number) => padUcs2BE(s, n)
: (s: string, n: number) => padString(s, n);
padFn("", 128).copy(buf, 190); // Volume Set Identifier
padFn("", 128).copy(buf, 318); // Publisher Identifier
padFn("", 128).copy(buf, 446); // Data Preparer Identifier
padFn("", 128).copy(buf, 574); // Application Identifier
padFn("", 37).copy(buf, 702); // Copyright File Identifier
padFn("", 37).copy(buf, 739); // Abstract File Identifier
padFn("", 37).copy(buf, 776); // Bibliographic File Identifier
opts.date.copy(buf, 813); // Creation
opts.date.copy(buf, 830); // Modification
UNUSED_VOLUME_DATE.copy(buf, 847); // Expiration
UNUSED_VOLUME_DATE.copy(buf, 864); // Effective
buf[881] = 1; // File Structure Version
return buf;
}
function buildVolumeDescriptorTerminator(): Buffer {
const buf = Buffer.alloc(SECTOR);
buf[0] = 0xff;
buf.write("CD001", 1, 5, "ascii");
buf[6] = 1;
return buf;
}
// Builds the 34-byte root directory record that lives inside the volume
// descriptor (BP 157-190 of PVD/SVD). Identical layout to a regular directory
// record but identifier is the single byte 0x00.
function buildRootDirRecordInVD(rootSector: number, rootSize: number, recDate: Buffer): Buffer {
return buildDirRecord(rootSector, rootSize, true, recDate, Buffer.from([0x00]));
}
export type IsoFile = { name: string, data: Buffer };
export function buildIso(volumeId: string, files: IsoFile[]): Buffer {
const date = new Date();
const recDate = dirRecordingDate(date);
const volDateBuf = volumeDate(date);
// Compute per-file directory record sizes for both views.
const isoEntries = files.map((f) => ({
file: f,
idBytes: isoFileIdentifier(f.name),
}));
const jolietEntries = files.map((f) => ({
file: f,
idBytes: ucs2BE(f.name),
}));
// We need root sector + size before we know file sectors — but file sectors
// depend only on the root dir size, which depends only on the file count.
// Compute the root dir buffer twice if needed (sizes are stable since they
// depend only on identifier bytes, not on file extents).
const dirRecLen = (lenFi: number) => 33 + lenFi + (lenFi % 2 === 0 ? 1 : 0);
const isoRootSize = 34 + 34 + isoEntries.reduce((acc, e) => acc + dirRecLen(e.idBytes.length), 0);
const jolietRootSize = 34 + 34 + jolietEntries.reduce((acc, e) => acc + dirRecLen(e.idBytes.length), 0);
if (isoRootSize > SECTOR || jolietRootSize > SECTOR) {
throw new Error(`Root directory exceeds ${SECTOR} bytes; multi-sector root not supported.`);
}
// Sector layout.
const sysAreaSectors = 16;
const pvdSector = sysAreaSectors;
const svdSector = pvdSector + 1;
const termSector = svdSector + 1;
const isoLPathSector = termSector + 1;
const isoMPathSector = isoLPathSector + 1;
const jolietLPathSector = isoMPathSector + 1;
const jolietMPathSector = jolietLPathSector + 1;
const isoRootSector = jolietMPathSector + 1;
const jolietRootSector = isoRootSector + 1;
let nextSector = jolietRootSector + 1;
const fileLayout = files.map((f) => {
const sector = nextSector;
const sectors = Math.max(1, Math.ceil(f.data.length / SECTOR));
nextSector += sectors;
return { file: f, sector, size: f.data.length };
});
const totalSectors = nextSector;
const pathTableSize = 10;
const isoRootDirRecordVD = buildRootDirRecordInVD(isoRootSector, SECTOR, recDate);
const jolietRootDirRecordVD = buildRootDirRecordInVD(jolietRootSector, SECTOR, recDate);
const pvd = buildVolumeDescriptor({
joliet: false,
volumeId,
volumeSpaceSize: totalSectors,
pathTableSize,
lPathSector: isoLPathSector,
mPathSector: isoMPathSector,
rootDirRecord: isoRootDirRecordVD,
date: volDateBuf,
});
const svd = buildVolumeDescriptor({
joliet: true,
volumeId,
volumeSpaceSize: totalSectors,
pathTableSize,
lPathSector: jolietLPathSector,
mPathSector: jolietMPathSector,
rootDirRecord: jolietRootDirRecordVD,
date: volDateBuf,
});
const term = buildVolumeDescriptorTerminator();
const isoLPath = padToSector(buildPathTable(isoRootSector, "LE"));
const isoMPath = padToSector(buildPathTable(isoRootSector, "BE"));
const jolietLPath = padToSector(buildPathTable(jolietRootSector, "LE"));
const jolietMPath = padToSector(buildPathTable(jolietRootSector, "BE"));
const isoRoot = buildRootDirEntries(
isoRootSector,
SECTOR,
recDate,
isoEntries.map((e, i) => ({
idBytes: e.idBytes,
sector: fileLayout[i].sector,
size: fileLayout[i].size,
})),
);
const jolietRoot = buildRootDirEntries(
jolietRootSector,
SECTOR,
recDate,
jolietEntries.map((e, i) => ({
idBytes: e.idBytes,
sector: fileLayout[i].sector,
size: fileLayout[i].size,
})),
);
// Each file must occupy the exact number of sectors the layout reserved for
// it. An empty file reserves 1 sector (via Math.max(1, …)) but
// padToSector(Buffer.alloc(0)) returns 0 bytes — that would desync every
// subsequent file's extent. Explicitly pad to the reserved size instead.
const fileBuffers = fileLayout.map((f) => {
const reservedSectors = Math.max(1, Math.ceil(f.file.data.length / SECTOR));
const reservedBytes = reservedSectors * SECTOR;
if (f.file.data.length === reservedBytes) return f.file.data;
const out = Buffer.alloc(reservedBytes);
f.file.data.copy(out, 0);
return out;
});
return Buffer.concat([
Buffer.alloc(sysAreaSectors * SECTOR),
pvd,
svd,
term,
isoLPath,
isoMPath,
jolietLPath,
jolietMPath,
isoRoot,
jolietRoot,
...fileBuffers,
]);
}
export function writeIso(path: string, volumeId: string, files: IsoFile[]): void {
const buf = buildIso(volumeId, files);
writeFileSync(path, buf);
}

View File

@ -0,0 +1,19 @@
import { defineConfig, mergeConfig } from 'vitest/config';
import sharedConfig from '../../vitest.shared';
export default mergeConfig(
sharedConfig,
defineConfig({
test: {
// Override the shared `maxWorkers: 8` — with it set, tinypool defaults
// minThreads to the host's available parallelism, producing
// "minThreads/maxThreads must not conflict" on machines with >8 cores.
poolOptions: {
threads: {
minThreads: 1,
maxThreads: 4,
},
},
},
}),
);

View File

@ -737,7 +737,7 @@ importers:
version: 1.166.6(crossws@0.4.4(srvx@0.8.16))
nitro:
specifier: ^3.0.0
version: 3.0.0(@electric-sql/pglite@0.3.2)(chokidar@4.0.3)(lru-cache@11.2.2)(mysql2@3.15.3)(rolldown@1.0.0-rc.3)(vite@7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))(xml2js@0.6.2)
version: 3.0.0(@electric-sql/pglite@0.3.2)(chokidar@4.0.3)(lru-cache@11.2.2)(mysql2@3.15.3)(vite@7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))(xml2js@0.6.2)
react:
specifier: 19.2.1
version: 19.2.1
@ -950,7 +950,7 @@ importers:
devDependencies:
mint:
specifier: ^4.2.487
version: 4.2.487(@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/node@24.9.2)(@types/react@18.3.12)(encoding@0.1.13)(react-dom@19.2.3(react@19.2.3))(tsx@4.19.3)(typescript@5.9.3)(yaml@2.6.0)
version: 4.2.487(@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/node@20.17.6)(@types/react@18.3.12)(encoding@0.1.13)(react-dom@19.2.3(react@19.2.3))(tsx@4.19.3)(typescript@5.9.3)(yaml@2.6.0)
examples/cjs-test:
dependencies:
@ -1498,10 +1498,10 @@ importers:
version: link:../../packages/stack
'@supabase/ssr':
specifier: latest
version: 0.10.0(@supabase/supabase-js@2.101.1)
version: 0.10.0(@supabase/supabase-js@2.102.1)
'@supabase/supabase-js':
specifier: latest
version: 2.101.1
version: 2.102.1
jose:
specifier: ^5.2.2
version: 5.6.3
@ -2024,6 +2024,9 @@ importers:
commander:
specifier: ^13.1.0
version: 13.1.0
extract-zip:
specifier: ^2.0.1
version: 2.0.1
jiti:
specifier: ^2.4.2
version: 2.6.1
@ -9760,23 +9763,23 @@ packages:
resolution: {integrity: sha512-SXuhqhuR5FXaYgKTXzZJeqtVA6JKb9IZWaGeEUxHHiOcFy2p51wccO72bYpXwoK4D5pzQOIYLTuAc7etxyMmwg==}
engines: {node: '>=12.16'}
'@supabase/auth-js@2.101.1':
resolution: {integrity: sha512-Kd0Wey+RkFHgyVep7adS6UOE2pN6MJ3mZ32PAXSvfw6IjUkFRC7IQpdZZjUOcUe5pXr1ejufCRgF6lsGINe4Tw==}
'@supabase/auth-js@2.102.1':
resolution: {integrity: sha512-2uH2WB0H98TOGDtaFWhxIcR42Dro/VB7VDZanz/4bVJsqioIue1m3TUqu3xciDm2W9r+1LXQvYNsYbQfWmD+uQ==}
engines: {node: '>=20.0.0'}
'@supabase/functions-js@2.101.1':
resolution: {integrity: sha512-OZWU7YtaG+NNNFZK8p/FuJ6gpq7pFyrG2fLOopP73HAIDHDGpOttPJapvO8ADu3RkqfQfkwrB354vPkSBbZ20A==}
'@supabase/functions-js@2.102.1':
resolution: {integrity: sha512-UcrcKTPnAIo+Yp9Jjq9XXwFbsmgRYY637mwka9ZjmTIWcX/xr1pote4OVvaGQycVY1KTiQgjMvpC0Q0yJhRq3w==}
engines: {node: '>=20.0.0'}
'@supabase/phoenix@0.4.0':
resolution: {integrity: sha512-RHSx8bHS02xwfHdAbX5Lpbo6PXbgyf7lTaXTlwtFDPwOIw64NnVRwFAXGojHhjtVYI+PEPNSWwkL90f4agN3bw==}
'@supabase/postgrest-js@2.101.1':
resolution: {integrity: sha512-UW1RajH5jbZoK+ldAJ1I6VZ+HWwZ2oaKjEQ6Gn+AQ67CHQVxGl8wNQoLYyumbyaExm41I+wn7arulcY1eHeZJw==}
'@supabase/postgrest-js@2.102.1':
resolution: {integrity: sha512-InLvXKAYf8BIqiv9jWOYudWB3rU8A9uMbcip5BQ5sLLNPrbO1Ekkr79OvlhZBgMNSppxVyC7wPPGzLxMcTZhlA==}
engines: {node: '>=20.0.0'}
'@supabase/realtime-js@2.101.1':
resolution: {integrity: sha512-Oa6dno0OB9I+hv5do5zsZHbFu41ViZnE9IWjmkeeF/8fPmB5fWoHGqeTYEC3/0DAgtpUoFJa4FpvzFH0SBHo1Q==}
'@supabase/realtime-js@2.102.1':
resolution: {integrity: sha512-h2fCumib/v6u7XMwSPgxnpfimjX4xCEayUHrxWLC7UurfQjUZJ0pmJDgm6yj80DnUerxuulRghwm5zXYysFG/Q==}
engines: {node: '>=20.0.0'}
'@supabase/ssr@0.10.0':
@ -9784,12 +9787,12 @@ packages:
peerDependencies:
'@supabase/supabase-js': ^2.100.1
'@supabase/storage-js@2.101.1':
resolution: {integrity: sha512-WhTaUOBgeEvnKLy95Cdlp6+D5igSF/65yC727w1olxbet5nzUvMlajKUWyzNtQu2efrz2cQ7FcdVBdQqgT9YKQ==}
'@supabase/storage-js@2.102.1':
resolution: {integrity: sha512-eCL9T4Xpe40nmKlkUJ7Zq/hk34db1xPiT0WL3Iv5MbJqHuCAe5TxhV8Rjqd6DNZrzjtfYObZtYl9jKJaHrivqw==}
engines: {node: '>=20.0.0'}
'@supabase/supabase-js@2.101.1':
resolution: {integrity: sha512-Jnhm3LfuACwjIzvk2pfUbGQn7pa7hi6MFzfSyPrRYWVCCu69RPLCFyHSBl7HSBwadbQ3UZOznnD3gPca3ePrRA==}
'@supabase/supabase-js@2.102.1':
resolution: {integrity: sha512-bChxPVeLDnYN9M2d/u4fXsvylwSQG5grAl+HN8f+ZD9a9PuVU+Ru+xGmEsk+b9Iz3rJC9ZQnQUJYQ28fApdWYA==}
engines: {node: '>=20.0.0'}
'@sveltejs/sv-utils@0.0.3':
@ -11267,6 +11270,7 @@ packages:
basic-ftp@5.2.0:
resolution: {integrity: sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==}
engines: {node: '>=10.0.0'}
deprecated: Security vulnerability fixed in 5.2.1, please upgrade
bcrypt@6.0.0:
resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==}
@ -13398,6 +13402,7 @@ packages:
freestyle-sandboxes@0.1.6:
resolution: {integrity: sha512-zfyJy+DgmheFjCAPYMklo7rpzvuxNP46rB0a9WfNBEmitYGE23nlbjyTy8qdrmVuCVCoMIDQQzzJRkyuh0Szqg==}
deprecated: This package has been deprecated. Please use freestyle instead.
fresh@0.5.2:
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
@ -22478,16 +22483,6 @@ snapshots:
optionalDependencies:
'@types/node': 20.17.6
'@inquirer/checkbox@4.3.2(@types/node@24.9.2)':
dependencies:
'@inquirer/ansi': 1.0.2
'@inquirer/core': 10.3.2(@types/node@24.9.2)
'@inquirer/figures': 1.0.15
'@inquirer/type': 3.0.10(@types/node@24.9.2)
yoctocolors-cjs: 2.1.3
optionalDependencies:
'@types/node': 24.9.2
'@inquirer/confirm@5.1.21(@types/node@20.17.6)':
dependencies:
'@inquirer/core': 10.3.2(@types/node@20.17.6)
@ -22495,13 +22490,6 @@ snapshots:
optionalDependencies:
'@types/node': 20.17.6
'@inquirer/confirm@5.1.21(@types/node@24.9.2)':
dependencies:
'@inquirer/core': 10.3.2(@types/node@24.9.2)
'@inquirer/type': 3.0.10(@types/node@24.9.2)
optionalDependencies:
'@types/node': 24.9.2
'@inquirer/core@10.3.2(@types/node@20.17.6)':
dependencies:
'@inquirer/ansi': 1.0.2
@ -22515,19 +22503,6 @@ snapshots:
optionalDependencies:
'@types/node': 20.17.6
'@inquirer/core@10.3.2(@types/node@24.9.2)':
dependencies:
'@inquirer/ansi': 1.0.2
'@inquirer/figures': 1.0.15
'@inquirer/type': 3.0.10(@types/node@24.9.2)
cli-width: 4.1.0
mute-stream: 2.0.0
signal-exit: 4.1.0
wrap-ansi: 6.2.0
yoctocolors-cjs: 2.1.3
optionalDependencies:
'@types/node': 24.9.2
'@inquirer/editor@4.2.23(@types/node@20.17.6)':
dependencies:
'@inquirer/core': 10.3.2(@types/node@20.17.6)
@ -22536,14 +22511,6 @@ snapshots:
optionalDependencies:
'@types/node': 20.17.6
'@inquirer/editor@4.2.23(@types/node@24.9.2)':
dependencies:
'@inquirer/core': 10.3.2(@types/node@24.9.2)
'@inquirer/external-editor': 1.0.3(@types/node@24.9.2)
'@inquirer/type': 3.0.10(@types/node@24.9.2)
optionalDependencies:
'@types/node': 24.9.2
'@inquirer/expand@4.0.23(@types/node@20.17.6)':
dependencies:
'@inquirer/core': 10.3.2(@types/node@20.17.6)
@ -22552,14 +22519,6 @@ snapshots:
optionalDependencies:
'@types/node': 20.17.6
'@inquirer/expand@4.0.23(@types/node@24.9.2)':
dependencies:
'@inquirer/core': 10.3.2(@types/node@24.9.2)
'@inquirer/type': 3.0.10(@types/node@24.9.2)
yoctocolors-cjs: 2.1.3
optionalDependencies:
'@types/node': 24.9.2
'@inquirer/external-editor@1.0.3(@types/node@20.17.6)':
dependencies:
chardet: 2.1.1
@ -22567,13 +22526,6 @@ snapshots:
optionalDependencies:
'@types/node': 20.17.6
'@inquirer/external-editor@1.0.3(@types/node@24.9.2)':
dependencies:
chardet: 2.1.1
iconv-lite: 0.7.0
optionalDependencies:
'@types/node': 24.9.2
'@inquirer/figures@1.0.15': {}
'@inquirer/figures@1.0.3': {}
@ -22585,13 +22537,6 @@ snapshots:
optionalDependencies:
'@types/node': 20.17.6
'@inquirer/input@4.3.1(@types/node@24.9.2)':
dependencies:
'@inquirer/core': 10.3.2(@types/node@24.9.2)
'@inquirer/type': 3.0.10(@types/node@24.9.2)
optionalDependencies:
'@types/node': 24.9.2
'@inquirer/number@3.0.23(@types/node@20.17.6)':
dependencies:
'@inquirer/core': 10.3.2(@types/node@20.17.6)
@ -22599,13 +22544,6 @@ snapshots:
optionalDependencies:
'@types/node': 20.17.6
'@inquirer/number@3.0.23(@types/node@24.9.2)':
dependencies:
'@inquirer/core': 10.3.2(@types/node@24.9.2)
'@inquirer/type': 3.0.10(@types/node@24.9.2)
optionalDependencies:
'@types/node': 24.9.2
'@inquirer/password@4.0.23(@types/node@20.17.6)':
dependencies:
'@inquirer/ansi': 1.0.2
@ -22614,14 +22552,6 @@ snapshots:
optionalDependencies:
'@types/node': 20.17.6
'@inquirer/password@4.0.23(@types/node@24.9.2)':
dependencies:
'@inquirer/ansi': 1.0.2
'@inquirer/core': 10.3.2(@types/node@24.9.2)
'@inquirer/type': 3.0.10(@types/node@24.9.2)
optionalDependencies:
'@types/node': 24.9.2
'@inquirer/prompts@7.10.1(@types/node@20.17.6)':
dependencies:
'@inquirer/checkbox': 4.3.2(@types/node@20.17.6)
@ -22637,35 +22567,20 @@ snapshots:
optionalDependencies:
'@types/node': 20.17.6
'@inquirer/prompts@7.10.1(@types/node@24.9.2)':
'@inquirer/prompts@7.9.0(@types/node@20.17.6)':
dependencies:
'@inquirer/checkbox': 4.3.2(@types/node@24.9.2)
'@inquirer/confirm': 5.1.21(@types/node@24.9.2)
'@inquirer/editor': 4.2.23(@types/node@24.9.2)
'@inquirer/expand': 4.0.23(@types/node@24.9.2)
'@inquirer/input': 4.3.1(@types/node@24.9.2)
'@inquirer/number': 3.0.23(@types/node@24.9.2)
'@inquirer/password': 4.0.23(@types/node@24.9.2)
'@inquirer/rawlist': 4.1.11(@types/node@24.9.2)
'@inquirer/search': 3.2.2(@types/node@24.9.2)
'@inquirer/select': 4.4.2(@types/node@24.9.2)
'@inquirer/checkbox': 4.3.2(@types/node@20.17.6)
'@inquirer/confirm': 5.1.21(@types/node@20.17.6)
'@inquirer/editor': 4.2.23(@types/node@20.17.6)
'@inquirer/expand': 4.0.23(@types/node@20.17.6)
'@inquirer/input': 4.3.1(@types/node@20.17.6)
'@inquirer/number': 3.0.23(@types/node@20.17.6)
'@inquirer/password': 4.0.23(@types/node@20.17.6)
'@inquirer/rawlist': 4.1.11(@types/node@20.17.6)
'@inquirer/search': 3.2.2(@types/node@20.17.6)
'@inquirer/select': 4.4.2(@types/node@20.17.6)
optionalDependencies:
'@types/node': 24.9.2
'@inquirer/prompts@7.9.0(@types/node@24.9.2)':
dependencies:
'@inquirer/checkbox': 4.3.2(@types/node@24.9.2)
'@inquirer/confirm': 5.1.21(@types/node@24.9.2)
'@inquirer/editor': 4.2.23(@types/node@24.9.2)
'@inquirer/expand': 4.0.23(@types/node@24.9.2)
'@inquirer/input': 4.3.1(@types/node@24.9.2)
'@inquirer/number': 3.0.23(@types/node@24.9.2)
'@inquirer/password': 4.0.23(@types/node@24.9.2)
'@inquirer/rawlist': 4.1.11(@types/node@24.9.2)
'@inquirer/search': 3.2.2(@types/node@24.9.2)
'@inquirer/select': 4.4.2(@types/node@24.9.2)
optionalDependencies:
'@types/node': 24.9.2
'@types/node': 20.17.6
'@inquirer/rawlist@4.1.11(@types/node@20.17.6)':
dependencies:
@ -22675,14 +22590,6 @@ snapshots:
optionalDependencies:
'@types/node': 20.17.6
'@inquirer/rawlist@4.1.11(@types/node@24.9.2)':
dependencies:
'@inquirer/core': 10.3.2(@types/node@24.9.2)
'@inquirer/type': 3.0.10(@types/node@24.9.2)
yoctocolors-cjs: 2.1.3
optionalDependencies:
'@types/node': 24.9.2
'@inquirer/search@3.2.2(@types/node@20.17.6)':
dependencies:
'@inquirer/core': 10.3.2(@types/node@20.17.6)
@ -22692,15 +22599,6 @@ snapshots:
optionalDependencies:
'@types/node': 20.17.6
'@inquirer/search@3.2.2(@types/node@24.9.2)':
dependencies:
'@inquirer/core': 10.3.2(@types/node@24.9.2)
'@inquirer/figures': 1.0.15
'@inquirer/type': 3.0.10(@types/node@24.9.2)
yoctocolors-cjs: 2.1.3
optionalDependencies:
'@types/node': 24.9.2
'@inquirer/select@4.4.2(@types/node@20.17.6)':
dependencies:
'@inquirer/ansi': 1.0.2
@ -22711,24 +22609,10 @@ snapshots:
optionalDependencies:
'@types/node': 20.17.6
'@inquirer/select@4.4.2(@types/node@24.9.2)':
dependencies:
'@inquirer/ansi': 1.0.2
'@inquirer/core': 10.3.2(@types/node@24.9.2)
'@inquirer/figures': 1.0.15
'@inquirer/type': 3.0.10(@types/node@24.9.2)
yoctocolors-cjs: 2.1.3
optionalDependencies:
'@types/node': 24.9.2
'@inquirer/type@3.0.10(@types/node@20.17.6)':
optionalDependencies:
'@types/node': 20.17.6
'@inquirer/type@3.0.10(@types/node@24.9.2)':
optionalDependencies:
'@types/node': 24.9.2
'@isaacs/cliui@8.0.2':
dependencies:
string-width: 5.1.2
@ -22866,9 +22750,9 @@ snapshots:
dependencies:
langium: 3.3.1
'@mintlify/cli@4.0.1090(@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/node@24.9.2)(@types/react@18.3.12)(encoding@0.1.13)(react-dom@19.2.3(react@19.2.3))(tsx@4.19.3)(typescript@5.9.3)(yaml@2.6.0)':
'@mintlify/cli@4.0.1090(@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/node@20.17.6)(@types/react@18.3.12)(encoding@0.1.13)(react-dom@19.2.3(react@19.2.3))(tsx@4.19.3)(typescript@5.9.3)(yaml@2.6.0)':
dependencies:
'@inquirer/prompts': 7.9.0(@types/node@24.9.2)
'@inquirer/prompts': 7.9.0(@types/node@20.17.6)
'@mintlify/common': 1.0.835(@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@18.3.12)(encoding@0.1.13)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(tsx@4.19.3)(typescript@5.9.3)(yaml@2.6.0)
'@mintlify/link-rot': 3.0.1010(@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@18.3.12)(encoding@0.1.13)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(tsx@4.19.3)(typescript@5.9.3)(yaml@2.6.0)
'@mintlify/prebuild': 1.0.977(@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@18.3.12)(encoding@0.1.13)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(tsx@4.19.3)(typescript@5.9.3)(yaml@2.6.0)
@ -22881,7 +22765,7 @@ snapshots:
front-matter: 4.0.2
fs-extra: 11.2.0
ink: 6.3.0(@types/react@18.3.12)(react@19.2.3)
inquirer: 12.3.0(@types/node@24.9.2)
inquirer: 12.3.0(@types/node@20.17.6)
js-yaml: 4.1.0
mdast-util-mdx-jsx: 3.2.0
open: 8.4.2
@ -29249,21 +29133,21 @@ snapshots:
'@stripe/stripe-js@7.7.0': {}
'@supabase/auth-js@2.101.1':
'@supabase/auth-js@2.102.1':
dependencies:
tslib: 2.8.1
'@supabase/functions-js@2.101.1':
'@supabase/functions-js@2.102.1':
dependencies:
tslib: 2.8.1
'@supabase/phoenix@0.4.0': {}
'@supabase/postgrest-js@2.101.1':
'@supabase/postgrest-js@2.102.1':
dependencies:
tslib: 2.8.1
'@supabase/realtime-js@2.101.1':
'@supabase/realtime-js@2.102.1':
dependencies:
'@supabase/phoenix': 0.4.0
'@types/ws': 8.18.1
@ -29273,23 +29157,23 @@ snapshots:
- bufferutil
- utf-8-validate
'@supabase/ssr@0.10.0(@supabase/supabase-js@2.101.1)':
'@supabase/ssr@0.10.0(@supabase/supabase-js@2.102.1)':
dependencies:
'@supabase/supabase-js': 2.101.1
'@supabase/supabase-js': 2.102.1
cookie: 1.0.2
'@supabase/storage-js@2.101.1':
'@supabase/storage-js@2.102.1':
dependencies:
iceberg-js: 0.8.1
tslib: 2.8.1
'@supabase/supabase-js@2.101.1':
'@supabase/supabase-js@2.102.1':
dependencies:
'@supabase/auth-js': 2.101.1
'@supabase/functions-js': 2.101.1
'@supabase/postgrest-js': 2.101.1
'@supabase/realtime-js': 2.101.1
'@supabase/storage-js': 2.101.1
'@supabase/auth-js': 2.102.1
'@supabase/functions-js': 2.102.1
'@supabase/postgrest-js': 2.102.1
'@supabase/realtime-js': 2.102.1
'@supabase/storage-js': 2.102.1
transitivePeerDependencies:
- bufferutil
- utf-8-validate
@ -30017,6 +29901,7 @@ snapshots:
'@types/node@24.9.2':
dependencies:
undici-types: 7.16.0
optional: true
'@types/nodemailer@6.4.15':
dependencies:
@ -33134,7 +33019,7 @@ snapshots:
debug: 4.4.3
enhanced-resolve: 5.17.1
eslint: 8.57.1
eslint-module-utils: 2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1)
eslint-module-utils: 2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.56.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
fast-glob: 3.3.3
get-tsconfig: 4.8.1
is-bun-module: 1.2.1
@ -33177,7 +33062,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1):
eslint-module-utils@2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.56.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
dependencies:
debug: 3.2.7
optionalDependencies:
@ -33255,7 +33140,7 @@ snapshots:
doctrine: 2.1.0
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1)
eslint-module-utils: 2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.56.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
hasown: 2.0.2
is-core-module: 2.15.1
is-glob: 4.0.3
@ -35159,12 +35044,12 @@ snapshots:
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
inquirer@12.3.0(@types/node@24.9.2):
inquirer@12.3.0(@types/node@20.17.6):
dependencies:
'@inquirer/core': 10.3.2(@types/node@24.9.2)
'@inquirer/prompts': 7.10.1(@types/node@24.9.2)
'@inquirer/type': 3.0.10(@types/node@24.9.2)
'@types/node': 24.9.2
'@inquirer/core': 10.3.2(@types/node@20.17.6)
'@inquirer/prompts': 7.10.1(@types/node@20.17.6)
'@inquirer/type': 3.0.10(@types/node@20.17.6)
'@types/node': 20.17.6
ansi-escapes: 4.3.2
mute-stream: 2.0.0
run-async: 3.0.0
@ -36641,9 +36526,9 @@ snapshots:
dependencies:
minipass: 7.1.2
mint@4.2.487(@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/node@24.9.2)(@types/react@18.3.12)(encoding@0.1.13)(react-dom@19.2.3(react@19.2.3))(tsx@4.19.3)(typescript@5.9.3)(yaml@2.6.0):
mint@4.2.487(@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/node@20.17.6)(@types/react@18.3.12)(encoding@0.1.13)(react-dom@19.2.3(react@19.2.3))(tsx@4.19.3)(typescript@5.9.3)(yaml@2.6.0):
dependencies:
'@mintlify/cli': 4.0.1090(@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/node@24.9.2)(@types/react@18.3.12)(encoding@0.1.13)(react-dom@19.2.3(react@19.2.3))(tsx@4.19.3)(typescript@5.9.3)(yaml@2.6.0)
'@mintlify/cli': 4.0.1090(@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/node@20.17.6)(@types/react@18.3.12)(encoding@0.1.13)(react-dom@19.2.3(react@19.2.3))(tsx@4.19.3)(typescript@5.9.3)(yaml@2.6.0)
transitivePeerDependencies:
- '@radix-ui/react-popover'
- '@types/node'
@ -37086,7 +36971,7 @@ snapshots:
jsonpath-plus: 10.4.0
lodash.topath: 4.5.2
nitro@3.0.0(@electric-sql/pglite@0.3.2)(chokidar@4.0.3)(lru-cache@11.2.2)(mysql2@3.15.3)(rolldown@1.0.0-rc.3)(vite@7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))(xml2js@0.6.2):
nitro@3.0.0(@electric-sql/pglite@0.3.2)(chokidar@4.0.3)(lru-cache@11.2.2)(mysql2@3.15.3)(vite@7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))(xml2js@0.6.2):
dependencies:
consola: 3.4.2
cookie-es: 2.0.0
@ -37106,7 +36991,6 @@ snapshots:
unenv: 2.0.0-rc.21
unstorage: 2.0.0-alpha.3(chokidar@4.0.3)(db0@0.3.4(@electric-sql/pglite@0.3.2)(mysql2@3.15.3))(lru-cache@11.2.2)(ofetch@1.5.1)
optionalDependencies:
rolldown: 1.0.0-rc.3
vite: 7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0)
xml2js: 0.6.2
transitivePeerDependencies:
@ -40784,7 +40668,8 @@ snapshots:
undici-types@6.21.0: {}
undici-types@7.16.0: {}
undici-types@7.16.0:
optional: true
undici@6.19.8: {}