mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
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:
parent
a65022b8f7
commit
30dbdffc4a
129
.github/workflows/qemu-emulator-build.yaml
vendored
129
.github/workflows/qemu-emulator-build.yaml
vendored
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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": {
|
||||
|
||||
166
packages/stack-cli/src/commands/emulator.test.ts
Normal file
166
packages/stack-cli/src/commands/emulator.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
|
||||
259
packages/stack-cli/src/lib/iso.test.ts
Normal file
259
packages/stack-cli/src/lib/iso.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
403
packages/stack-cli/src/lib/iso.ts
Normal file
403
packages/stack-cli/src/lib/iso.ts
Normal 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);
|
||||
}
|
||||
19
packages/stack-cli/vitest.config.ts
Normal file
19
packages/stack-cli/vitest.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
241
pnpm-lock.yaml
241
pnpm-lock.yaml
@ -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: {}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user