diff --git a/.github/workflows/qemu-emulator-build.yaml b/.github/workflows/qemu-emulator-build.yaml index 5df149746..4bb738124 100644 --- a/.github/workflows/qemu-emulator-build.yaml +++ b/.github/workflows/qemu-emulator-build.yaml @@ -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 </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 } diff --git a/packages/stack-cli/package.json b/packages/stack-cli/package.json index 3f574e241..57a8bfaae 100644 --- a/packages/stack-cli/package.json +++ b/packages/stack-cli/package.json @@ -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": { diff --git a/packages/stack-cli/src/commands/emulator.test.ts b/packages/stack-cli/src/commands/emulator.test.ts new file mode 100644 index 000000000..9cbe9caa1 --- /dev/null +++ b/packages/stack-cli/src/commands/emulator.test.ts @@ -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(); + } + }); +}); diff --git a/packages/stack-cli/src/commands/emulator.ts b/packages/stack-cli/src/commands/emulator.ts index 7753cc86a..5967a1103 100644 --- a/packages/stack-cli/src/commands/emulator.ts +++ b/packages/stack-cli/src/commands/emulator.ts @@ -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(path: string): Promise { + const token = githubToken(); + const headers: Record = { + 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); +} + 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): NodeJS.ProcessEnv { return { ...process.env, @@ -113,6 +166,33 @@ function emulatorSpawnEnv(extra?: Record): 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): Promise { const scriptsDir = emulatorScriptsDir(); mkdirSync(emulatorRunDir(), { recursive: true }); @@ -149,17 +229,21 @@ async function startEmulator(arch: "arm64" | "amd64"): Promise { 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(`/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 { const dest = join(imageDir, asset); const tmpDest = `${dest}.download`; console.log(`Pulling ${asset} from release ${tag}...`); + const headers: Record = { 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 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 { + 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(`/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 ", "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(`/repos/${repo}/pulls/${opts.pr}`); + const headRefName = pr.head.ref; + const runs = await ghApi( + `/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 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 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 ", "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(`/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); }); diff --git a/packages/stack-cli/src/lib/iso.test.ts b/packages/stack-cli/src/lib/iso.test.ts new file mode 100644 index 000000000..91f069e7a --- /dev/null +++ b/packages/stack-cli/src/lib/iso.test.ts @@ -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); + } + }); +}); diff --git a/packages/stack-cli/src/lib/iso.ts b/packages/stack-cli/src/lib/iso.ts new file mode 100644 index 000000000..b226af0bc --- /dev/null +++ b/packages/stack-cli/src/lib/iso.ts @@ -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); +} diff --git a/packages/stack-cli/vitest.config.ts b/packages/stack-cli/vitest.config.ts new file mode 100644 index 000000000..0caeccbe4 --- /dev/null +++ b/packages/stack-cli/vitest.config.ts @@ -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, + }, + }, + }, + }), +); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dd890a4bd..5c2364de0 100644 --- a/pnpm-lock.yaml +++ b/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: {}