diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md new file mode 100644 index 0000000..c87958b --- /dev/null +++ b/.claude/rules/testing.md @@ -0,0 +1,32 @@ +--- +paths: + - "test/**" +--- + +# Test buckets — short vs long + +Integration tests in `test/` split by image-build cost. + +**Short tests** assume a test image already exists (`DEVCELL_TEST_*_IMAGE`, or a local `devcell-user:*-thin`) and skip cleanly when it doesn't. They must NEVER call `buildLocalImage()`. The inner loop and per-PR CI run only these. + +**Long tests** call `buildLocalImage(...)` to provision their own image (~5–10 min per stack) and MUST gate at the top: + +```go +if testing.Short() { t.Skip("long: builds its own image") } +``` + +Nightly and release CI run these. + +## Run modes + +| Command | What runs | When | +|---|---|---| +| `go test -short ./test` | Short only | Inner loop, pre-commit, PR CI | +| `DEVCELL_TEST_THIN_IMAGE= go test ./test` | All, against a pinned tag, no build | PR CI after `docker pull` | +| `DEVCELL_TEST_BUILD_THIN=1 go test ./test` | `TestMain` builds ultimate-thin once, shared by all | Nightly, release | + +## Rules + +- Only `TestMain` and long tests may call `buildLocalImage`. +- Long tests start with the `testing.Short()` skip — no exceptions. +- Skip messages must name both the missing artifact and the command that supplies it, e.g. ``"set DEVCELL_TEST_DEV_IMAGE or run `cell build --stack dev --thin`"``. diff --git a/.claude/rules/vocabulary.md b/.claude/rules/vocabulary.md new file mode 100644 index 0000000..d2cf38b --- /dev/null +++ b/.claude/rules/vocabulary.md @@ -0,0 +1,16 @@ +# Vocabulary + +The runtime model has five entities. Use these words consistently in code, comments, error messages, log output, docs, and prose — a rename in one layer without the others is what made the old naming ambiguous. + +- **cell** — a named, persistent identity, and a boundary: one shared `$HOME` (`~/.devcell//`), one network, one secrets scope. May host many projects. May be running or stopped. Defaults to `main`; override with `DEVCELL_CELL_NAME`, or inherit from `TMUX_SESSION_NAME`. +- **project** — a host directory with code, mounted into a container. +- **container** — the running docker instance for one (cell, project) pair. Ephemeral. +- **stack** — the image variant a container is built from. +- **module** — a toggleable Nix capability composed into a stack. + +## Retired words + +Do not use these as devcell-layer terms: + +- ~~**session**~~ — tmux owns this word. +- ~~**workspace**~~ — survives only in `internal/serve/` for the MS-TSWP RDP protocol, where it is the protocol's own term. `WorkspaceResource` is still pending a rename to `Cell`. diff --git a/.dockerignore b/.dockerignore index 10a5843..5aa998a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -18,3 +18,12 @@ web/.context docs/ test/js/ test/results/ +.workspaces/ +.worktrees/ +.gocache/ +.gomodcache/ +.venv/ +.vagrant/ +.playwright-mcp/ +Vagrantfile.local +.scratch/CONTINUE.md diff --git a/.github/workflows/build.dev.yml b/.github/workflows/build.dev.yml index 079b6f6..d26bce2 100644 --- a/.github/workflows/build.dev.yml +++ b/.github/workflows/build.dev.yml @@ -116,7 +116,7 @@ jobs: - name: Hydrate /nix volume from GHCR cache if: inputs.skip_nix_cache != true run: | - HASH=${{ hashFiles('nixhome/**') }} + HASH=${{ github.sha }} ./bin/cell nix-store pull \ --image "${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:nix-cache-${{ matrix.arch }}-${HASH}" \ --fallback "${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:nix-cache-${{ matrix.arch }}-latest" \ @@ -125,17 +125,16 @@ jobs: # Build both stacks sequentially in the same job so the nix-store # volume accumulates derivations from both — single tar dump at job - # end carries everything needed by docker-test. `cell build --thin` + # end carries everything needed by docker-test. `cell build` # reuses store paths across the two invocations (nix is # content-addressed), so ultimate after base is incremental. - name: Build thin image (base stack) env: DEVCELL_NIX_VOLUME: devcell-nix-store-${{ matrix.arch }} - DEVCELL_NIXHOME_PATH: ${{ github.workspace }}/nixhome DEVCELL_NIX_MAX_JOBS: "4" run: | BASE_TAG="${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:v0.0.0-${{ matrix.arch }}-base" - ./bin/cell build --thin --stack base --image "$BASE_TAG" --debug + ./bin/cell build --stack base --image "$BASE_TAG" --debug echo "BASE_TAG=$BASE_TAG" >> "$GITHUB_ENV" # Interim publish: lock in the base-stack derivations as a usable @@ -149,7 +148,7 @@ jobs: timeout-minutes: 45 env: ARCH: ${{ matrix.arch }} - HASH: ${{ hashFiles('nixhome/**') }} + HASH: ${{ github.sha }} STAGE: post-base DEVCELL_NIX_PUSH_DEBUG: "1" run: task nix-cache:publish @@ -157,11 +156,10 @@ jobs: - name: Build thin image (ultimate stack) env: DEVCELL_NIX_VOLUME: devcell-nix-store-${{ matrix.arch }} - DEVCELL_NIXHOME_PATH: ${{ github.workspace }}/nixhome DEVCELL_NIX_MAX_JOBS: "4" run: | ULT_TAG="${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:v0.0.0-${{ matrix.arch }}-ultimate" - ./bin/cell build --thin --stack ultimate --image "$ULT_TAG" --debug + ./bin/cell build --stack ultimate --image "$ULT_TAG" --debug echo "ULT_TAG=$ULT_TAG" >> "$GITHUB_ENV" - name: Push to GHCR (both stacks) @@ -177,7 +175,7 @@ jobs: timeout-minutes: 120 env: ARCH: ${{ matrix.arch }} - HASH: ${{ hashFiles('nixhome/**') }} + HASH: ${{ github.sha }} STAGE: post-ultimate DEVCELL_NIX_PUSH_DEBUG: "1" run: task nix-cache:publish @@ -228,7 +226,7 @@ jobs: - name: Hydrate /nix volume from GHCR cache run: | - HASH=${{ hashFiles('nixhome/**') }} + HASH=${{ github.sha }} ./bin/cell nix-store pull \ --image "${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:nix-cache-${{ matrix.arch }}-${HASH}" \ --fallback "${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:nix-cache-${{ matrix.arch }}-latest" \ @@ -384,8 +382,6 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Run cell claude --version (full pipeline) - env: - DEVCELL_NIXHOME_PATH: ${{ github.workspace }}/nixhome run: | # Simulate a new user in a fresh project dir mkdir -p /tmp/e2e-project && cd /tmp/e2e-project diff --git a/.github/workflows/build.release.yml b/.github/workflows/build.release.yml index 1cb0887..2ea4b3b 100644 --- a/.github/workflows/build.release.yml +++ b/.github/workflows/build.release.yml @@ -87,12 +87,12 @@ jobs: # Hydrate /nix volume from the GHCR cache image populated by # build.dev.yml (same `nix-cache-${arch}-*` tag scheme). Release - # builds reuse the same nixhome closure as dev builds for any - # given `hashFiles('nixhome/**')`, so the cache is interchangeable. + # builds reuse the same nixhome closure as dev builds, so the + # cache is interchangeable. - name: Hydrate /nix volume from GHCR cache if: inputs.skip_nix_cache != true run: | - HASH=${{ hashFiles('nixhome/**') }} + HASH=${{ github.sha }} ./bin/cell nix-store pull \ --image "${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:nix-cache-${{ matrix.arch }}-${HASH}" \ --fallback "${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:nix-cache-${{ matrix.arch }}-latest" \ @@ -103,10 +103,9 @@ jobs: id: build env: DEVCELL_NIX_VOLUME: devcell-nix-store-${{ matrix.arch }} - DEVCELL_NIXHOME_PATH: ${{ github.workspace }}/nixhome run: | TAG="${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:${{ env.VERSION }}-${{ matrix.arch }}" - ./bin/cell build --thin --image "$TAG" --debug + ./bin/cell build --image "$TAG" --debug echo "tag=$TAG" >> "$GITHUB_OUTPUT" - name: Push to GHCR @@ -120,7 +119,7 @@ jobs: timeout-minutes: 45 env: ARCH: ${{ matrix.arch }} - HASH: ${{ hashFiles('nixhome/**') }} + HASH: ${{ github.sha }} STAGE: release run: task nix-cache:publish @@ -236,8 +235,6 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Run cell claude --version (full pipeline) - env: - DEVCELL_NIXHOME_PATH: ${{ github.workspace }}/nixhome run: | # Simulate a new user in a fresh project dir mkdir -p /tmp/e2e-project && cd /tmp/e2e-project diff --git a/.gitignore b/.gitignore index 4ae45a4..5eefddb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ .workspaces .worktrees -nixhome/user.nix .venv .env.devcell *.egg-info @@ -18,20 +17,46 @@ web/src/content/cell/ bin/ .playwright-mcp test/testdata/**/nixhome +test/testdata/windows-arm64.* +# Versioned ssh-able base images (13+ GB each) the dev-env test builds on. +test/testdata/windows-sshable-*.qcow +# WSL-ready checkpoints (drivers + share + WSL engine baked in). +test/testdata/windows-wsl-*.qcow +# Nix-provisioned checkpoints (WSL checkpoint + home-manager activated). +test/testdata/windows-nix-*.qcow +# UTM bundle: the debug VM's full disk + EFI vars + config (~24 GB). +test/testdata/Windows.utm/ +# Kernel-bootable firmware shared with the host for task debug:windows:start. +test/testdata/QEMU_EFI.kernel.fd +# Stable $HOME for the CLI-driven Windows build test: holds the cached ISOs and +# the installed template, so a rerun is a boot rather than a 2h47m reinstall. +test/testdata/cellhome/ test/results +test/results_archive .devcell .devcell.toml .gocache .gomodcache docs/ .nixhome-tmp/ +.rendered-ps1/ # macOS .DS_Store -# Personal dev config / scratch (not project state) -.claude/ +# Personal dev config / scratch (not project state). +# `.claude/*` (not `.claude/`) so the shared subdirs below can be re-included — +# git cannot un-ignore a path inside an excluded directory. +.claude/* +!.claude/rules/ .context/ +.scratch/ .envrc .idea/ -CLAUDE.md \ No newline at end of file +CLAUDE.local.md +# task debug:windows runtime state +.tmp/ + +# local multi-repo workspace +go.work +go.work.sum diff --git a/.mise.toml b/.mise.toml index 9093936..273a1a8 100644 --- a/.mise.toml +++ b/.mise.toml @@ -5,8 +5,10 @@ # git hooks land automatically. # # Nix-native contributors get the same behavior from the shellHook -# in flake.nix; `.tool-versions` handles Go pinning for asdf users. +# in flake.nix. [tools] +# Keep in sync with the `go` directive in go.mod. +go = "1.26" pre-commit = "4.0.1" [hooks] diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a9f610c..8f7252c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,18 +14,13 @@ repos: - repo: local hooks: - # Keep flake.nix `vendorHash` in sync with go.mod / go.sum. - # Fires only when the Go module surface changes; delegates to - # `task nix:update-vendor-hash`, which builds .#cell with a fake - # hash, extracts the real hash from the mismatch error, and - # rewrites flake.nix in place. If the hash actually changed, - # pre-commit aborts the commit so the user reviews the diff and - # re-stages flake.nix — that guarantees any tag pointing at the - # commit has the correct hash baked in. + # Warn (non-blocking) when go.mod/go.sum change and vendorHash + # may be stale. Run `task nix:sync` to fix before pushing. - id: nix-vendor-hash - name: Sync flake.nix vendorHash with go.mod/go.sum - entry: task nix:update-vendor-hash + name: Check flake.nix vendorHash matches go.mod/go.sum + entry: task nix:check-vendor-hash language: system files: ^(go\.mod|go\.sum)$ pass_filenames: false require_serial: true + verbose: true diff --git a/.tool-versions b/.tool-versions deleted file mode 100644 index 668a388..0000000 --- a/.tool-versions +++ /dev/null @@ -1 +0,0 @@ -golang 1.24.1 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a03ce53 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,59 @@ +# devcell — Project Instructions + +## Terminology + +cell, project, container, stack, module — defined in `.claude/rules/vocabulary.md`, which loads every session. Do NOT use *session* or *workspace* as devcell-layer terms. + +## Git Policy + +- Do NOT create commits automatically. Always ask the user to commit. +- Do NOT push to remote unless the user explicitly asks. + +## TDD + +Every behavioral change to `cmd/`, `internal/`, or `nixhome/modules/llm/*.nix` lands with a test that was failing before the change. Write the failing test first, implement the minimum to pass, then refactor. + +Applies to: a new flag, env var, TOML key, or CLI subcommand (`cmd/*_test.go`); a new `internal/*` function with observable behavior (same package); a new MCP server, system-prompt source, or runner argv field (`internal/runner/*_test.go`). + +No new test required for: pure refactors, docs, dependency bumps, nix module additions (nixhome now lives in `devcell-sh/community-home`), or entrypoint shell fragments (covered by `test/`). + +## Nix environment layout + +- Nix is owned by the `devcell` user, home at `/opt/devcell` — stable, never remounted. +- The session user is `$HOST_USER`, home at `/home/$HOST_USER`, created at startup by the entrypoint. +- Nix profile path is `/opt/devcell/.local/state/nix/profiles/profile` — home-manager's native path, updated on every `home-manager switch`. +- The entrypoint copies `/opt/devcell/` dotfiles to `/home/$HOST_USER/` with `sed "s|/opt/devcell|$HOME|g"` to redirect write paths. +- Use `ln -sfT` (not `ln -sf`) when replacing a symlink-to-directory; `-T` prevents creating the link *inside* the target. +- `ENV USER=devcell` is required in the nix stage — `nix.sh` checks `[ -n "$USER" ]` and silently no-ops if empty. +- `$HOME/.config/nix/nix.conf` must carry `experimental-features = nix-command flakes` at BUILD time. + +## Architecture detection in Dockerfiles + +Do NOT use `ARG TARGETARCH=amd64` — the docker driver doesn't set it for host-platform builds. Use `ARCH=$(uname -m)` in `RUN` steps. + +## Nix module edits + +Nix modules (nixhome) now live in the standalone `devcell-sh/community-home` repo. Edits to `.nix` files in this repo are limited to `flake.nix` (the Go package build). + +Escaping inside `writeShellScriptBin` (`''...''` strings) is the usual culprit: + +- `${VAR}` must be `''${VAR}` (otherwise Nix interpolates it) +- `''` (empty shell string) must be `''''` +- `$VAR` without braces passes through as-is + +## Go module and generated-docs hygiene + +The CI **Deploy Site** workflow compiles all `cmd/*.go` together with `cmd/gendoc.go`. Three things must hold or `go build` exits 1: + +1. Run `go mod tidy && go build ./...` after any dependency or import change, and commit the result. A green local build is NOT enough — CI starts from a clean module cache, so a missing `go.sum` entry only surfaces there. +2. Build-time-only tooling deps must stay anchored in `cmd/tools.go` (`//go:build tools`). `cmd/gendoc.go` is `//go:build ignore`, so `go mod tidy` can't see its `cobra/doc` import and would prune the transitive deps. Anchor any other build-ignored tool's deps there too. +3. `docs/` is gitignored (swagger output) but `cmd/serve.go` imports it, so any workflow compiling `serve.go` must run `task swagger:generate` first. + +After changing `go.mod`/`go.sum`, run `task nix:sync` — it resolves `flake.nix`'s `vendorHash` and stages it. The pre-commit hook only verifies. + +## Disk space + +If a build fails with "no space left on device": + +1. Prune build cache first (safe): `docker buildx prune -af` +2. If still insufficient, **ask the user to stop old containers — never stop them yourself.** Each pins a ~13 GB untagged image with almost no layer sharing, so 2–3 usually frees ~20 GB. Then `docker image prune`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/CONTINUE.md b/CONTINUE.md new file mode 100644 index 0000000..47ebd4e --- /dev/null +++ b/CONTINUE.md @@ -0,0 +1,77 @@ +# CONTINUE.md — session wakeup document (written 2026-08-06) + +## Mission context +Goal: get WSL2 + NixOS-WSL running inside the Windows 11 ARM64 debug VM, booted +with plain `qemu-system-aarch64` on the user's Mac (M4 Pro, macOS 26.5.2) at +native speed. VM disk = UTM bundle `test/testdata/Windows.utm/Data/D76FB0BC-D7CC-4481-A6B6-492BEB4D834B.qcow2`. + +## FINAL VERDICT (fully diagnosed, sealed) +**WSL2 under hvf nested virt is impossible today.** Chain of evidence: +- QEMU 11.0.93 (= 11.1-rc) installed on the Mac; nested virt (EL2) enabled via + `-M virt,virtualization=on,gic-version=3`. +- EDK2 hangs at "Start boot option" under nested — known upstream issue in the + hvf nested series; workaround `-boot menu=on,splash-time=0` (APPLIED in + Taskfile, works — Windows boots to desktop under nested config). +- Alpine control VM (`task debug:alpine:start`): kernel prints + `CPU: All CPU(s) started at EL2` and `kvm: Hyp nVHE mode initialized + successfully` → nested EL2 WORKS for Linux, but in **nVHE-only** form. +- Windows event log (every boot): Event 43 + `Hypervisor launch failed; EL2 not present.` → Windows' hypervisor requires + **VHE**; Apple's nested API (macOS 26) is nVHE-only, no FEAT_NV1/VNCR. + `wsl --install --from-file` always fails `HCS_E_HYPERV_NOT_INSTALLED`. +- Not fixable in QEMU: VHE is pervasive untrappable hardware behavior; hvf has + no NV-style exits. Blocked on Apple exposing VHE-in-nested (Feedback + Assistant) or QEMU hybrid hvf+tcg (multi-year). +- Practical paths: `ACCEL=tcg` (whole WSL2 chain works, slow 10-25 min boot); + or run NixOS directly under hvf (nested Linux works great); or wait for Apple. +- `HypervisorPresent:True` from WMI is a red herring; event log is authoritative. +- Tractable upstream patches if ever desired: tpm-tis HV_BAD_ARGUMENT under + nested; EDK2 hang root cause (missing EL2 phys timer); clearer VHE error. + +## Current state of the VM tooling (all works, verified) +`task debug:windows:start` — boots UTM disk under hvf, 4 gates +(VNC→IP→SSH→qga), ~10s to green, IP 192.168.2.2, SMB share via dockurr/samba, +VNC 127.0.0.1:5907 pass `vnc`. Graceful stop verified: "guest shut down +cleanly" (qga path). Passwordless SSH: Mac key in guest's +administrators_authorized_keys; `ssh dmitry@192.168.2.2` (password fallback: +rdp). Guest has staged `%TEMP%\nixos.aarch64.wsl` (577MB) + +`C:\Users\Dmitry\nixos-import.ps1`; WSL engine 2.7.11 installed; wsl --list +empty (expected — import blocked). + +### Taskfile changes this session (feature/wip, ALL UNCOMMITTED — git policy: never commit unless asked) +- `debug:windows:start`: NESTED var (default 1; 0 = proven plain-virt boot + with TPM); SECURE var (0 = qemu's plain EDK2 instead of UTM secure-code); + version-gated `virtualization=on,gic-version=3` + `-boot + menu=on,splash-time=0` when nested; TPM skipped when nested (tpm-tis = + HV_BAD_ARGUMENT under EL2; safe: BitLocker Protection Off); NVMe serial + truncated `cut -c1-20` (QEMU 11.1 enforces spec); launch wrapped in + `if ! qemu…; then tail -5 qemu.log; fi` (daemonize hides errors); + pidfile pre-touched user-owned (no more root-owned leftovers); stop reads + pidfile via `cat || $SUDO cat`. +- NEW `debug:alpine:start` / `debug:alpine:stop`: nested-virt litmus test. + ISO `.tmp/alpine-virt-aarch64.iso` (downloaded, 80MB). SERIAL_PORT default + 5910 → interactive serial on tcp:0.0.0.0:5910 (`nc 127.0.0.1 5910`); + SERIAL_PORT= (empty) → file log + blocking wait for login prompt. + NESTED=0 control boots at EL1. +- Also earlier (pre-session summary): flake.nix devShell + swtpm; .gitignore + `.tmp/`; whole start/stop rewrite (pid lock, qmsg perl helper for + QMP/qga — macOS `nc -U` never returns data; `env kill` not `kill` in task + scripts — mvdan/sh builtin no-ops). + +## Techniques discovered (reusable) +- I can watch the VM myself from this container: VNC via + `host.docker.internal:5907`, python vncdotool (pip-installed, user site) + captures screenshots → Read the png. Guest SSH direct: `ssh -o + BatchMode=yes dmitry@192.168.2.2` works from container. +- Repo `.tmp/` is bind-mounted → I can read qemu.log/serial.log live. +- Alpine serial over tcp: connect from container, send '\n' to wake getty, + login root (no password), run dmesg. +- Windows over cmd-SSH: `&` separators; PowerShell `$_` breaks through ssh + quoting — use dism/findstr instead; strip UTF-16 with `tr -d '\0\r'`. + +## Open items +- All session changes uncommitted; user hasn't asked to commit. +- Optional next: NixOS import via `task debug:windows:start ACCEL=tcg` run + (import script staged in guest); user hasn't decided. +- deleted CONTINUE.md from a previous session was in git status (D CONTINUE.md); + this file replaces it. diff --git a/README.md b/README.md index 4f3ee91..eb55eb8 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ Add-on modules (set `modules = ["android"]` in `.devcell.toml`): | Module | What's inside | |---|---| -| **android** | ADB + fastboot (all platforms), Android SDK + build-tools + emulator + apktool + jadx (x86_64 only) | +| **android** | ADB + fastboot and the full app RE toolkit — decompilers (jadx, apktool, cfr, dex2jar, enjarify, procyon, androguard), APK acquisition/signing (apkeep, bundletool, apksigner), static triage (apkleaks, apkid, quark-engine), dynamic analysis (mitmproxy, mitmproxy2swagger, frida-tools, jnitrace, scrcpy), OTA/boot-image tools — all platforms; Android SDK + build-tools + emulator (x86_64 only) | | **desktop** | GUI desktop: VNC, RDP, Fluxbox, PulseAudio | | **scraping** | Playwright stealth scripts, anti-fingerprint Chromium config | | **infra** | Cloud CLI tools: AWS, GCP, Azure | @@ -73,6 +73,59 @@ vagrant_box = "utm/bookworm" On first run the CLI scaffolds a `Vagrantfile`, starts the VM, installs Nix single-user, and applies the same home-manager configuration used by Docker images. Subsequent runs detect whether provisioning is needed and skip it if the binary is already present. +## libvirt engine (host VMs from inside a cell) + +Inside a Docker cell on a Mac there is no HVF and no `/dev/kvm`, so `--engine=qemu` falls back to TCG software emulation (10–20× slower). The libvirt engine instead drives QEMU **on the macOS host** — with HVF acceleration — through libvirtd, reached from the cell over `qemu+tcp://host.docker.internal/session`. + +Scope: libvirt mode boots and connects to an **already-prepped template**. Build the template once with `cell build --engine=qemu` on the macOS host; `cell build --engine=libvirt` intentionally refuses (CELL-379 tracks install-over-libvirt). + +One-time host setup (macOS): + +```bash +brew install libvirt +brew services start libvirt +``` + +Enable TCP listen for the session daemon in `libvirtd.conf` (usually `/opt/homebrew/etc/libvirt/libvirtd.conf`): + +``` +listen_tcp = 1 +listen_addr = "127.0.0.1" +auth_tcp = "none" +``` + +> **Security note:** `qemu+tcp` with `auth_tcp = "none"` is unauthenticated — anyone who can reach the port can control your VMs. Keep `listen_addr` on loopback/the Docker bridge only. A hardened `qemu+ssh://` transport is planned; until then treat this as a local-development convenience. + +Then from any cell: + +```bash +cell shell --engine=libvirt # boot the template on the host, SSH in +cell shell --engine=libvirt --dry-run # print the resolved URI + domain XML +``` + +**Auto-default:** inside a Docker cell on a Mac (container + `host.docker.internal` resolves + no usable `/dev/kvm`), `--engine=qemu` automatically upgrades to libvirt remote mode — local qemu could only mean TCG. Pin the in-container path with `--engine=qemu --local`. + +**Project files:** the guest's `~\` is synced over the session's SSH channel — pushed before your agent starts (`push`, default), optionally pulled back on exit (`two-way`), or disabled (`off`). + +Configuration (`.devcell.toml`): + +```toml +[cell] +engine = "libvirt" +libvirt_uri = "qemu+tcp://host.docker.internal/session" # default; env: DEVCELL_LIBVIRT_URI +qemu_project_sync = "push" # push (default) | two-way | off; env: DEVCELL_QEMU_PROJECT_SYNC + +# Container→host path rewrites for the domain XML: QEMU on the host must +# open disks/firmware at HOST paths, not the cell's bind-mount paths. +[cell.libvirt_path_map] +"/devcell-155" = "/Users/dmitry/dev/dimmkirr/devcell" +"/home/dmitry" = "/Users/dmitry" +``` + +The host UEFI firmware defaults to brew's `/opt/homebrew/share/qemu/edk2-aarch64-code.fd`; override with `DEVCELL_LIBVIRT_FIRMWARE`. + +Verify connectivity with `virsh -c qemu+tcp://host.docker.internal/session list --all` from inside the cell, or just run any libvirt-engine command — the preflight maps each failure (port closed, wrong service, auth enabled) to the fix. + ## MCP servers Baked into the image and auto-merged into each agent's config at container startup. User-defined servers are preserved. Where applicable, the backing tools ship too: KiCad, Inkscape, and OpenTofu are installed alongside their MCP servers, so the agent can run `tofu plan`, analyze PCBs, or edit SVGs. New servers ship with image updates. @@ -130,7 +183,7 @@ Start simple, go deeper when you need to. **Extend a stack** - edit `.devcell/flake.nix` to add nix packages. Run `cell build` to apply. -**Fork nixhome** - fork the [nixhome](https://github.com/DimmKirr/devcell/tree/main/nixhome) repo, point your flake to your fork. Upstream updates still merge cleanly. +**Fork nixhome** - fork the [nixhome](https://github.com/devcell-sh/community-home) repo, point your flake to your fork. Upstream updates still merge cleanly.
Development diff --git a/Taskfile.yml b/Taskfile.yml index dd7e3fc..5a4181f 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -36,6 +36,36 @@ vars: git describe --tags --always --dirty 2>/dev/null || echo "v0.0.0" fi CELL_LDFLAGS: -s -w -X github.com/DimmKirr/devcell/internal/version.Version={{.CELL_VERSION}} -X github.com/DimmKirr/devcell/internal/version.GitCommit={{.GIT_COMMIT_HASH}} -X github.com/DimmKirr/devcell/internal/version.BuildDate={{.BUILD_DATE}} + CELL_BUILD_TAGS: + sh: | + tags="" + if pkg-config --exists wimlib 2>/dev/null; then tags="wimlib"; fi + echo "$tags" + + # Docker daemon the Docker-dependent test tasks talk to. + # + # Pinned to the Docker Desktop socket while the Colima migration is in + # flight, so a half-provisioned Colima VM cannot silently become the build + # daemon mid-suite (a 2 GiB stock VM makes the thin build's ceilings drop + # out entirely — see clampBuildLimits and `task debug:colima`). + # + # Resolution order: + # 1. explicit DOCKER_SOCK= override + # 2. ~/.docker/run/docker.sock — Docker Desktop, present on the host Mac + # 3. ambient DOCKER_HOST, else /var/run/docker.sock — this is the path + # inside a devcell container, where the host forwards its daemon socket + # and the Docker Desktop user socket does NOT exist. Hardcoding (2) + # would break every container-side run. + # + # Target a different daemon explicitly: + # task test:integration DOCKER_SOCK=unix://$HOME/.colima/default/docker.sock + DOCKER_SOCK: + sh: | + if [ -S "$HOME/.docker/run/docker.sock" ]; then + echo "unix://$HOME/.docker/run/docker.sock" + else + echo "${DOCKER_HOST:-unix:///var/run/docker.sock}" + fi env: BUILDKIT_PROGRESS: plain @@ -59,74 +89,122 @@ tasks: cmds: - go run $(ls cmd/*.go | grep -Ev '(_test|main)\.go') web/src/content/cell - # ── Validation (CI-cheap, no build) ──────────────────────────────────── - nix:validate: - desc: Validate all nixhome stacks — syntax check then attr check (no build, no activation) + hm:generate: + desc: Regenerate nix/home-manager/options.nix from the Go config schema (internal/cfg.CellConfig) dir: "{{.TASKFILE_DIR}}" + silent: true cmds: + - mkdir -p nix/home-manager + - go run cmd/hmoptgen.go -out nix/home-manager/options.nix + # Parse-check the module pair when nix is available (CI Go jobs may not have it). - | - set -e - NIX_BIN=/opt/devcell/.local/state/nix/profiles/profile/bin - FLAKE="{{.TASKFILE_DIR}}/nixhome" - - echo "=== 1/2 Syntax check (nix-instantiate --parse) ===" - FAIL=0 - for f in $(find nixhome -name '*.nix'); do - if sudo "$NIX_BIN/nix-instantiate" --parse "$f" >/dev/null 2>&1; then - echo " OK $f" - else - echo " FAIL $f" - sudo "$NIX_BIN/nix-instantiate" --parse "$f" 2>&1 | grep 'error:' >&2 - FAIL=1 - fi - done - [ "$FAIL" -eq 0 ] || exit 1 - - echo "" - echo "=== 2/2 Attr check (nix eval, no build) ===" - ARCH=$(uname -m) - [ "$ARCH" = "aarch64" ] && SUFFIX="-aarch64" || SUFFIX="" - for stack in base go node python fullstack electronics ultimate; do - CFG="devcell-${stack}${SUFFIX}" - printf " %-40s" "$CFG" - COUNT=$(sudo "$NIX_BIN/nix" eval \ - "${FLAKE}#homeConfigurations.${CFG}.config.home.packages" \ - --apply 'builtins.length' --json 2>/dev/null) \ - && echo "OK ($COUNT packages)" \ - || { echo "FAIL"; sudo "$NIX_BIN/nix" eval \ - "${FLAKE}#homeConfigurations.${CFG}.config.home.packages" \ - --apply 'builtins.length' --json 2>&1 | grep 'error:' >&2; exit 1; } - done - echo "" - echo "All stacks valid." + if command -v nix-instantiate >/dev/null 2>&1; then + for f in nix/home-manager/options.nix nix/home-manager/module.nix; do + nix-instantiate --parse "$f" >/dev/null || exit 1 + done + fi - nix:update-vendor-hash: - desc: Refresh flake.nix vendorHash after go.mod/go.sum change (writes flake.nix in place) + nix:sync: + desc: Sync flake.nix vendorHash with go.mod/go.sum and stage it — the one command to run after changing dependencies dir: "{{.TASKFILE_DIR}}" silent: true cmds: - | + # vendorHash covers the THIRD-PARTY module set (go.sum), not devcell's + # own source — nix hashes `src` itself. So this only ever needs running + # when go.mod/go.sum change; editing devcell code never invalidates it. + # + # Resolution is the fixed-output dance: build goModules with the current + # hash and read the `got:` value from the mismatch error. Deliberately + # NOT nix-update: its eval imports the flake via `getFlake `, + # which copies the ENTIRE repo directory into the store — including + # gitignored VM images (test/ alone is >100 GB) — and fills the disk. + # `nix build` on a flake ref uses the git fetcher: tracked files only. FLAKE="{{.TASKFILE_DIR}}/flake.nix" - replace_hash() { local tmp=$(mktemp); sed "s|vendorHash = \"sha256-[^\"]*\"|vendorHash = \"$1\"|" "$FLAKE" > "$tmp" && cat "$tmp" > "$FLAKE" && rm -f "$tmp"; } - OLD_HASH=$(grep 'vendorHash = "sha256-' "$FLAKE" | sed 's/.*"\(sha256-[^"]*\)".*/\1/') - BACKUP=$(mktemp) - cp "$FLAKE" "$BACKUP" - replace_hash "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" - echo "Building .#cell to compute vendorHash (this may take a minute)..." - NEW_HASH=$(nix build .#cell 2>&1 | grep 'got:' | awk '{print $2}') - if [ -z "$NEW_HASH" ]; then - cp "$BACKUP" "$FLAKE" - rm -f "$BACKUP" - echo "nix build succeeded — vendorHash is already correct" + OLD=$(grep 'vendorHash = "sha256-' "$FLAKE" | sed 's/.*"\(sha256-[^"]*\)".*/\1/') + echo "Verifying vendorHash against go.sum ($(awk '{print $1}' go.sum | sort -u | wc -l | tr -d ' ') modules — downloads on first run)..." + if OUT=$(nix build "{{.TASKFILE_DIR}}#cell.goModules" --no-link 2>&1); then + echo "vendorHash already correct ($OLD)" exit 0 fi - rm -f "$BACKUP" - replace_hash "$NEW_HASH" - if [ "$OLD_HASH" != "$NEW_HASH" ]; then - echo "vendorHash updated: $OLD_HASH → $NEW_HASH" - echo "flake.nix was modified — re-stage and re-commit" + NEW=$(printf '%s\n' "$OUT" | sed -n 's/.*got: *\(sha256-[^ ]*\).*/\1/p' | head -1) + if [ -z "$NEW" ]; then + echo "ERROR: goModules build failed for a reason other than a hash mismatch:" >&2 + printf '%s\n' "$OUT" | tail -15 >&2 + exit 1 + fi + sed "s|$OLD|$NEW|" "$FLAKE" > "$FLAKE.tmp" && mv "$FLAKE.tmp" "$FLAKE" + if ! nix build "{{.TASKFILE_DIR}}#cell.goModules" --no-link; then + echo "ERROR: resolved hash did not verify — flake.nix restored." >&2 + git checkout -- "$FLAKE" exit 1 fi + git add "$FLAKE" + echo "vendorHash updated and staged: $OLD → $NEW" + + nix:check-vendor-hash: + desc: Warn if go.mod/go.sum changed without a vendorHash update (non-blocking, instant) + dir: "{{.TASKFILE_DIR}}" + silent: true + cmds: + - | + # Instant check: if go.mod or go.sum are staged, the vendorHash + # line in flake.nix must be part of the same staged diff. Merely + # touching flake.nix for an unrelated reason (e.g. an unrelated + # nix module edit riding in the same commit) is not enough — it + # previously produced a false "all good" on commit 96bee90. No + # nix build needed for this check. + if git diff --cached --name-only | grep -qE '^(go\.mod|go\.sum)$'; then + if ! git diff --cached -- flake.nix | grep -q '^[+-].*vendorHash'; then + echo "⚠ go.mod/go.sum changed but the vendorHash line in flake.nix was not updated — run: task nix:sync" >&2 + fi + fi + exit 0 + + nix:build: + desc: Build the cell package via nix and verify it runs — the real check that vendorHash/flake.nix are correct + dir: "{{.TASKFILE_DIR}}" + deps: [hm:generate] + cmds: + - | + set -e + OUT=$(nix build .#cell --no-link --print-out-paths) + "$OUT/bin/cell" --version + + test:powershell:lint: + desc: Lint the guest PowerShell scripts (.ps1, .psm1) with PSScriptAnalyzer — template lint lives in go-winkit (task test:powershell:lint there) + dir: "{{.TASKFILE_DIR}}" + silent: true + cmds: + - | + set -e + if ! command -v pwsh >/dev/null 2>&1; then + echo "pwsh not found — install: nix profile install nixpkgs#powershell" >&2 + exit 1 + fi + pwsh -NoProfile -NonInteractive -Command ' + if (-not (Get-Module -ListAvailable PSScriptAnalyzer)) { + Write-Output "Installing PSScriptAnalyzer..." + Install-Module PSScriptAnalyzer -Force -Scope CurrentUser -Repository PSGallery + } + $files = @( + Get-ChildItem -Recurse -Path "internal/vm/qemu/guest" -Include "*.ps1","*.psm1" + ) + if ($files.Count -eq 0) { Write-Output "No .ps1/.psm1 files found"; exit 0 } + $fail = $false + foreach ($f in $files) { + $results = Invoke-ScriptAnalyzer -Path $f.FullName -Severity Error,Warning + if ($results) { + $fail = $true + foreach ($r in $results) { + Write-Output (" {0}:{1} [{2}] {3}" -f $r.ScriptName, $r.Line, $r.Severity, $r.Message) + } + } else { + Write-Output (" OK {0}" -f $f.FullName) + } + } + if ($fail) { exit 1 } else { Write-Output "All scripts clean." } + ' bake:validate: desc: Print resolved docker-bake config (HCL parse check, no build) @@ -136,6 +214,29 @@ tasks: - GIT_COMMIT={{.GIT_COMMIT_HASH}} docker buildx bake --file {{.TASKFILE_DIR}}/docker-bake.hcl --print ci # ── cell CLI binary ──────────────────────────────────────────────────── + + cell:deps: + desc: Verify build-time library dependencies + dir: "{{.TASKFILE_DIR}}" + silent: true + cmds: + - | + ok=1 + if [ "$(uname -s)" = "Darwin" ]; then + if ! command -v pkg-config >/dev/null 2>&1; then + echo "⚠ pkg-config not found (brew install pkg-config)" + ok=0 + fi + if ! pkg-config --exists wimlib 2>/dev/null; then + echo "⚠ wimlib not found — QEMU Windows VM builds will be disabled" + echo " Install: brew install wimlib" + fi + fi + if ! command -v go >/dev/null 2>&1; then + echo "✗ go not found"; ok=0 + fi + [ "$ok" -eq 1 ] || { echo ""; echo "Fix the above and re-run."; exit 1; } + # macOS Sequoia (15.x) launch-constraint guard: after `go build`, the OS- # recorded provenance for the freshly written file doesn't match the new # binary's content → kernel SIGKILLs the process at exec time, before @@ -146,11 +247,11 @@ tasks: cell:build: desc: Build cell CLI binary → ./bin/cell dir: "{{.TASKFILE_DIR}}" - deps: [swagger:generate] + deps: [swagger:generate, hm:generate, cell:deps] silent: true cmds: - mkdir -p bin - - CGO_ENABLED={{if eq OS "darwin"}}1{{else}}0{{end}} go build -ldflags "{{.CELL_LDFLAGS}}" -o ./bin/cell ./cmd/ + - CGO_ENABLED={{if eq OS "darwin"}}1{{else}}0{{end}} go build {{if .CELL_BUILD_TAGS}}-tags "{{.CELL_BUILD_TAGS}}" {{end}}-ldflags "{{.CELL_LDFLAGS}}" -o ./bin/cell ./cmd/ - '[ "$(uname -s)" != "Darwin" ] || xattr -c ./bin/cell' - '[ "$(uname -s)" != "Darwin" ] || codesign --force --sign - --entitlements entitlements.plist ./bin/cell' @@ -235,108 +336,6 @@ tasks: --set '*.output=type=image,push=true,oci-mediatypes=true,compression=zstd,compression-level=3,force-compression=true' ci {{.CLI_ARGS}} - # ── image:pure (nix2container — default) ─────────────────────────────── - image:pure:build: - desc: Build all pure local images (base + ultimate, load into Docker daemon) - cmds: - - task: image:pure:build:base - - task: image:pure:build:ultimate - - image:pure:build:base: - desc: Build pure base image locally - cmds: - - task: image:pure:build:stack - vars: { STACK: base } - - image:pure:build:ultimate: - desc: Build pure ultimate image locally - cmds: - - task: image:pure:build:stack - vars: { STACK: ultimate } - - image:pure:build:stack: - aliases: [image:build:pure] # back-compat (cited in internal/runner/pure_build.go:381) - desc: "Build a pure image (single stack), load to Docker. STACK={base|ultimate|go|node|python|fullstack|electronics}" - dir: "{{.TASKFILE_DIR}}" - silent: true - vars: - STACK: '{{.STACK | default "base"}}' - # SUDO: empty in CI (Determinate Systems single-user nix on PATH) and on - # the host (Determinate or upstream single-user). Override SUDO=sudo for - # devcell containers where DEVCELL_NIX_DAEMON=false (pre-CELL-72 builds). - SUDO: '{{.SUDO | default ""}}' - # NIX: defaults to "nix" on PATH. Override NIX=/full/path/to/nix when the - # binary isn't on PATH (uncommon — devcell, CI, and host all expose nix). - NIX: '{{.NIX | default "nix"}}' - NIX_ARCH: - sh: '[ "$(uname -m)" = "aarch64" ] && echo aarch64-linux || echo x86_64-linux' - env: - DEVCELL_BUILD_DATE: '{{.DEVCELL_BUILD_DATE | default "auto"}}' - DEVCELL_BUILD_REV: '{{.DEVCELL_BUILD_REV | default .GIT_COMMIT_HASH}}' - cmds: - - echo ">> Building devcell-{{.STACK}}-pure-image for {{.NIX_ARCH}}" - # --impure required so image.nix can read DEVCELL_BUILD_{DATE,REV} via builtins.getEnv. - - '{{.SUDO}} {{.NIX}} build --impure "path:{{.TASKFILE_DIR}}/nixhome#packages.{{.NIX_ARCH}}.devcell-{{.STACK}}-pure-image" --out-link "{{.TASKFILE_DIR}}/result-{{.STACK}}-pure"' - - echo ">> Loading into Docker daemon as devcell-user:{{.STACK}}-pure" - - '{{.SUDO}} {{.NIX}} run --impure "path:{{.TASKFILE_DIR}}/nixhome#packages.{{.NIX_ARCH}}.devcell-{{.STACK}}-pure-image.copyToDockerDaemon"' - - docker images devcell-user:{{.STACK}}-pure - - image:pure:push: - desc: Build + push all pure images (base + ultimate) - cmds: - - task: image:pure:push:base - - task: image:pure:push:ultimate - - image:pure:push:base: - desc: Build + push pure base image to registry - cmds: - - task: image:pure:push:stack - vars: { STACK: base } - - image:pure:push:ultimate: - desc: Build + push pure ultimate image to registry - cmds: - - task: image:pure:push:stack - vars: { STACK: ultimate } - - image:pure:push:stack: - desc: "Build + push a pure image (single stack). STACK= ARCH=auto|amd64|arm64 TAG= REGISTRY=… SUDO= NIX=" - dir: "{{.TASKFILE_DIR}}" - silent: true - vars: - STACK: '{{.STACK | default "ultimate"}}' - REGISTRY: '{{.REGISTRY | default "ghcr.io/devcell-sh/devcell"}}' - TAG: '{{.TAG | default (printf "v0.0.0-%s-pure" .STACK)}}' - SUDO: '{{.SUDO | default ""}}' # see image:pure:build:stack notes - NIX: '{{.NIX | default "nix"}}' - NIX_ARCH: - sh: | - case "{{.ARCH | default ""}}" in - amd64|x86_64) echo x86_64-linux ;; - arm64|aarch64) echo aarch64-linux ;; - *) [ "$(uname -m)" = "aarch64" ] && echo aarch64-linux || echo x86_64-linux ;; - esac - env: - DEVCELL_BUILD_DATE: '{{.DEVCELL_BUILD_DATE | default "auto"}}' - DEVCELL_BUILD_REV: '{{.DEVCELL_BUILD_REV | default .GIT_COMMIT_HASH}}' - cmds: - # nix2container's `.copyTo` runs skopeo from the derivation closure with - # OCI media types end-to-end — zstd layers are legal, no v2s2 mismatch. - # Auth: docker/login-action writes ~/.docker/config.json; --authfile reuses it. - # --retry-times 5 + --retry-delay 5: GHCR occasionally returns 504 Gateway - # Timeout uploading large blobs (~hundreds of MB); without retries skopeo - # bails after a single failure and we lose all the upload progress on the - # remaining blobs. 5×5s = 25s max retry budget per blob is well below the - # GHA job timeout and covers the typical GHCR recovery window. - - >- - {{.SUDO}} {{.NIX}} run --impure - "path:{{.TASKFILE_DIR}}/nixhome#packages.{{.NIX_ARCH}}.devcell-{{.STACK}}-pure-image.copyTo" - -- - --authfile "${DOCKER_CONFIG:-$HOME/.docker}/config.json" - --retry-times 5 - --retry-delay 5s - "docker://{{.REGISTRY}}:{{.TAG}}" - # ── image: variant-agnostic utilities ────────────────────────────────── image:mirror: desc: "Copy a tag between registries (e.g. GHCR → ECR Public). SRC= DST=" @@ -375,7 +374,11 @@ tasks: silent: true dir: "{{.TASKFILE_DIR}}" cmds: + - 'echo "docker daemon: {{.DOCKER_SOCK}}"' - go test -v -timeout 120s ./test/... {{.CLI_ARGS}} + # see DOCKER_SOCK in top-level vars + env: + DOCKER_HOST: '{{.DOCKER_SOCK}}' test:vagrant: desc: Run E2E install test in a clean Debian VM (QEMU). Requires vagrant + vagrant-qemu plugin. @@ -406,7 +409,94 @@ tasks: test:cache: desc: Round-trip nix-cache pipeline locally (~1 min, requires docker + crane) cmds: + - 'echo "docker daemon: {{.DOCKER_SOCK}}"' - go test -v -count=1 -run TestCacheRoundtrip ./test/... + # see DOCKER_SOCK in top-level vars + env: + DOCKER_HOST: '{{.DOCKER_SOCK}}' + + # ── Full Windows install via QEMU (CELL-429) ────────────────────────── + # Usage: + # task test:windows:build # runs tcg subtest (default) + # task test:windows:build ACCEL=hvf # runs hvf subtest + # task test:windows:build ACCEL="" # runs all subtests + test:windows:build: + desc: "Run the full unattended Windows install through `cell build --engine=qemu`" + platforms: [darwin] + silent: true + vars: + ACCEL: tcg + DEBUG_DIR: '{{.TASKFILE_DIR}}/.scratch/debug' + cmds: + - mkdir -p {{.DEBUG_DIR}} + - | + set -euo pipefail + env_log="{{.DEBUG_DIR}}/environment.log" + + # ── Resolve every path the VM boot uses ── + qemu_bin=$(which qemu-system-aarch64 2>/dev/null || true) + qemu_real="" + qemu_prefix="" + fw="" + if [ -n "$qemu_bin" ]; then + qemu_real=$(realpath "$qemu_bin" 2>/dev/null || readlink -f "$qemu_bin" 2>/dev/null || echo "$qemu_bin") + qemu_prefix=$(dirname "$qemu_real")/.. + fw="${qemu_prefix}/share/qemu/edk2-aarch64-code.fd" + fi + + cache_dir="${DEVCELL_QEMU_CACHE_DIR:-$HOME/.devcell/cache/qemu}" + win_iso="${DEVCELL_TEST_WINDOWS_ISO:-${cache_dir}/windows-arm64-en-us.iso}" + virtio_iso="${DEVCELL_TEST_VIRTIO_ISO:-${cache_dir}/virtio-win.iso}" + + check_file() { + local label="$1" path="$2" + if [ -f "$path" ]; then + printf " %-14s %-6s %s\n" "$label" "$(du -h "$path" 2>/dev/null | cut -f1)" "$path" + else + printf " %-14s %-6s %s\n" "$label" "MISS" "$path" + fi + } + + { + echo "=== test:windows:build $(date -u +%Y%m%dT%H%M%SZ) ===" + + echo "--- qemu binary ---" + echo " PATH lookup: ${qemu_bin:-NOT FOUND}" + echo " realpath: ${qemu_real:-N/A}" + [ -n "$qemu_bin" ] && qemu-system-aarch64 --version 2>&1 | head -1 | sed 's/^/ version: /' + echo "" + + echo "--- VM boot file preflight ---" + check_file "firmware" "$fw" + check_file "windows-iso" "$win_iso" + check_file "virtio-iso" "$virtio_iso" + echo "" + + echo "--- cache directory ---" + echo " resolved: $cache_dir" + if [ -d "$cache_dir" ]; then + ls -lh "$cache_dir" 2>/dev/null | sed 's/^/ /' + else + echo " DOES NOT EXIST" + fi + echo "" + } 2>&1 | tee "$env_log" + + # ── Run the full Windows install test ── + accel="{{.ACCEL}}" + run_filter="TestCellBuildWindows_QEMU" + if [ -n "$accel" ]; then + run_filter="TestCellBuildWindows_QEMU/${accel}" + fi + log="{{.DEBUG_DIR}}/TestCellBuildWindows_QEMU.log" + echo "▸ ${run_filter} → ${log}" + build_env="DEVCELL_TEST_INSTALL=1 DEVCELL_TEST_REBUILD=1" + if env $build_env go test -run "${run_filter}" -timeout 8h -v -count=1 ./internal/vm/qemu/ 2>&1 | tee "${log}"; then + echo " ✓ PASS" + else + echo " ✗ FAIL" + exit 1 + fi # ── Nix-store GHCR cache pipeline ────────────────────────────────────── # Stream the populated /nix volume to GHCR as a multi-layer cache image. @@ -445,6 +535,130 @@ tasks: debug: cmds: - task: debug:macos + # ── Forced QEMU Windows autobuild with full log capture (CELL-428/429) ───── + debug:autobuild: + desc: "Forced QEMU Windows template build with full log capture → .scratch/debug/autobuild.log" + platforms: [darwin] + deps: [install] + silent: true + env: + # CELL-429 iteration 2 instrument: ships the WinPE agent on the answer + # volume plus a pre-baked one-shot diagnostic (drvload vioscsi + diskpart + # volume list → devcell-out.txt). + # H1 ($WinPEDriver$ sweep never ran): devcell-setupact.log snapshot + # shows whether wpeinit processed the driver dir. + # H2 (padForFAT broke the driver files): drivers now ship byte-exact, + # and drvload's real output/exit code lands in devcell-out.txt. + # H3 (any non-reg-add RunSynchronous aborts 0x8007000D): this run IS + # the test — the agent launcher is the vetted %l pattern; if the + # abort returns, H3 is confirmed and the agent design is dead. + DEVCELL_QEMU_WINPE_AGENT: "1" + vars: + LOG: '{{.TASKFILE_DIR}}/.scratch/debug/autobuild.log' + CACHE: '{{.HOME}}/.devcell/cache/qemu' + cmds: + - mkdir -p {{.TASKFILE_DIR}}/.scratch/debug + - | + { + echo "=== debug:autobuild $(date -u +%Y%m%dT%H%M%SZ) ===" + echo "--- cell version ---" + cell --version 2>&1 || true + echo "--- qemu ---" + command -v qemu-system-aarch64 2>&1 || true + qemu-system-aarch64 --version 2>&1 | head -1 || true + echo "--- media cache ---" + ls -la "{{.CACHE}}/" 2>&1 || true + echo "--- cached ISO volume descriptors (expect: catalog@15, BEA01@16, CD001 BRVD@17, NSR02@18, TEA01@19) ---" + for s in 15 16 17 18 19; do + printf "sector %s: " "$s" + dd if="{{.CACHE}}/windows-arm64-en-us.iso" bs=2048 skip=$s count=1 2>/dev/null | xxd -l 8 | head -1 || echo "unreadable" + done + echo "--- bootloader sidecar ---" + ls -la "{{.CACHE}}/windows-arm64-en-us.iso.bootaa64.efi" 2>&1 || echo "no sidecar (will re-master or fall back to ISO read)" + echo "--- template dir before ---" + ls -la "{{.HOME}}/.devcell/windows/ultimate/" 2>&1 || true + echo "" + } > {{.LOG}} 2>&1 + - | + set -o pipefail + cell claude --engine=qemu --debug --force 2>&1 | tee -a {{.LOG}} + rc=$? + { + echo "" + echo "=== exit code: $rc ===" + echo "=== serial.log (tail 120) ===" + tail -120 "{{.TASKFILE_DIR}}/.scratch/debug/serial.log" 2>/dev/null || true + echo "=== guest-progress.log (tail 60) ===" + tail -60 "{{.TASKFILE_DIR}}/.scratch/debug/guest-progress.log" 2>/dev/null || true + echo "=== newest screenshots ===" + ls -t "{{.TASKFILE_DIR}}/.scratch/debug/screenshots/" 2>/dev/null | head -12 || true + echo "=== template dir after ===" + ls -la "{{.HOME}}/.devcell/windows/ultimate/" 2>/dev/null || true + } >> {{.LOG}} 2>&1 + echo "" + echo "full log: {{.LOG}}" + exit $rc + + # ── Windows ISO debugging (UUP dump download + wimlib assembly) ───── + debug:windows: + desc: "Quick Windows QEMU smoke test: build → exec echo hello world" + platforms: [darwin] + deps: [install] + silent: true + vars: + MARKER: '{{.HOME}}/.devcell/windows/ultimate/.provisioned' + LOG: '{{.TASKFILE_DIR}}/.scratch/debug/windows.log' + cmds: + - | + set -euo pipefail + mkdir -p "$(dirname "{{.LOG}}")" + + _run() { + echo "=== [0/3] Pre-flight diagnostics ===" + echo " date: $(date -u +%Y-%m-%dT%H:%M:%SZ)" + echo " cell: $(cell --version 2>&1 || echo 'not found')" + echo " qemu: $(qemu-system-aarch64 --version 2>/dev/null | head -1 || echo 'not found')" + echo " bunk env: DEVCELL_BUNK=${DEVCELL_BUNK:-} SESSION_PORT_PREFIX=${SESSION_PORT_PREFIX:-}" + echo " marker: {{.MARKER}} — $(test -f '{{.MARKER}}' && echo 'EXISTS' || echo 'MISSING')" + echo "" + echo " template disk:" + ls -lh {{.HOME}}/.devcell/windows/ultimate/disk-ultimate.qcow2 2>/dev/null || echo " not found" + echo "" + echo " instance disk:" + ls -lh {{.HOME}}/.devcell/DIMM/windows/disk.qcow2 2>/dev/null || echo " not found" + echo "" + echo " ports.json:" + cat {{.HOME}}/.devcell/DIMM/windows/ports.json 2>/dev/null || echo " not found" + echo "" + echo " qemu processes:" + pgrep -lf qemu-system 2>/dev/null || echo " none" + echo "" + echo " port 2222 (legacy):" + lsof -i :2222 -sTCP:LISTEN 2>/dev/null || echo " not in use" + echo "" + + echo "=== [1/3] Build VM ===" + if [ -f "{{.MARKER}}" ]; then + echo "Template already provisioned — skipping build." + else + echo "Template not provisioned — building (--force in case stale disk exists)..." + cell build --engine=qemu --force --debug 2>&1 + fi + echo "" + + echo "=== [2/3] Post-build diagnostics ===" + echo " marker: $(test -f '{{.MARKER}}' && echo 'EXISTS' || echo 'MISSING')" + echo " template disk:" + ls -lh {{.HOME}}/.devcell/windows/ultimate/disk-ultimate.qcow2 2>/dev/null || echo " not found" + echo " qemu processes:" + pgrep -lf qemu-system 2>/dev/null || echo " none" + echo "" + + echo "=== [3/3] Shell exec: echo hello world ===" + cell shell --engine=qemu --debug -- cmd /c echo hello world + } + + _run 2>&1 | tee "{{.LOG}}" # ── macOS VM debugging ──────────────────────────────────────────────── debug:macos: desc: Mount a stopped macOS VM disk and dump diagnostics to .devcell/debug/-macos.log @@ -571,13 +785,966 @@ tasks: echo "" echo "saved to: $LOG" + # ── CD bus × accelerator matrix (CELL-429) ────────────────────────── + debug:macos:disk: + desc: "Run WinPE CD visibility tests (scsi-cd × hvf × {el2,no-el2}) on macOS" + platforms: [darwin] + silent: true + vars: + DEBUG_DIR: '{{.TASKFILE_DIR}}/.scratch/debug' + cmds: + - mkdir -p {{.DEBUG_DIR}} + - | + set -euo pipefail + env_log="{{.DEBUG_DIR}}/environment.log" + + # ── Resolve every path the VM boot uses ── + # These mirror the Go helpers: requireQEMUBin, FirmwarePath, + # requireWindowsISO, requireVirtioISO (boot_test.go / download.go). + qemu_bin=$(which qemu-system-aarch64 2>/dev/null || true) + qemu_real="" + qemu_prefix="" + fw="" + if [ -n "$qemu_bin" ]; then + qemu_real=$(realpath "$qemu_bin" 2>/dev/null || readlink -f "$qemu_bin" 2>/dev/null || echo "$qemu_bin") + qemu_prefix=$(dirname "$qemu_real")/.. + fw="${qemu_prefix}/share/qemu/edk2-aarch64-code.fd" + fi + + cache_dir="${DEVCELL_QEMU_CACHE_DIR:-$HOME/.devcell/cache/qemu}" + win_iso="${DEVCELL_TEST_WINDOWS_ISO:-${cache_dir}/windows-arm64-en-us.iso}" + virtio_iso="${DEVCELL_TEST_VIRTIO_ISO:-${cache_dir}/virtio-win.iso}" + fw_override="${QEMU_FIRMWARE_OVERRIDE:-}" + + # Helper: print file info or MISSING + check_file() { + local label="$1" path="$2" + if [ -f "$path" ]; then + printf " %-14s %-6s %s\n" "$label" "$(du -h "$path" 2>/dev/null | cut -f1)" "$path" + else + printf " %-14s %-6s %s\n" "$label" "MISS" "$path" + fi + } + + { + echo "=== debug:macos:disk $(date -u +%Y%m%dT%H%M%SZ) ===" + + echo "--- qemu binary ---" + echo " PATH lookup: ${qemu_bin:-NOT FOUND}" + echo " realpath: ${qemu_real:-N/A}" + echo " prefix: ${qemu_prefix:-N/A}" + [ -n "$qemu_bin" ] && qemu-system-aarch64 --version 2>&1 | head -1 | sed 's/^/ version: /' + echo "" + + echo "--- VM boot file preflight ---" + echo " (mirrors Go: FirmwarePath, requireWindowsISO, requireVirtioISO)" + check_file "firmware" "$fw" + check_file "windows-iso" "$win_iso" + check_file "virtio-iso" "$virtio_iso" + [ -n "$fw_override" ] && check_file "fw-override" "$fw_override" + echo "" + + echo "--- firmware fingerprint ---" + if [ -f "$fw" ]; then + echo " sha256: $(shasum -a 256 "$fw" | cut -d' ' -f1)" + strings "$fw" | grep -i 'edk2-stable\|build.*20[0-9][0-9]' | head -3 | sed 's/^/ build-tag: /' || true + fi + echo "" + + echo "--- cache directory ---" + echo " DEVCELL_QEMU_CACHE_DIR=${DEVCELL_QEMU_CACHE_DIR:-}" + echo " resolved: $cache_dir" + if [ -d "$cache_dir" ]; then + ls -lh "$cache_dir" 2>/dev/null | sed 's/^/ /' + else + echo " DOES NOT EXIST" + fi + echo "" + + echo "--- env overrides ---" + echo " DEVCELL_TEST_WINDOWS_ISO=${DEVCELL_TEST_WINDOWS_ISO:-}" + echo " DEVCELL_TEST_VIRTIO_ISO=${DEVCELL_TEST_VIRTIO_ISO:-}" + echo " QEMU_FIRMWARE_OVERRIDE=${QEMU_FIRMWARE_OVERRIDE:-}" + echo " DEVCELL_QEMU_EFI_KERNEL=${DEVCELL_QEMU_EFI_KERNEL:-}" + echo "" + + echo "--- qemu share directory ---" + if [ -n "$qemu_prefix" ] && [ -d "${qemu_prefix}/share/qemu" ]; then + echo " ${qemu_prefix}/share/qemu:" + ls -1 "${qemu_prefix}/share/qemu/" | grep -iE 'edk2|efi|uefi|aarch64|arm' | sed 's/^/ /' + else + echo " NOT FOUND" + fi + echo "" + } 2>&1 | tee "$env_log" + + # ── Test matrix: scsi-cd × {tcg,hvf} × {el2,no-el2} ── + # usb-storage disabled: EDK2 can't enumerate the answer FAT + # volume when it falls to a USB 2.0 port (XHCI port exhaustion). + # All variants run in one go test binary so subtests share a single + # timestamped results directory. + log="{{.DEBUG_DIR}}/TestWinPECDVisibility.log" + echo "▸ TestWinPECDVisibility (all variants) → ${log}" + env_args="" + [ -n "$fw_override" ] && env_args="QEMU_FIRMWARE_OVERRIDE=$fw_override" + if env $env_args go test -run "TestWinPECDVisibility/scsi-cd/hvf" -timeout 60m -v ./internal/vm/qemu/ 2>&1 | tee "${log}"; then + echo " ✓ PASS" + else + echo " ✗ FAIL" + exit 1 + fi + + debug:macos:winpe: + desc: "Run WinPE Hyper-V/WSL2 injection test (tcg + hvf) on macOS" + platforms: [darwin] + silent: true + vars: + DEBUG_DIR: '{{.TASKFILE_DIR}}/.scratch/debug' + cmds: + - mkdir -p {{.DEBUG_DIR}} + - | + set -euo pipefail + fw_override="${QEMU_FIRMWARE_OVERRIDE:-}" + + failed=0 + for sub in "tcg" "hvf"; do + slug="TestWinPEHyperVInjection-${sub}" + log="{{.DEBUG_DIR}}/${slug}.log" + echo "▸ TestWinPEHyperVInjection/${sub} → ${log}" + env_args="" + [ -n "$fw_override" ] && env_args="QEMU_FIRMWARE_OVERRIDE=$fw_override" + if env $env_args go test -run "TestWinPEHyperVInjection/${sub}" -timeout 15m -v ./internal/vm/qemu/ 2>&1 | tee "${log}"; then + echo " ✓ PASS" + else + echo " ✗ FAIL" + failed=$((failed + 1)) + fi + echo "" + done + + echo "=== done — ${failed} failure(s) ===" + exit $failed + + # ── QEMU Windows debug VM (boot an existing debug disk) ───────────── + debug:windows:start: + desc: "Boot the Windows.utm disk with qemu-system-aarch64 — same hardware as UTM (see .scratch/UTMCommand.txt), macOS-native hvf, vmnet-shared network. Vars: DISK, EFI_VARS, SMP, MEM, ACCEL (hvf|tcg)" + silent: true + vars: + # The UTM bundle's own disk, booted in place: Windows state stays + # continuous whether the VM last ran under UTM or under this task. + DISK: '{{.DISK | default (printf "%s/test/testdata/Windows.utm/Data/D76FB0BC-D7CC-4481-A6B6-492BEB4D834B.qcow2" .TASKFILE_DIR)}}' + # UEFI variable store from the same bundle — boot entries and + # firmware state the machine was installed with. + EFI_VARS: '{{.EFI_VARS | default (printf "%s/test/testdata/Windows.utm/Data/efi_vars.fd" .TASKFILE_DIR)}}' + # UTM profile: cpus=4,sockets=1,cores=4,threads=1 and 8 GiB. + SMP: '{{.SMP | default "4"}}' + MEM: '{{.MEM | default "8192"}}' + # hvf = macOS-native virtualization, near-native speed; no guest EL2 + # on stock QEMU <= 11.0, so Hyper-V/WSL2 cannot start (QEMU 11.1+ on + # M3+/macOS 15+ lifts this). tcg = full emulation: slow, but the EL3 + # secure machine where the whole WSL2 chain works. + ACCEL: '{{.ACCEL | default (eq OS "darwin" | ternary "hvf" "tcg")}}' + # NESTED=0 disables guest EL2 on hvf even when QEMU supports it — + # the proven plain-virt boot (with TPM), at the cost of no WSL2. + NESTED: '{{.NESTED | default "1"}}' + # SECURE=0 boots qemu's plain EDK2 instead of UTM's Secure-Boot- + # enforcing build. Windows runs fine without Secure Boot; use this + # when the firmware refuses to launch it (signature/SB errors). + SECURE: '{{.SECURE | default "1"}}' + # tcg only: kernel-loaded EL3 firmware (hvf boots EDK2 pflash). + FIRMWARE: '{{.FIRMWARE | default (printf "%s/.devcell/cache/qemu/QEMU_EFI.kernel.fd" .HOME)}}' + # VNC console on 127.0.0.1:5907 (open with Screen Sharing) — the only + # way to see pre-OS screens like BitLocker prompts. VNC=0 disables. + VNC: '{{.VNC | default "1"}}' + # Directory shared into the guest over SMB (drive Z:). No virtiofsd + # exists on macOS and Windows speaks no 9p, so the share is served + # by a disposable samba container on the host (needs docker). + SHARE_DIR: '{{.SHARE_DIR | default .USER_WORKING_DIR}}' + preconditions: + - sh: command -v qemu-system-aarch64 >/dev/null + msg: qemu-system-aarch64 not found on PATH + - sh: test -f "{{.DISK}}" + msg: "no disk at {{.DISK}} — copy the Windows.utm bundle into test/testdata or set DISK=" + cmds: + - | + set -euo pipefail + # All runtime state (pid, logs, sockets) lives in the repo-local + # .tmp/ (gitignored). + D="{{.TASKFILE_DIR}}/.tmp"; mkdir -p "$D" + PIDFILE="$D/qemu-windows.pid" + + # vmnet-shared needs the com.apple.vm.networking entitlement, which + # a plain qemu binary lacks — root is the workaround UTM does not + # need (its app bundle is entitled). + SUDO="" + [ "$(uname)" = "Darwin" ] && [ "$(id -u)" != "0" ] && SUDO="sudo" + + # Simple pid lock. NOTE: 'kill'/'ps' must go through env/command in + # these tasks — task's embedded shell (mvdan/sh) has a job-control + # kill builtin that silently no-ops on external pids. + if [ -f "$PIDFILE" ] && ps -p "$(cat "$PIDFILE")" >/dev/null 2>&1; then + echo "already running (pid $(cat "$PIDFILE")) — task debug:windows:stop first" + exit 1 + fi + rm -f "$PIDFILE" + + # The disk's own write lock catches everything else (UTM itself, + # a hand-launched qemu): fail with a pointer instead of reaping. + if ! qemu-img info "{{.DISK}}" >/dev/null 2>&1; then + echo "ERROR: {{.DISK}} is write-locked — is the VM running in UTM or another qemu?" + echo " close it there (or: ps aux | grep qemu-system) and retry" + exit 1 + fi + + # Hardware mirrors .scratch/UTMCommand.txt: -machine virt -cpu host, + # nvme with the bundle's serial, virtio-net with UTM's MAC on + # vmnet-shared (same network, so the guest keeps its 192.168.64.x + # DHCP lease). The SPICE/audio/usb-redir/swtpm plumbing is UTM + # frontend glue and intentionally dropped. + # QEMU 11.1+ enforces the NVMe spec's 20-char serial limit (older + # builds accepted the full bundle UUID). Windows identifies the + # boot volume by GPT, not disk serial, so truncation is safe. + serial=$(basename "{{.DISK}}" .qcow2 | cut -c1-20) + nested=0 + if [ "{{.ACCEL}}" = "hvf" ]; then + machine="virt"; cpu=host; accel=hvf + # QEMU 11.1+ (rc builds report 11.0.9x) can expose EL2 to hvf + # guests on M3+/macOS 15+ — the capability Hyper-V/WSL2 needs. + # Older QEMU rejects virtualization=on under hvf, so gate on + # version and fall back to the plain machine silently. + qv=$(qemu-system-aarch64 --version | sed -n 's/.*version \([0-9.]*\).*/\1/p') + [ "{{.NESTED}}" = "0" ] && qv=disabled + case "$qv" in + 11.0.9*|11.[1-9]*|1[2-9].*|[2-9][0-9].*) + # gic-version=3: EL2 guests need the GICv3's interrupt + # virtualization (the tcg WSL2 path uses it too); the virt + # machine's default GICv2 hangs the Windows bootloader here. + machine="virt,virtualization=on,gic-version=3"; nested=1 + # fake-el2=on: our patched hvf emulates VHE in the sysreg + # trap handler — Apple's nested API is nVHE-only, and + # Windows' hypervisor refuses to launch without VHE (Event + # 43 'EL2 not present'). Both flags are required together: + # virtualization=on exposes EL2, fake-el2=on makes it VHE. + # Gate on the property existing (scan the binary — there is + # no CLI help for accel sub-options, and a live probe would + # boot the VM) so a stock QEMU still starts, just VHE-less. + if grep -aq fake-el2 "$(command -v qemu-system-aarch64)"; then + accel="hvf,fake-el2=on" + echo "fake-el2=on: VHE emulation active — Windows Hyper-V/WSL2 can launch" + else + echo "note: this qemu has no fake-el2 — Windows boots, but Hyper-V/WSL2 won't start (VHE missing)" + fi + # Known issue in the hvf nested-virt series: EDK2 hangs + # forever at 'Start boot option' under EL2; the documented + # workaround is the zero-delay boot menu (qemu-devel, + # 'HVF: Add support for platform vGIC and nested virt'). + set -- "$@" -boot menu=on,splash-time=0 + echo "QEMU $qv: nested virtualization (EL2) enabled — Hyper-V/WSL2 can start" + ;; + esac + # UTM's EDK2 build drives virtio-gpu (its virtio-ramfb-gl is a + # virtio-gpu variant), NOT stock ramfb — with ramfb the console + # stays "guest has not initialized the display". The UTM guest + # tools already installed Windows' virtio-gpu driver. + dispdev=virtio-gpu-pci + # EDK2 code image: UTM's secure-code build if cached (matches the + # efi_vars layout), else the one qemu ships. + CODE_FD="$HOME/Library/Containers/com.utmapp.UTM/Data/Library/Caches/qemu/edk2-aarch64-secure-code.fd" + [ "{{.SECURE}}" = "0" ] && CODE_FD="" + [ -f "$CODE_FD" ] || CODE_FD="$(dirname "$(command -v qemu-system-aarch64)")/../share/qemu/edk2-aarch64-code.fd" + if [ ! -f "$CODE_FD" ] || [ ! -f "{{.EFI_VARS}}" ]; then + echo "ERROR: need EDK2 code image ($CODE_FD) and vars ({{.EFI_VARS}})" + exit 1 + fi + set -- "$@" \ + -drive if=pflash,format=raw,unit=0,file="$CODE_FD",file.locking=off,readonly=on \ + -drive if=pflash,unit=1,file="{{.EFI_VARS}}" + else + # EL3 secure machine, kernel-loaded firmware — the only boot + # environment where Windows' hypervisor (and WSL2) runs today. + machine="virt,virtualization=on,gic-version=3,secure=on" + cpu=neoverse-n1; accel=tcg,thread=multi + # Our kernel-loaded EL3 firmware drives stock ramfb. + dispdev=ramfb + FW="{{.FIRMWARE}}" + [ -f "$FW" ] || FW="${DEVCELL_QEMU_EFI_KERNEL:-}" + if [ -z "$FW" ] || [ ! -f "$FW" ]; then FW="{{.TASKFILE_DIR}}/test/testdata/QEMU_EFI.kernel.fd"; fi + if [ ! -f "$FW" ]; then + echo "ERROR: no kernel-bootable firmware (FIRMWARE / \$DEVCELL_QEMU_EFI_KERNEL / testdata)" + exit 1 + fi + set -- -kernel "$FW" + fi + + # TPM: Windows was installed with one under UTM (BitLocker seals + # against it). swtpm replays the bundle's own tpmdata, so the guest + # sees the very TPM it has always had. swtpm comes from the dev + # shell (flake.nix) or `nix profile install nixpkgs#swtpm`. + TPMDATA="$(dirname "{{.DISK}}")/tpmdata" + if [ "$nested" = "1" ]; then + # QEMU 11.1-rc rejects the TPM's MMIO mapping under EL2 + # (HV_BAD_ARGUMENT in hvf-all.c). This guest's BitLocker has + # Protection Off (clear key), so it boots fine without one. + echo "note: skipping TPM — tpm-tis-device is incompatible with nested virt in this QEMU" + elif command -v swtpm >/dev/null 2>&1 && [ -f "$TPMDATA" ]; then + rm -f "$D/swtpm.sock" + # terminate: swtpm exits by itself when qemu disconnects. + swtpm socket --tpm2 \ + --tpmstate backend-uri=file://"$TPMDATA" \ + --ctrl type=unixio,path="$D/swtpm.sock",terminate \ + --daemon --pid file="$D/swtpm.pid" + for _ in $(seq 1 25); do [ -S "$D/swtpm.sock" ] && break; sleep 0.2; done + # UTM's fork exposes the TPM as CRB; stock qemu on ARM has TIS. + # Windows drives both. + tpmdev=tpm-tis-device + qemu-system-aarch64 -device help 2>/dev/null | grep -q tpm-crb-device && tpmdev=tpm-crb-device + set -- "$@" \ + -chardev socket,id=chrtpm0,path="$D/swtpm.sock" \ + -tpmdev emulator,id=tpm0,chardev=chrtpm0 \ + -device "$tpmdev",tpmdev=tpm0 + else + echo "WARNING: no swtpm or no $TPMDATA — booting without TPM (BitLocker may demand a recovery key)" + fi + + # File share: a samba container on the host serves SHARE_DIR + # read-write; the guest maps it with its native SMB client (the + # net use line is printed at the end). Recreated every start so + # the shared directory always matches SHARE_DIR. + SMB="" + if command -v docker >/dev/null 2>&1; then + docker rm -f devcell-smb >/dev/null 2>&1 || true + if docker run -d --name devcell-smb --restart unless-stopped \ + -p 445:445 -e USER=dmitry -e PASS=rdp -e NAME=devcell \ + -v "{{.SHARE_DIR}}:/storage" dockurr/samba >/dev/null 2>&1; then + SMB=1 + else + echo "WARNING: samba container failed to start — no Z: share this boot" + fi + else + echo "WARNING: docker not found — no SMB share for {{.SHARE_DIR}}" + fi + + # Logs and pidfile must be user-owned BEFORE the sudo'd qemu opens + # them — root only writes into the existing files, so ownership + # stays with the user (the sockets can't be pre-created; those get + # chmod'd after launch). + $SUDO rm -f "$D/serial.log" "$D/qemu.log" "$D/qmp.sock" "$D/qga.sock" + touch "$D/serial.log" "$D/qemu.log" "$PIDFILE" + + # Debug breadcrumb (H1: was fake-el2 actually applied?): the exact + # binary, its version, and the resolved machine/accel/extra argv, + # readable from the devcell container via the bind-mounted .tmp/. + { + echo "date: $(date)" + echo "binary: $(command -v qemu-system-aarch64)" + echo "version: $(qemu-system-aarch64 --version | head -1)" + echo "machine: $machine" + echo "accel: $accel" + echo "nested: $nested" + echo "extra: $*" + } > "$D/launch-info.txt" + + [ -n "$SUDO" ] && echo "vmnet-shared networking needs root: sudo will prompt" + if ! $SUDO qemu-system-aarch64 "$@" \ + -machine "$machine" -cpu "$cpu" -accel "$accel" \ + -smp cpus={{.SMP}},sockets=1,cores={{.SMP}},threads=1 -m {{.MEM}} \ + -drive if=none,media=disk,id=disk0,file="{{.DISK}}",discard=unmap,detect-zeroes=unmap \ + -device nvme,drive=disk0,serial="$serial",bootindex=1 \ + -device virtio-net-pci,mac=72:5B:B2:09:74:68,netdev=net0 \ + -netdev vmnet-shared,id=net0 \ + -device virtio-rng-pci \ + -device virtio-serial \ + -chardev socket,id=qga0,path="$D/qga.sock",server=on,wait=off \ + -device virtserialport,chardev=qga0,name=org.qemu.guest_agent.0 \ + -device nec-usb-xhci,id=usb-bus \ + -device usb-kbd,bus=usb-bus.0 -device usb-tablet,bus=usb-bus.0 \ + {{if ne .VNC "0"}}-object secret,id=vncpw,data=vnc -vnc 127.0.0.1:7,password-secret=vncpw{{else}}-display none{{end}} -device "$dispdev" \ + -rtc base=localtime \ + -serial file:"$D/serial.log" \ + -D "$D/qemu.log" \ + -qmp unix:"$D/qmp.sock",server,nowait \ + -name devcell-debug-windows \ + -daemonize -pidfile "$PIDFILE"; then + # Post-daemonize errors go to -D only; replay them here. + echo "ERROR: qemu failed to start — last lines of $D/qemu.log:" + tail -5 "$D/qemu.log" 2>/dev/null | sed 's/^/ /' + exit 1 + fi + + # The sudo'd qemu writes the pidfile and control sockets as root; + # open them up so the pid lock, IP discovery and the stop task's + # graceful shutdown work unprivileged. + [ -n "$SUDO" ] && $SUDO chmod 644 "$PIDFILE" && $SUDO chmod 666 "$D/qga.sock" "$D/qmp.sock" 2>/dev/null || true + + echo "started (pid $(cat "$PIDFILE")), disk {{.DISK}}, accel {{.ACCEL}}" + [ "{{.ACCEL}}" = "tcg" ] && echo "TCG boot is slow — expect 10-25 min until the guest answers." + + # Block until the guest is genuinely reachable, then report and + # exit — the VM itself stays daemonized. Order: VNC console (up in + # seconds — early eyes on firmware/BitLocker screens), then the + # DHCP lease, then SSH answering. Logs go to their files only (the + # serial line is a VT100 UI, not readable log output). + slow=1; [ "{{.ACCEL}}" = "tcg" ] && slow=20 + + {{if ne .VNC "0"}} + printf "1/4 waiting for the VNC console..." + for _ in $(seq 1 60); do nc -z -G 2 127.0.0.1 5907 2>/dev/null && break; sleep 1; done + echo " up — open vnc://127.0.0.1:5907 (password: vnc)" + {{end}} + + # IP discovery. Authoritative source: qemu-guest-agent inside + # Windows (the UTM guest tools run it) answering over the + # virtio-serial channel. Fallback: bootpd's lease file — but it + # keeps STALE leases for our MAC from earlier boots on other vmnet + # subnets (seen: 192.168.64.43 recorded while the guest actually + # got 192.168.2.2), so lease candidates only count when the live + # ARP entry for that IP shows our MAC. + MAC_LEASE="72:5b:b2:9:74:68" + GUEST_IP="" + # macOS nc -U never surfaces responses from qemu's unix sockets + # (both QMP and qga probes came back empty on live sockets, run + # 20260805) — talk to them with perl instead. + qmsg() { + perl -MIO::Socket::UNIX -e ' + my ($path, @msgs) = @ARGV; + my $s = IO::Socket::UNIX->new(Peer => $path) or exit 1; + my $out = ""; + eval { + local $SIG{ALRM} = sub { die }; + alarm 4; + for my $m (@msgs) { + print $s $m, "\n"; + my $buf = ""; sysread($s, $buf, 65536); $out .= $buf; + } + alarm 0; + }; + print $out; + ' "$@" + } + printf "2/4 discovering the guest IP..." + for _ in $(seq 1 $((90 * slow))); do + resp=$(qmsg "$D/qga.sock" '{"execute":"guest-network-get-interfaces"}' 2>/dev/null || true) + GUEST_IP=$(printf '%s' "$resp" \ + | perl -ne 'while (/"ip-address"\s*:\s*"(\d+\.\d+\.\d+\.\d+)"/g) { print "$1\n" }' 2>/dev/null \ + | grep -Ev '^(127\.|169\.254\.)' | head -1 || true) + [ -n "$GUEST_IP" ] && break + # The Mac IS the vmnet gateway, so its ARP table maps our MAC to + # the live IP as soon as the guest talks — catches the case where + # Windows silently reuses its old lease (no fresh dhcpd_leases + # entry, run 20260805). + # 169.254.* is APIPA — the guest's pre-DHCP self-assignment; it + # lands in the ARP cache but routes nowhere (seen 169.254.81.138 + # while the real lease was still coming, run 20260805). + GUEST_IP=$(arp -an 2>/dev/null | sed -n "s/.*(\([0-9.]*\)) at $MAC_LEASE .*/\1/p" \ + | grep -Ev '^(169\.254\.|127\.)' | head -1 || true) + [ -n "$GUEST_IP" ] && break + for ip in $(awk -v mac="$MAC_LEASE" ' + /ip_address=/ { ip = $0; sub(/.*=/, "", ip) } + /hw_address=/ && index($0, mac) { print ip } + ' /var/db/dhcpd_leases 2>/dev/null | sort -u); do + ping -c 1 -t 1 "$ip" >/dev/null 2>&1 || continue + if arp -n "$ip" 2>/dev/null | grep -qi "$MAC_LEASE"; then GUEST_IP="$ip"; break; fi + done + [ -n "$GUEST_IP" ] && break + sleep 2 + done + if [ -z "$GUEST_IP" ]; then + echo " not found" + echo "ERROR: guest IP not discovered. Diagnostics:" + echo "--- qga raw response (empty = agent silent / nc issue):" + qmsg "$D/qga.sock" '{"execute":"guest-network-get-interfaces"}' 2>&1 | head -c 300 || true + echo "" + echo "--- arp entries for $MAC_LEASE:" + arp -an 2>/dev/null | grep -i "$MAC_LEASE" || echo "(none)" + echo "--- leases for $MAC_LEASE:" + grep -B2 "$MAC_LEASE" /var/db/dhcpd_leases 2>/dev/null || echo "(none)" + exit 1 + fi + echo " $GUEST_IP" + + printf "3/4 waiting for SSH..." + up="" + for _ in $(seq 1 $((150 * slow))); do + if nc -z -G 2 "$GUEST_IP" 22 2>/dev/null; then up=1; break; fi + sleep 2 + done + if [ -z "$up" ]; then + echo " not answering" + echo "ERROR: guest holds $GUEST_IP but SSH never came up — check the VNC console (BitLocker/recovery screen?)" + exit 1 + fi + echo " up" + + # The guest agent powers IP discovery and the stop task's graceful + # shutdown — verify it actually answers (guest-ping -> {"return"}). + # Non-fatal: without it everything still works via ssh/arp. + printf "4/4 checking qemu guest agent..." + GA="" + for _ in $(seq 1 15); do + if qmsg "$D/qga.sock" '{"execute":"guest-ping"}' 2>/dev/null | grep -q '"return"'; then GA=1; break; fi + sleep 2 + done + if [ -n "$GA" ]; then echo " ok"; else echo " SILENT (QEMU-GA service not answering — discovery/shutdown fall back to arp/ssh)"; fi + + echo "VM is up" + {{if ne .VNC "0"}}echo " VNC: open vnc://127.0.0.1:5907 (password: vnc)"{{end}} + echo " RDP: $GUEST_IP:3389 (user dmitry, password rdp)" + echo " SSH: ssh dmitry@$GUEST_IP" + if [ -n "$SMB" ]; then + GW=$(echo "$GUEST_IP" | sed 's/\.[0-9]*$/.1/') + echo " Share: {{.SHARE_DIR}} — in the guest run: net use Z: \\\\$GW\\devcell /user:dmitry rdp" + fi + echo " logs: $D/serial.log, $D/qemu.log" + + debug:windows:stop: + desc: "Stop the QEMU Windows debug VM started by debug:windows:start" + silent: true + vars: + DISK: '{{.DISK | default (printf "%s/test/testdata/Windows.utm/Data/D76FB0BC-D7CC-4481-A6B6-492BEB4D834B.qcow2" .TASKFILE_DIR)}}' + cmds: + - | + set -eu + D="{{.TASKFILE_DIR}}/.tmp" + PIDFILE="$D/qemu-windows.pid" + SUDO=""; [ "$(uname)" = "Darwin" ] && [ "$(id -u)" != "0" ] && SUDO="sudo" + + # macOS nc -U never surfaces responses from qemu's unix sockets — + # talk to QMP/qga with perl (same helper as the start task). + qmsg() { + perl -MIO::Socket::UNIX -e ' + my ($path, @msgs) = @ARGV; + my $s = IO::Socket::UNIX->new(Peer => $path) or exit 1; + my $out = ""; + eval { + local $SIG{ALRM} = sub { die }; + alarm 4; + for my $m (@msgs) { + print $s $m, "\n"; + my $buf = ""; sysread($s, $buf, 65536); $out .= $buf; + } + alarm 0; + }; + print $out; + ' "$@" + } + + # env kill, never bare kill: task's embedded shell (mvdan/sh) has a + # job-control kill builtin that silently no-ops on external pids. + _kill() { # pid + $SUDO env kill "$1" 2>/dev/null || true + for _ in $(seq 1 20); do ps -p "$1" >/dev/null 2>&1 || return 0; sleep 0.5; done + $SUDO env kill -9 "$1" 2>/dev/null || true + sleep 1 + ! ps -p "$1" >/dev/null 2>&1 + } + + stopped=0 + if [ -f "$PIDFILE" ]; then + # A launch that died pre-chmod leaves the pidfile root-owned 0600. + pid=$(cat "$PIDFILE" 2>/dev/null || $SUDO cat "$PIDFILE") + if ps -p "$pid" >/dev/null 2>&1; then + # Graceful first — killing qemu is a power cut: the guest logs + # Event 41 and spends the next boot in crash recovery (that + # WAS the 'boot loop', run 20260805). ~1 min total budget. + # Cleanest: shutdown over SSH — the one channel proven to + # work. BatchMode: succeeds only when the Mac's key is + # authorized in the guest; falls through instantly otherwise. + GIP=$(arp -an 2>/dev/null | sed -n 's/.*(\([0-9.]*\)) at 72:5b:b2:9:74:68 .*/\1/p' | head -1) + if [ -n "$GIP" ] && ps -p "$pid" >/dev/null 2>&1; then + if ssh -o BatchMode=yes -o StrictHostKeyChecking=no -o ConnectTimeout=5 \ + "dmitry@$GIP" "shutdown /s /t 0" >/dev/null 2>&1; then + printf "guest shutdown via ssh — waiting..." + for _ in $(seq 1 15); do ps -p "$pid" >/dev/null 2>&1 || break; sleep 2; done + echo "" + fi + fi + # Then qemu-ga (no JSON response expected; watch the pid). + if [ -S "$D/qga.sock" ] && ps -p "$pid" >/dev/null 2>&1; then + qmsg "$D/qga.sock" '{"execute":"guest-shutdown"}' >/dev/null 2>&1 || true + printf "guest-agent shutdown requested — waiting..." + for _ in $(seq 1 10); do ps -p "$pid" >/dev/null 2>&1 || break; sleep 2; done + echo "" + fi + # Last graceful resort: ACPI power-button via QMP. + if [ -S "$D/qmp.sock" ] && ps -p "$pid" >/dev/null 2>&1; then + resp=$(qmsg "$D/qmp.sock" '{"execute":"qmp_capabilities"}' '{"execute":"system_powerdown"}' 2>/dev/null || true) + if printf '%s' "$resp" | grep -q '"return"'; then + printf "ACPI powerdown sent — waiting for guest shutdown..." + for _ in $(seq 1 10); do ps -p "$pid" >/dev/null 2>&1 || break; sleep 2; done + echo "" + else + echo "QMP powerdown failed (no response) — falling back to kill" + fi + fi + if ! ps -p "$pid" >/dev/null 2>&1; then echo "guest shut down cleanly (pid $pid)"; stopped=1 + elif _kill "$pid"; then echo "stopped qemu (pid $pid) — forced; guest will crash-recover next boot"; stopped=1 + else echo "WARNING: qemu (pid $pid) survived SIGKILL — check 'ps -p $pid'"; fi + fi + $SUDO rm -f "$PIDFILE" + fi + # Fallback: a crashed start left no usable pidfile but qemu still + # holds the disk. + for pid in $(pgrep -f "qemu-system-aarch64.*$(basename "{{.DISK}}")" 2>/dev/null || true); do + if _kill "$pid"; then echo "stopped stray qemu (pid $pid)"; stopped=1; fi + done + # swtpm self-terminates when qemu disconnects; sweep a leftover. + if [ -f "$D/swtpm.pid" ]; then + pid=$(cat "$D/swtpm.pid") + ps -p "$pid" >/dev/null 2>&1 && _kill "$pid" >/dev/null 2>&1 || true + rm -f "$D/swtpm.pid" + fi + # The SMB share container serves no one once the VM is down. + if command -v docker >/dev/null 2>&1; then + docker rm -f devcell-smb >/dev/null 2>&1 && echo "stopped SMB share container" || true + fi + rm -f "$D/qmp.sock" "$D/swtpm.sock" 2>/dev/null || true + [ "$stopped" -eq 1 ] || echo "nothing to stop" + + debug:alpine:start: + desc: "Boot an Alpine ISO under hvf with guest EL2 — sanity test for nested virtualization (vars: ISO, SMP, MEM, NESTED=0 to disable EL2)" + silent: true + vars: + ISO: '{{.ISO | default (printf "%s/.tmp/alpine-virt-aarch64.iso" .TASKFILE_DIR)}}' + SMP: '{{.SMP | default "2"}}' + MEM: '{{.MEM | default "2048"}}' + NESTED: '{{.NESTED | default "1"}}' + # Serial console on tcp: (all interfaces) — interactive, e.g. + # `nc 127.0.0.1 5910`. SERIAL_PORT= (empty) logs to a file instead + # and makes the task block until the login prompt appears there. + SERIAL_PORT: '{{.SERIAL_PORT | default "5910"}}' + preconditions: + - sh: command -v qemu-system-aarch64 >/dev/null + msg: qemu-system-aarch64 not found on PATH + - sh: test -f "{{.ISO}}" + msg: "no ISO at {{.ISO}}" + cmds: + - | + set -euo pipefail + D="{{.TASKFILE_DIR}}/.tmp"; mkdir -p "$D" + PIDFILE="$D/qemu-alpine.pid" + + # Same pid-lock discipline as debug:windows:start ('kill'/'ps' must + # go through env — mvdan/sh has a job-control kill builtin). + if [ -f "$PIDFILE" ] && ps -p "$(cat "$PIDFILE")" >/dev/null 2>&1; then + echo "already running (pid $(cat "$PIDFILE")) — task debug:alpine:stop first" + exit 1 + fi + rm -f "$PIDFILE" + + machine="virt,gic-version=3"; bootfix=""; accel=hvf + if [ "{{.NESTED}}" != "0" ]; then + machine="virt,virtualization=on,gic-version=3" + # EDK2 hangs at 'Start boot option' under hvf nested virt (known + # upstream issue); the zero-delay boot menu skips the hung path. + bootfix="-boot menu=on,splash-time=0" + # H2 litmus: with our patched hvf (fake-el2 VHE emulation) Linux + # must print 'kvm-arm: ... VHE mode initialized' instead of + # nVHE. If dmesg still says nVHE here, the patch isn't + # advertising VHE (ID_AA64MMFR1_EL1.VH) to the guest at all — + # no point retrying Windows. Same binary-scan gate as the + # windows task so a stock QEMU still boots. + if grep -aq fake-el2 "$(command -v qemu-system-aarch64)"; then + accel="hvf,fake-el2=on" + echo "fake-el2=on: check dmesg for 'VHE mode initialized' (vs nVHE)" + fi + fi + + CODE_FD="$(dirname "$(command -v qemu-system-aarch64)")/../share/qemu/edk2-aarch64-code.fd" + if [ ! -f "$CODE_FD" ]; then + echo "ERROR: no EDK2 code image at $CODE_FD" + exit 1 + fi + + rm -f "$D/alpine-serial.log" "$D/alpine-qemu.log" + touch "$D/alpine-serial.log" "$D/alpine-qemu.log" "$PIDFILE" + + # Same breadcrumb as debug:windows:start (H1-style: which binary / + # accel actually ran) — dates the boot, so a stale VM from before a + # qemu rebuild can't masquerade as a fresh litmus result. + { + echo "date: $(date)" + echo "binary: $(command -v qemu-system-aarch64)" + echo "version: $(qemu-system-aarch64 --version | head -1)" + echo "machine: $machine" + echo "accel: $accel" + } > "$D/alpine-launch-info.txt" + serialdev="file:$D/alpine-serial.log" + [ -n "{{.SERIAL_PORT}}" ] && serialdev="tcp:0.0.0.0:{{.SERIAL_PORT}},server=on,wait=off" + + # No network, no sudo — this VM exists only to answer one question: + # does a guest boot with EL2 under hvf on this host/QEMU? + if ! qemu-system-aarch64 \ + -machine "$machine" -cpu host -accel "$accel" \ + -smp {{.SMP}} -m {{.MEM}} \ + -drive if=pflash,format=raw,readonly=on,file="$CODE_FD" \ + $bootfix \ + -cdrom "{{.ISO}}" \ + -display none \ + -serial "$serialdev" \ + -D "$D/alpine-qemu.log" \ + -name devcell-debug-alpine \ + -daemonize -pidfile "$PIDFILE"; then + echo "ERROR: qemu failed to start — last lines of $D/alpine-qemu.log:" + tail -5 "$D/alpine-qemu.log" 2>/dev/null | sed 's/^/ /' + exit 1 + fi + echo "started (pid $(cat "$PIDFILE")), machine $machine" + + # Success = the login prompt on serial. The kernel's entry + # exception level ("CPU: All CPU(s) started at ELx") is the + # nested-virt verdict when it's visible — Alpine boots with + # 'quiet', so its absence proves nothing. + if [ -n "{{.SERIAL_PORT}}" ]; then + echo "serial console on tcp port {{.SERIAL_PORT}} — connect with: nc 127.0.0.1 {{.SERIAL_PORT}}" + echo "stop with: task debug:alpine:stop" + exit 0 + fi + printf "waiting for the boot (up to 120s)..." + up="" + for _ in $(seq 1 60); do + grep -q "login:" "$D/alpine-serial.log" 2>/dev/null && up=1 && break + sleep 2 + done + echo "" + if [ -z "$up" ]; then + echo "no login prompt after 120s — guest never booted. Tail of serial:" + tail -5 "$D/alpine-serial.log" | sed 's/^/ /' + exit 1 + fi + echo " guest is up (login prompt on serial)" + # H2 verdict, printed inline: entry EL + which Hyp mode KVM chose. + # 'VHE mode initialized' -> fake-el2 advertises VHE, Windows-worthy; + # 'nVHE mode initialized' -> guest still reads ID_AA64MMFR1_EL1.VH=0 + # (ID regs aren't trapped at EL1) — fix + # belongs in hvf's cached ID registers. + grep -iE "started at EL|CPU features|kvm.*(VHE|nVHE|mode initialized)" \ + "$D/alpine-serial.log" 2>/dev/null | sed 's/^/ /' || true + if grep -q "Hyp VHE mode initialized" "$D/alpine-serial.log" 2>/dev/null; then + echo " VERDICT: VHE — the fake-el2 patch presents VHE to the guest" + elif grep -qi "nVHE mode initialized" "$D/alpine-serial.log" 2>/dev/null; then + echo " VERDICT: nVHE — guest still sees ID_AA64MMFR1_EL1.VH=0; Windows will keep failing" + fi + echo " serial log: $D/alpine-serial.log" + echo " stop with: task debug:alpine:stop" + + debug:alpine:stop: + desc: "Stop the Alpine nested-virt test VM" + silent: true + cmds: + - | + set -eu + D="{{.TASKFILE_DIR}}/.tmp" + PIDFILE="$D/qemu-alpine.pid" + _kill() { # pid — env kill: mvdan/sh's builtin no-ops on external pids + env kill "$1" 2>/dev/null || true + for _ in $(seq 1 10); do ps -p "$1" >/dev/null 2>&1 || return 0; sleep 0.5; done + env kill -9 "$1" 2>/dev/null || true + sleep 1 + ! ps -p "$1" >/dev/null 2>&1 + } + stopped=0 + if [ -f "$PIDFILE" ]; then + pid=$(cat "$PIDFILE" 2>/dev/null || true) + if [ -n "$pid" ] && ps -p "$pid" >/dev/null 2>&1; then + # A live-CD guest holds no state worth a graceful shutdown. + if _kill "$pid"; then echo "stopped qemu (pid $pid)"; stopped=1; fi + fi + rm -f "$PIDFILE" + fi + for pid in $(pgrep -f "qemu-system-aarch64.*devcell-debug-alpine" 2>/dev/null || true); do + if _kill "$pid"; then echo "stopped stray qemu (pid $pid)"; stopped=1; fi + done + [ "$stopped" -eq 1 ] || echo "nothing to stop" + + debug:qemu: + desc: "One-shot QEMU fake-el2/VHE diagnostic report (run on the Mac): binary provenance, patch content, litmus verdicts, guest event log. Vars: PATCH (path to hvf-vhe-emulation.patch), GUEST_IP" + silent: true + vars: + # The nixhome repo's patch — override when it lives elsewhere: + # task debug:qemu PATCH=/path/to/hvf-vhe-emulation.patch + PATCH: '{{.PATCH | default ""}}' + GUEST_IP: '{{.GUEST_IP | default "192.168.2.2"}}' + cmds: + - | + set -u + D="{{.TASKFILE_DIR}}/.tmp" + # Persist the report for the /continue-debug loop: full history in + # .scratch/debug/, always-current copy at .tmp/debug-qemu.txt (both + # bind-mounted into the devcell container). + RPT_DIR="{{.TASKFILE_DIR}}/.scratch/debug"; mkdir -p "$RPT_DIR" + RPT="$RPT_DIR/qemu-$(date +%Y%m%dT%H%M%S).log" + sect() { printf '\n===== %s =====\n' "$1"; } + # GNU stat vs BSD stat: uname is NOT a reliable discriminator — a + # nix profile puts GNU coreutils first on PATH even on macOS (seen + # live: 'stat -f %Sm' dumped filesystem info). Try GNU syntax + # first, fall back to BSD. + fmtime() { + stat -c '%y' "$1" 2>/dev/null || stat -f '%Sm' "$1" 2>/dev/null || echo "unknown" + } + + report() { + # ---- 1. Binary provenance (H1-class: WHICH qemu would run?) ---- + sect "QEMU BINARY" + BIN="$(command -v qemu-system-aarch64 || true)" + if [ -z "$BIN" ]; then + echo "FAIL: qemu-system-aarch64 not on PATH" + else + echo "path: $BIN" + # readlink -f resolves the nix profile indirection to the store + # path — this is the line that changes when a rebuild actually + # landed (same store hash = same binary, whatever anyone claims). + echo "store path: $(readlink -f "$BIN" 2>/dev/null || echo 'unresolvable')" + echo "version: $("$BIN" --version | head -1)" + # Nix normalizes store mtimes to epoch+1 — the store HASH is the + # only build-identity signal. Reports are persisted; diff the + # 'store path' line against the previous report to prove a + # rebuild actually landed. + if grep -aq fake-el2 "$(readlink -f "$BIN")" 2>/dev/null; then + echo "fake-el2: PRESENT in binary" + else + echo "fake-el2: ABSENT — stock build, nothing to test" + fi + fi + + # ---- 2. The patch itself (does it touch the ID regs?) ---- + sect "VHE PATCH CONTENT" + P="{{.PATCH}}" + if [ -z "$P" ]; then + # Plain if/break — a failing `[ -f ] && ...` list would trip + # task's errexit on the last miss and kill the whole report. + for c in \ + "$HOME/dev/dimmkirr/nixhome/home/dmitry/packages/patches/hvf-vhe-emulation.patch" \ + "$HOME/nixhome/home/dmitry/packages/patches/hvf-vhe-emulation.patch"; do + if [ -f "$c" ]; then P="$c"; break; fi + done + # Known locations missed — hunt for it (bounded depth, quick). + if [ -z "$P" ]; then + P="$(find "$HOME/dev" "$HOME/src" "$HOME/nixhome" \ + -maxdepth 7 -name hvf-vhe-emulation.patch -print 2>/dev/null | head -1 || true)" + if [ -n "$P" ]; then echo "(auto-found via find: $P)"; fi + fi + fi + if [ -z "$P" ] || [ ! -f "$P" ]; then + echo "patch not found — pass PATCH=/path/to/hvf-vhe-emulation.patch" + else + echo "patch: $P ($(wc -l < "$P" | tr -d ' ') lines, mtime $(fmtime "$P"))" + echo "--- files touched ---" + grep -E '^\+\+\+ ' "$P" | sed 's/^/ /' + echo "--- VH / ID-reg hunks (NMD-256 blocker: must set ID_AA64MMFR1_EL1.VH=1) ---" + if grep -nE 'ID_AA64MMFR1|AA64MMFR1_EL1|, VH,|FIELD_DP64' "$P" | head -20 | sed 's/^/ /' | grep .; then :; else + echo " NONE — patch never touches the ID registers; guest will read VH=0 (nVHE)" + fi + fi + + # ---- 3. Last recorded launches (what actually ran) ---- + sect "LAST LAUNCHES (.tmp breadcrumbs)" + for f in "$D/launch-info.txt" "$D/alpine-launch-info.txt"; do + if [ -f "$f" ]; then + echo "--- $(basename "$f") ---" + sed 's/^/ /' "$f" + else + echo "--- $(basename "$f"): none ---" + fi + done + + # ---- 4. Alpine litmus verdict (H2: does the guest see VHE?) ---- + sect "ALPINE LITMUS VERDICT" + if [ -s "$D/alpine-serial.log" ]; then + grep -aiE "started at EL|Virtualization Host|VHE|nVHE" "$D/alpine-serial.log" | head -8 | sed 's/^/ /' + if grep -aq "Hyp VHE mode initialized" "$D/alpine-serial.log"; then + echo " VERDICT: VHE — fake-el2 presents VHE to the guest" + elif grep -aqi "nVHE mode initialized" "$D/alpine-serial.log"; then + echo " VERDICT: nVHE — guest still reads ID_AA64MMFR1_EL1.VH=0" + else + echo " VERDICT: inconclusive (no kvm mode line in log)" + fi + else + echo "no file-mode serial log. Interactive VM running?" + if [ -f "$D/qemu-alpine.pid" ] && ps -p "$(cat "$D/qemu-alpine.pid")" >/dev/null 2>&1; then + echo " yes (pid $(cat "$D/qemu-alpine.pid")) — serial is on tcp; for a self-reporting run:" + fi + echo " task debug:alpine:stop && task debug:alpine:start SERIAL_PORT=" + fi + + # ---- 5. Windows guest verdict (H3: did the hypervisor launch?) ---- + sect "WINDOWS GUEST (Event 43 check)" + if ssh -o BatchMode=yes -o StrictHostKeyChecking=no -o ConnectTimeout=5 \ + "dmitry@{{.GUEST_IP}}" "wevtutil qe System /q:\"*[System[Provider[@Name='Microsoft-Windows-Hyper-V-Hypervisor']]]\" /c:2 /rd:true /f:text" \ + 2>/dev/null | tr -d '\0\r' | grep -E "Date:|Event ID:|present|failed|successfully" | sed 's/^/ /' | grep .; then + : + else + echo " guest not reachable at {{.GUEST_IP}} (VM down, or key not authorized) — skipped" + fi + + sect "NEXT STEP" + echo "fake-el2 ABSENT -> rebuild/install the patched qemu (store path must change)" + echo "no VH hunk in patch -> fix is in the wrong layer; see NMD-256 'Field evidence'" + echo "VERDICT: nVHE -> VH bit still not advertised; re-check patch + rebuild" + echo "VERDICT: VHE -> task debug:windows:stop && task debug:windows:start SECURE=0" + echo "Event 43 gone on new boot -> hypervisor is up; proceed to WSL2/nixos-import" + } + report 2>&1 | tee "$RPT" "$D/debug-qemu.txt" + printf '\nreport saved: %s\n' "$RPT" + + debug:windows:status: + desc: "Snapshot the debug VM + guest state into .scratch/debug/windows-vm-.log (run while the VM is up; one ssh password prompt: rdp)" + silent: true + vars: + IP: '{{.IP | default ""}}' + cmds: + - | + set -euo pipefail + D="{{.TASKFILE_DIR}}/.tmp" + LOG="{{.TASKFILE_DIR}}/.scratch/debug/windows-vm-$(date -u +%Y%m%dT%H%M%SZ).log" + mkdir -p "$(dirname "$LOG")" + + { echo "===== HOST: qemu process =====" + pgrep -fl qemu-system-aarch64 || echo "no qemu running" + echo; echo "===== HOST: QMP status =====" + printf '{"execute":"qmp_capabilities"}\n{"execute":"query-status"}\n' \ + | nc -U -w 2 "$D/qmp.sock" 2>&1 || echo "qmp unavailable" + echo; echo "===== HOST: serial tail (boot manager entries = boot count) =====" + tr -d '\000-\010\013-\037' < "$D/serial.log" 2>/dev/null | sed 's/\[[0-9;=]*[A-Za-z]//g' | grep -a BdsDxe || true + } > "$LOG" 2>&1 + + # Guest IP: explicit IP= var, else ask the guest agent. + GUEST_IP="{{.IP}}" + if [ -z "$GUEST_IP" ]; then + GUEST_IP=$(printf '{"execute":"guest-network-get-interfaces"}\n' \ + | nc -U -w 2 "$D/qga.sock" 2>/dev/null \ + | perl -ne 'while (/"ip-address"\s*:\s*"(\d+\.\d+\.\d+\.\d+)"/g) { print "$1\n" }' \ + | grep -Ev '^(127\.|169\.254\.)' | head -1 || true) + fi + if [ -z "$GUEST_IP" ]; then + echo "guest IP unknown (agent silent) — pass IP="; echo "partial log: $LOG"; exit 1 + fi + echo "guest: $GUEST_IP — one ssh password prompt (rdp) collects everything" + + # One ssh session, one prompt. Each block is tagged with the + # hypothesis it confirms or refutes: + # H1 BitLocker/TPM blocked TPM-less boots -> manage-bde, Get-Tpm, BitLocker events + # H2 Automatic Repair after unclean kills -> System events 41/6008 + # H3 benign reboot (updates/PnP installs) -> System event 1074 + boot time + ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 "dmitry@$GUEST_IP" ' + echo ===== GUEST: identity/boot ===== & ver & systeminfo | findstr /i /c:"Boot Time" & + echo ===== H1: BitLocker status ===== & manage-bde -status C: 2>&1 & + echo ===== H1: TPM state ===== & powershell -NoProfile -Command "Get-Tpm | Format-List TpmPresent,TpmReady,TpmEnabled 2>&1" & + echo ===== H1: BitLocker events ===== & wevtutil qe "Microsoft-Windows-BitLocker/BitLocker Management" /c:5 /rd:true /f:text 2>&1 & + echo ===== H2: unclean shutdowns (41/6008) ===== & wevtutil qe System "/q:*[System[(EventID=41) or (EventID=6008)]]" /c:5 /rd:true /f:text 2>&1 & + echo ===== H3: orderly restarts (1074, who asked) ===== & wevtutil qe System "/q:*[System[(EventID=1074)]]" /c:5 /rd:true /f:text 2>&1 & + echo ===== WSL: engine + distros + virt capability ===== & set WSL_UTF8=1 & wsl.exe --version 2>&1 & wsl.exe --list --verbose 2>&1 & + powershell -NoProfile -Command "(Get-CimInstance Win32_ComputerSystem).HypervisorPresent; (Get-CimInstance Win32_Processor).VirtualizationFirmwareEnabled" + ' >> "$LOG" 2>&1 || echo "guest collection incomplete (see log)" + + echo "saved: $LOG" + # ── Host Nix installation debugging ────────────────────────────────── debug:nix: desc: Collect comprehensive info about the host Nix/Lix installation (run on macOS) platforms: [darwin] silent: true vars: - LOG: '{{.TASKFILE_DIR}}/.context/debug/nix.log' + LOG: '{{.TASKFILE_DIR}}/.scratch/debug/nix.log' cmds: - | set -euo pipefail @@ -747,7 +1914,7 @@ tasks: readlink -f "$(which darwin-rebuild 2>/dev/null)" 2>/dev/null || true section "NIXHOME REPO" - for d in "{{.ROOT_DIR}}/.context/examples/nixhome" ~/dev/dimmkirr/nixhome ~/dev/n0mad/nixhome ~/nixhome ~/.config/nixhome; do + for d in "{{.ROOT_DIR}}/.scratch/examples/nixhome" ~/dev/dimmkirr/nixhome ~/dev/n0mad/nixhome ~/nixhome ~/.config/nixhome; do if [ -d "$d" ]; then echo " found: $d" ls -la "$d/" | head -15 @@ -766,7 +1933,7 @@ tasks: break fi done - echo " (searched .context/examples/nixhome, ~/dev/dimmkirr/nixhome, ~/dev/n0mad/nixhome, ~/nixhome, ~/.config/nixhome)" + echo " (searched .scratch/examples/nixhome, ~/dev/dimmkirr/nixhome, ~/dev/n0mad/nixhome, ~/nixhome, ~/.config/nixhome)" section "/etc/ MODIFICATION ARCHAEOLOGY" echo "Goal: determine if Nix installer used mv (rename) or append (tee/echo >>)" @@ -870,7 +2037,7 @@ tasks: echo "Cross-referencing nixhome config with actual /etc/ state" echo "" NIXHOME="" - for d in "{{.ROOT_DIR}}/.context/examples/nixhome" ~/dev/dimmkirr/nixhome ~/dev/n0mad/nixhome ~/nixhome ~/.config/nixhome; do + for d in "{{.ROOT_DIR}}/.scratch/examples/nixhome" ~/dev/dimmkirr/nixhome ~/dev/n0mad/nixhome ~/nixhome ~/.config/nixhome; do [ -d "$d/hosts" ] && NIXHOME="$d" && break done if [ -n "$NIXHOME" ]; then @@ -892,7 +2059,7 @@ tasks: fi else echo " nixhome repo not found" - echo " (searched .context/examples/nixhome, ~/dev/dimmkirr/nixhome, ~/dev/n0mad/nixhome, ~/nixhome, ~/.config/nixhome)" + echo " (searched .scratch/examples/nixhome, ~/dev/dimmkirr/nixhome, ~/dev/n0mad/nixhome, ~/nixhome, ~/.config/nixhome)" fi section "TCC / FDA CONTEXT" @@ -945,3 +2112,191 @@ tasks: _dump 2>&1 | tee "{{.LOG}}" echo "" echo "saved to: {{.LOG}}" + + # ── Colima / thin-build resource debugging ──────────────────────────── + # Targets the CELL-359 builder-ceiling work. The npm ci "Killed" (exit 137, + # cgroup OOM) root cause is already settled: --memory 8g + `max-jobs = auto` + # (= nproc, which --cpus does NOT lower) gave 8 concurrent derivations ~1 GiB + # each. What is NOT settled is the three hypotheses below, each of which can + # still break a build on Colima after that fix. + debug:colima: + desc: Dump Colima/Docker daemon capacity, context wiring, and the resource ceilings the thin build will actually emit + platforms: [darwin] + silent: true + vars: + LOG: '{{.TASKFILE_DIR}}/.scratch/debug/colima.log' + # Pin the Colima daemon explicitly so this task probes Colima while the + # ambient docker CLI keeps pointing at whatever else is in use (Docker + # Desktop). Override for a non-"default" instance: + # task debug:colima COLIMA_HOST=unix://$HOME/.colima//docker.sock + COLIMA_HOST: 'unix://{{.HOME}}/.colima/default/docker.sock' + cmds: + - | + set -uo pipefail + mkdir -p "$(dirname "{{.LOG}}")" + + _dump() { + section() { echo ""; echo "===== $1 ====="; } + item() { echo "--- $1 ---"; } + + COLIMA_HOST='{{.COLIMA_HOST}}' + COLIMA_SOCK="${COLIMA_HOST#unix://}" + # Ambient wiring, captured BEFORE anything is pinned — this is what + # `cell` actually inherits, and half of H1's evidence. + AMBIENT_DOCKER_HOST="${DOCKER_HOST:-}" + + section "PROVENANCE" + echo " date: $(date -u +%Y-%m-%dT%H:%M:%SZ)" + echo " cell: $(cell --version 2>&1 | head -1 || echo 'not found')" + echo " commit: $(git -C '{{.TASKFILE_DIR}}' describe --always --dirty 2>/dev/null || echo unknown)" + echo " colima host: $COLIMA_HOST" + echo " socket: $(test -S "$COLIMA_SOCK" && echo 'EXISTS (socket)' || echo 'MISSING — is colima running?')" + if ! test -S "$COLIMA_SOCK"; then + echo "" + echo " !! $COLIMA_SOCK is not a socket. Colima probes below will fail." + echo " !! Start it with 'colima start', or pass COLIMA_HOST=... for another instance." + fi + + # ── H1: docker context mismatch ─────────────────────────────────── + # Hypothesis: probeDockerCapacity() shells out to a bare `docker info`, + # so if `cell`'s context differs from the one the probe resolves, the + # ceilings are computed against the WRONG daemon. A --cpus larger than + # the real daemon's CPU count makes dockerd exit 125 outright. + # CONFIRM: `docker context show` != the daemon `docker info` reports, + # or DOCKER_HOST set to something other than the active context. + # REFUTE: exactly one context, no DOCKER_HOST, Name == colima. + section "H1: DOCKER CONTEXT WIRING" + item "ambient env (what cell inherits — NOT pinned)" + echo " DOCKER_HOST=$AMBIENT_DOCKER_HOST" + echo " DOCKER_CONTEXT=${DOCKER_CONTEXT:-}" + item "docker context ls" + docker context ls 2>&1 || echo " docker context ls failed" + item "docker context show" + docker context show 2>&1 || echo " failed" + + # The core H1 comparison: the daemon the AMBIENT CLI resolves vs. the + # daemon Colima actually is. probeDockerCapacity() runs a bare + # `docker info`, so it sees the ambient one — if these two rows differ, + # the ceilings are computed against the wrong daemon. + item "AMBIENT docker info (this is what clampBuildLimits probes)" + env -u DOCKER_HOST docker info \ + --format ' Name={{`{{.Name}}`}} NCPU={{`{{.NCPU}}`}} MemTotal={{`{{.MemTotal}}`}} Server={{`{{.ServerVersion}}`}} Driver={{`{{.Driver}}`}}' 2>&1 | head -3 \ + || echo " ambient docker info failed" + item "COLIMA docker info (pinned to $COLIMA_HOST)" + DOCKER_HOST="$COLIMA_HOST" docker info \ + --format ' Name={{`{{.Name}}`}} NCPU={{`{{.NCPU}}`}} MemTotal={{`{{.MemTotal}}`}} Server={{`{{.ServerVersion}}`}} Driver={{`{{.Driver}}`}}' 2>&1 | head -3 \ + || echo " colima docker info failed" + item "VERDICT: do ambient and colima resolve to the same daemon?" + _amb=$(env -u DOCKER_HOST docker info --format '{{`{{.Name}}`}}/{{`{{.NCPU}}`}}/{{`{{.MemTotal}}`}}' 2>/dev/null) + _col=$(DOCKER_HOST="$COLIMA_HOST" docker info --format '{{`{{.Name}}`}}/{{`{{.NCPU}}`}}/{{`{{.MemTotal}}`}}' 2>/dev/null) + echo " ambient: ${_amb:-}" + echo " colima: ${_col:-}" + if [ -n "$_amb" ] && [ -n "$_col" ] && [ "$_amb" = "$_col" ]; then + echo " => SAME daemon. H1 REFUTED." + else + echo " => DIFFERENT daemons (or one unreachable). H1 SUPPORTED:" + echo " cell's ceilings are sized against 'ambient', not colima." + fi + item "docker info per context (does capacity differ between them?)" + for ctx in $(docker context ls --format '{{`{{.Name}}`}}' 2>/dev/null); do + printf ' %-18s ' "$ctx" + docker --context "$ctx" info --format 'Name={{`{{.Name}}`}} NCPU={{`{{.NCPU}}`}} MemTotal={{`{{.MemTotal}}`}}' 2>&1 | head -1 + done + item "socket paths" + ls -la "$COLIMA_SOCK" 2>/dev/null || echo " no colima docker.sock at $COLIMA_SOCK" + ls -la ~/.docker/run/docker.sock 2>/dev/null || echo " no docker-desktop user sock" + ls -la /var/run/docker.sock 2>/dev/null || echo " no /var/run/docker.sock" + + # ── Everything below measures COLIMA specifically ───────────────── + # Ambient wiring is already recorded above, so pin from here on. This + # is what lets the task debug Colima while normal docker use continues + # against another daemon. + export DOCKER_HOST="$COLIMA_HOST" + echo "" + echo " >>> DOCKER_HOST pinned to $DOCKER_HOST for all sections below <<<" + + # ── H2: VM disk exhaustion ──────────────────────────────────────── + # Hypothesis: the memory fix unblocks the build only to hit a full VM + # disk. A prior run reported "62.7 GB total, 59.5 GB used, 0.0 GB + # available" with 45 GB in local volumes — a nix build needs tens of GB + # in the shared /nix volume. + # CONFIRM: Avail on the VM's / (or /var/lib/docker) under ~20 GB. + # REFUTE: ample free space AND the nix-store volume well under quota. + section "H2: VM DISK CAPACITY" + item "colima list (disk column)" + colima list 2>&1 || echo " colima not installed / not running" + item "df inside the VM" + colima ssh -- df -h 2>&1 | head -20 || echo " colima ssh failed" + item "df for the docker data root specifically" + colima ssh -- df -h /var/lib/docker 2>&1 || echo " colima ssh failed" + item "docker system df -v (volume + build cache breakdown)" + docker system df -v 2>&1 | head -40 || echo " docker system df failed" + item "nix-store volume size" + docker system df -v 2>&1 | grep -iE 'nix|VOLUME NAME' || echo " no nix volume found" + item "docker builder cache" + docker builder du 2>&1 | tail -5 || echo " docker builder du unsupported" + + # ── H3: VM memory/swap under-provisioned vs. expectation ────────── + # Hypothesis: the VM has less RAM than assumed and/or no swap. Lima VMs + # are swapless by default, so --memory is a HARD ceiling. If the VM is + # the stock 2 GiB rather than 8 GiB, the 4g default is >= VM total and + # gets dropped entirely — the build then runs uncapped and dies to the + # VM-wide OOM killer instead, with a less legible failure. + # CONFIRM: MemTotal < 8 GiB, or SwapTotal 0 with a tight ceiling. + # REFUTE: MemTotal ~8 GiB and the emitted ceiling below it. + section "H3: VM MEMORY & SWAP" + item "colima ssh -- free -m" + colima ssh -- free -m 2>&1 || echo " colima ssh failed" + item "VM /proc/meminfo (MemTotal / MemAvailable / Swap)" + colima ssh -- grep -E 'MemTotal|MemAvailable|SwapTotal|SwapFree' /proc/meminfo 2>&1 || echo " colima ssh failed" + item "VM cgroup version + root memory.max" + colima ssh -- sh -c 'stat -fc %T /sys/fs/cgroup; cat /sys/fs/cgroup/memory.max 2>/dev/null' 2>&1 || echo " colima ssh failed" + item "VM nproc" + colima ssh -- nproc 2>&1 || echo " colima ssh failed" + item "colima config (requested vs actual)" + cat ~/.colima/default/colima.yaml 2>/dev/null | grep -E '^(cpu|memory|disk|vmType|mountType|arch):' || echo " no colima.yaml" + + # ── What the fix actually emits ─────────────────────────────────── + # Not a hypothesis — the ground truth for all three. Prints the real + # ceilings and nix concurrency for the daemon in force, so the log + # records what the builder was told rather than what we assume. + section "EMITTED BUILD CEILINGS (ground truth)" + item "DEVCELL_* overrides in the environment" + env | grep -E '^DEVCELL_(BUILD|NIX)_' || echo " none set — defaults apply" + # --dry-run is documented as "print docker run argv and exit without + # running" (cmd/root.go). It is wired for the tart/qemu/vagrant builders; + # if the thin/docker path does not honour it yet this prints a real build + # instead, so it is guarded by a timeout and --debug supplies the daemon + # diagnostics (logDockerDiagnostics) regardless. + item "cell build --dry-run --debug (argv + docker diagnostics)" + ( DEVCELL_STACK="${DEVCELL_STACK:-base}" \ + timeout 90 cell build --dry-run --debug 2>&1 \ + | grep -viE '^(copying|building) path' \ + | head -60 ) || echo " (--dry-run not honoured by the thin path, or timed out — see note in task)" + item "grep the emitted ceilings out of that output" + ( DEVCELL_STACK="${DEVCELL_STACK:-base}" \ + timeout 90 cell build --dry-run --debug 2>&1 \ + | grep -oE '\-\-(memory|cpus) [^ ]+|DEVCELL_NIX_(MAX_JOBS|CORES)=[0-9]+' \ + | sort -u ) || echo " none captured" + echo " expected with defaults: --memory = 3/4 of daemon RAM, no --cpus," + echo " DEVCELL_NIX_MAX_JOBS = ceiling/8GiB, DEVCELL_NIX_CORES = ncpu/max-jobs" + echo " (e.g. 24 GiB / 8-CPU daemon: --memory 18g MAX_JOBS=2 CORES=4)" + echo " if --memory is ABSENT, the daemon probe failed or ceiling opted out" + + # ── Prior OOM evidence, for regression comparison ───────────────── + section "RECENT OOM / EXIT-137 EVIDENCE" + item "VM dmesg OOM kills" + colima ssh -- sh -c 'dmesg 2>/dev/null | grep -iE "out of memory|oom-kill|killed process" | tail -20' 2>&1 || echo " no dmesg access" + item "exited containers killed by OOM" + docker ps -a --filter 'status=exited' --format '{{`{{.Names}}`}}\t{{`{{.Status}}`}}' 2>&1 | head -15 || true + for c in $(docker ps -aq 2>/dev/null | head -20); do + docker inspect "$c" --format '{{`{{.Name}}`}} OOMKilled={{`{{.State.OOMKilled}}`}} ExitCode={{`{{.State.ExitCode}}`}}' 2>/dev/null + done | grep -E 'OOMKilled=true|ExitCode=137' || echo " no OOM-killed containers retained" + + section "DONE" + echo "Paste this output back to /continue-debug to narrow the hypotheses." + } + + _dump 2>&1 | tee "{{.LOG}}" + echo "" + echo "saved to: {{.LOG}}" diff --git a/cmd/auth_kube.go b/cmd/auth_kube.go index a31e8d9..5a517ea 100644 --- a/cmd/auth_kube.go +++ b/cmd/auth_kube.go @@ -6,6 +6,7 @@ import ( "time" authkube "github.com/DimmKirr/devcell/internal/auth/kube" + "github.com/DimmKirr/devcell/internal/telemetry" "github.com/DimmKirr/devcell/internal/ux" "github.com/spf13/cobra" ) @@ -33,6 +34,8 @@ will both use it transparently. Requires kubectl on the host PATH.`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + telemetry.Track("auth_kube", map[string]any{"skip_cluster": authKubeSkipCluster}) + var source string if len(args) == 1 { source = args[0] diff --git a/cmd/behavior_test.go b/cmd/behavior_test.go index a11e214..8cee2fe 100644 --- a/cmd/behavior_test.go +++ b/cmd/behavior_test.go @@ -28,7 +28,7 @@ func buildBehaviourArgv(cwd string, envPairs []string, binary string, defaultFla // Scenario A: cwd=/tmp/myproject, TMUX_PANE=%3 func TestScenarioA_ContainerNameAndVNC(t *testing.T) { - guiCfg := cfg.CellConfig{Cell: cfg.CellSection{GUI: ptrBool(true)}} + guiCfg := cfg.CellConfig{GUI: cfg.GUISection{Enabled: ptrBool(true)}} argv := buildBehaviourArgv("/tmp/myproject", []string{"TMUX_PANE", "%3"}, "claude", []string{"--dangerously-skip-permissions"}, nil, guiCfg) @@ -42,7 +42,7 @@ func TestScenarioA_ContainerNameAndVNC(t *testing.T) { // Scenario B: two panes — names and VNC ports differ func TestScenarioB_TwoPanesNamesAndPortsDiffer(t *testing.T) { - guiCfg := cfg.CellConfig{Cell: cfg.CellSection{GUI: ptrBool(true)}} + guiCfg := cfg.CellConfig{GUI: cfg.GUISection{Enabled: ptrBool(true)}} argv3 := buildBehaviourArgv("/tmp/myproject", []string{"TMUX_PANE", "%3"}, "claude", nil, nil, guiCfg) argv4 := buildBehaviourArgv("/tmp/myproject", []string{"TMUX_PANE", "%4"}, @@ -104,7 +104,7 @@ func hasConsecutive(argv []string, a, b string) bool { // Scenario: GUI=true publishes both VNC and RDP ports func TestScenarioA_RDPPortPublished(t *testing.T) { - guiCfg := cfg.CellConfig{Cell: cfg.CellSection{GUI: ptrBool(true)}} + guiCfg := cfg.CellConfig{GUI: cfg.GUISection{Enabled: ptrBool(true)}} argv := buildBehaviourArgv("/tmp/myproject", []string{"TMUX_PANE", "%3"}, "claude", nil, nil, guiCfg) @@ -136,7 +136,7 @@ func TestScenarioA_ConfigDirVolume(t *testing.T) { // Scenario: [ports].publish_ip prefixes -p for VNC, RDP, and forward entries. func TestPublishIP_PrefixesAllPublishedPorts(t *testing.T) { c := cfg.CellConfig{ - Cell: cfg.CellSection{GUI: ptrBool(true)}, + GUI: cfg.GUISection{Enabled: ptrBool(true)}, Ports: cfg.PortsSection{ PublishIP: "0.0.0.0", Forward: []string{"3000", "8080:3000"}, @@ -163,7 +163,7 @@ func TestPublishIP_PrefixesAllPublishedPorts(t *testing.T) { // from other hosts regardless of dockerd's bind default. func TestPublishIP_EmptyDefaultsToAllInterfaces(t *testing.T) { c := cfg.CellConfig{ - Cell: cfg.CellSection{GUI: ptrBool(true)}, + GUI: cfg.GUISection{Enabled: ptrBool(true)}, Ports: cfg.PortsSection{Forward: []string{"3000"}}, } argv := buildBehaviourArgv("/tmp/myproject", []string{"TMUX_PANE", "%3"}, @@ -180,7 +180,7 @@ func TestPublishIP_EmptyDefaultsToAllInterfaces(t *testing.T) { // Scenario: explicit publish_ip="127.0.0.1" overrides the default for loopback-only binding. func TestPublishIP_LoopbackOverride(t *testing.T) { c := cfg.CellConfig{ - Cell: cfg.CellSection{GUI: ptrBool(true)}, + GUI: cfg.GUISection{Enabled: ptrBool(true)}, Ports: cfg.PortsSection{PublishIP: "127.0.0.1", Forward: []string{"3000"}}, } argv := buildBehaviourArgv("/tmp/myproject", []string{"TMUX_PANE", "%3"}, @@ -195,7 +195,7 @@ func TestPublishIP_LoopbackOverride(t *testing.T) { } func TestScenarioA_RDPPortNotPublishedWithoutGUI(t *testing.T) { - noGUI := cfg.CellConfig{Cell: cfg.CellSection{GUI: ptrBool(false)}} + noGUI := cfg.CellConfig{GUI: cfg.GUISection{Enabled: ptrBool(false)}} argv := buildBehaviourArgv("/tmp/myproject", []string{"TMUX_PANE", "%3"}, "claude", nil, nil, noGUI) diff --git a/cmd/build.go b/cmd/build.go index 97e96af..c089cd7 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -9,6 +9,7 @@ import ( "os/exec" "os/signal" "path/filepath" + "strconv" "strings" "syscall" @@ -16,6 +17,7 @@ import ( "github.com/DimmKirr/devcell/internal/config" "github.com/DimmKirr/devcell/internal/runner" "github.com/DimmKirr/devcell/internal/scaffold" + "github.com/DimmKirr/devcell/internal/telemetry" "github.com/DimmKirr/devcell/internal/ux" "github.com/DimmKirr/devcell/internal/version" "github.com/spf13/cobra" @@ -29,78 +31,28 @@ var buildCmd = &cobra.Command{ func init() { buildCmd.Flags().Bool("update", false, "update nix flake inputs and rebuild without cache") - buildCmd.Flags().Bool("no-generate", false, "skip regenerating build context (flake.nix, Dockerfile, etc.)") - // Post-2026-05-15 flip (CELL-183): pure is the default. --impure (CELL-165 - // canonical name) opts into the legacy Dockerfile path. --debian is the - // deprecated alias retained for one release. --pure is a silent no-op - // (same as default). - buildCmd.Flags().Bool("impure", false, "build via legacy Dockerfile path (default is nix2container/pure)") - buildCmd.Flags().Bool("debian", false, "deprecated alias for --impure (will be removed)") - buildCmd.Flags().Bool("pure", false, "silent no-op (kept for back-compat; pure is the default after CELL-183)") - // CELL-93: one-shot stack override. Precedence: --stack > $DEVCELL_STACK > - // [cell].stack in TOML > default (ResolvedStack → "base"). buildCmd.Flags().String("stack", "", "override [cell].stack for this build (base, go, node, python, fullstack, electronics, ultimate)") buildCmd.Flags().String("image", "", "override the built image tag (e.g. devcell-user:dev-thin); env DEVCELL_BUILD_IMAGE has lower precedence") - // CELL-156: thin image mode — nix store on Docker volume, not baked into image. - buildCmd.Flags().Bool("thin", false, "build thin image (default; kept for explicitness)") - buildCmd.Flags().Bool("no-thin", false, "build thick image (nix store baked into image)") - buildCmd.Flags().Bool("thick", false, "alias for --no-thin") buildCmd.Flags().Bool("force", false, "recreate VM even if it already exists (tart only)") buildCmd.Flags().Bool("no-cache", false, "re-download OCI image, bypassing tart cache (tart only)") } func runBuild(cmd *cobra.Command, _ []string) error { applyOutputFlagsWithLog("build") - update, _ := cmd.Flags().GetBool("update") - noGenerate, _ := cmd.Flags().GetBool("no-generate") - impure, _ := cmd.Flags().GetBool("impure") - // Back-compat: accept --debian as an alias for --impure. - if !impure { - debian, _ := cmd.Flags().GetBool("debian") - impure = debian - } - // Allow --impure / --debian via the positional scanner too so - // `cell claude --build --impure` (or --debian) works. - if !impure { - impure = scanFlag("--impure") || scanFlag("--debian") - } - noThin, _ := cmd.Flags().GetBool("no-thin") - thick, _ := cmd.Flags().GetBool("thick") - if !noThin { - noThin = thick || scanFlag("--no-thin") || scanFlag("--thick") - } - - thin := false - if noThin { - thin = false - } else { - thinFlag, _ := cmd.Flags().GetBool("thin") - if thinFlag || scanFlag("--thin") { - thin = true - } else { - c2, _ := config.LoadFromOS() - if c2.ConfigDir != "" { - thinCfg, thinCfgErr := cfg.LoadFromOSWithDirs(c2.ConfigDir, c2.BaseDir) - if thinCfgErr != nil { - return fmt.Errorf("loading config: %w", thinCfgErr) - } - thin = thinCfg.Cell.ResolvedThin() - } else { - thin = true - } - } - } - if !thin { - runner.WarnThickDeprecation() - } + telemetry.Track("build", map[string]any{ + "engine": scanStringFlag("--engine"), + "subcommand": "build", + "update": scanFlag("--update"), + "no_cache": scanFlag("--no-cache"), + "force": scanFlag("--force"), + }) c, err := config.LoadFromOS() if err != nil { return fmt.Errorf("load config: %w", err) } - // ── Non-Docker engines: dispatch before thin/pure/impure logic ──────────── engine := scanStringFlag("--engine") if scanFlag("--macos") { engine = "vagrant" @@ -116,14 +68,44 @@ func runBuild(cmd *cobra.Command, _ []string) error { if s := cmd.Flags().Lookup("stack").Value.String(); s != "" { stack = s } - nixhomePath := c.BaseDir + "/nixhome" - if cellCfgTart.Nix.NixhomePath != "" { - nixhomePath = cellCfgTart.Nix.NixhomePath - } force, _ := cmd.Flags().GetBool("force") noCache, _ := cmd.Flags().GetBool("no-cache") tartOCIImage := cellCfgTart.Cell.ResolvedTartOCIImage() - return runBuildTart(c.CellName, c.HostHome, c.BaseDir, stack, nil, nixhomePath, force, noCache, scanFlag("--dry-run"), tartOCIImage) + return runBuildTart(c.CellName, c.HostHome, c.BaseDir, stack, nil, force, noCache, scanFlag("--dry-run"), tartOCIImage) + } + + // ── qemu engine ───────────────────────────────────────────────────────── + if engine == "qemu" { + cellCfgQemu, cfgErr := cfg.LoadFromOSWithDirs(c.ConfigDir, c.BaseDir) + if cfgErr != nil { + return fmt.Errorf("loading config: %w", cfgErr) + } + stack := cellCfgQemu.Cell.ResolvedStack() + if s := cmd.Flags().Lookup("stack").Value.String(); s != "" { + stack = s + } + force, _ := cmd.Flags().GetBool("force") + noCache, _ := cmd.Flags().GetBool("no-cache") + return runBuildQemu(c.CellName, c.HostHome, c.BaseDir, stack, force, noCache, scanFlag("--dry-run"), cellCfgQemu.Cell) + } + + // ── libvirt engine ─────────────────────────────────────────────────────── + // Template building over libvirt is deferred (CELL-379); the MVP scope + // builds templates with --engine=qemu on the macOS host and libvirt only + // boots them remotely. + if engine == "libvirt" { + cellCfgLibvirt, cfgErr := cfg.LoadFromOSWithDirs(c.ConfigDir, c.BaseDir) + if cfgErr != nil { + return fmt.Errorf("loading config: %w", cfgErr) + } + uri := cellCfgLibvirt.Cell.ResolvedLibvirtURI() + if scanFlag("--dry-run") { + fmt.Println("libvirt engine (dry-run)") + fmt.Printf(" URI: %s\n", uri) + fmt.Println(" Would boot a prepped template remotely; template builds stay on `cell build --engine=qemu` (macOS host)") + return nil + } + return fmt.Errorf("cell build --engine=libvirt is not implemented — build the template with `cell build --engine=qemu` on the macOS host, then run with --engine=libvirt (CELL-379)") } // ── Vagrant engine ──────────────────────────────────────────────────────── @@ -140,10 +122,7 @@ func runBuild(cmd *cobra.Command, _ []string) error { if vagrantProvider == "" { vagrantProvider = "utm" } - nixhomeDir := resolveVagrantNixhome(c.BaseDir) - if nixhomeDir == "" { - nixhomeDir = c.BaseDir + "/nixhome" - } + nixhomeDir := "" vmConfigDir := os.Getenv("DEVCELL_CONFIG_DIR") if vmConfigDir == "" { vmConfigDir = c.HostHome + "/.config/devcell" @@ -158,95 +137,26 @@ func runBuild(cmd *cobra.Command, _ []string) error { ); err != nil { fmt.Fprintf(os.Stderr, "warning: vagrantfile scaffold failed: %v\n", err) } - return runVagrantBuild(c.BuildDir, c.BaseDir, cellCfgVagrant, update, scanFlag("--dry-run")) + return runVagrantBuild(c.BuildDir, c.BaseDir, cellCfgVagrant, scanFlag("--update"), scanFlag("--dry-run")) } - // ── Docker engine (thin/pure/impure) ───────────────────────────────────── - - // ── Thin image mode (CELL-156): nix store on Docker volume ── - if thin { - stackOverride, err := resolveStackOverride(cmd.Flags().Lookup("stack").Value.String(), os.Getenv) - if err != nil { - return err - } - imageOverride := cmd.Flags().Lookup("image").Value.String() - if imageOverride == "" { - imageOverride = os.Getenv("DEVCELL_BUILD_IMAGE") - } - return runBuildThin(c, stackOverride, imageOverride, update) - } - - // ── Default: pure (nix2container) engine — strict, no docker-build fallback ── - if !impure { - stackOverride, err := resolveStackOverride(cmd.Flags().Lookup("stack").Value.String(), os.Getenv) - if err != nil { - return err - } - return runBuildPure(c, stackOverride) - } - - // ── Docker engine (default) ─────────────────────────────────────────────── - if err := config.EnsureBuildDir(c.BuildDir); err != nil { - return fmt.Errorf("ensure build dir: %w", err) - } - - cellCfg, cfgErr := cfg.LoadFromOSWithDirs(c.ConfigDir, c.BaseDir) - if cfgErr != nil { - return fmt.Errorf("loading config: %w", cfgErr) - } - ux.Debugf("BuildDir: %s", c.BuildDir) - if cellCfg.Nix.NixhomePath != "" { - ux.Debugf("NixhomePath: %s (from config/env)", cellCfg.Nix.NixhomePath) - } - - // Sync local nixhome into build context when nixhome path is set. - if nixhomePath := cellCfg.Nix.NixhomePath; nixhomePath != "" { - ux.Debugf("Syncing nixhome: %s → %s/nixhome/", nixhomePath, c.BuildDir) - if err := scaffold.SyncNixhome(nixhomePath, c.BuildDir); err != nil { - return fmt.Errorf("sync nixhome: %w", err) - } - } - - if !noGenerate { - // Regenerate all build artifacts from merged config (flake.nix, - // Dockerfile, package.json, pyproject.toml) so that stack/modules - // changes in devcell.toml take effect without re-running cell init. - if err := scaffold.RegenerateBuildContext(c.BuildDir, cellCfg); err != nil { - return fmt.Errorf("regenerate build context: %w", err) - } - } - - if update { - if err := updateFlakeLockWithSpinner(c.BuildDir, false, "Updating nix flake inputs"); err != nil { - return err - } - } - - if err := buildImageWithSpinner(c.BuildDir, update, "Building devcell image", false); err != nil { + // ── Docker engine (thin) ───────────────────────────────────────────────── + stackOverride, err := resolveStackOverride(cmd.Flags().Lookup("stack").Value.String(), os.Getenv) + if err != nil { return err } - return nil + imageOverride := cmd.Flags().Lookup("image").Value.String() + if imageOverride == "" { + imageOverride = os.Getenv("DEVCELL_BUILD_IMAGE") + } + return runBuildThin(c, stackOverride, imageOverride, scanFlag("--update")) } -// runBuildPure runs the strict nix2container path. No docker-build fallback. -// -// Nixhome resolution mirrors the docker path (scaffold.go:130-140): -// 1. [cell].nixhome (TOML / DEVCELL_NIXHOME_PATH) — synced into BuildDir. -// 2. /nixhome on disk — synced into BuildDir. -// 3. github:DimmKirr/devcell/?dir=nixhome — passed to nix directly, -// no local sync. Nix fetches and caches under /nix/store. -// -// The flake ref is then composed by PureBuildArgv as -// "#packages..devcell--pure-image" and loaded into the -// local Docker daemon as runner.UserImageTagPure(). // resolveStackOverride collapses the --stack flag value and the DEVCELL_STACK -// env var into a single override string for runBuildPure. Precedence: -// flag > env > "" (empty → caller uses TOML / default). +// env var into a single override string. Precedence: +// flag > env > "" (empty = caller uses TOML / default). // -// Empty flagValue means the user didn't pass --stack; in that case the env -// var is consulted. Returns an error if either value names an unknown stack. -// getenv is injected so tests can drive the env layer deterministically -// (avoids polluting the real process env). +// getenv is injected so tests can drive the env layer deterministically. func resolveStackOverride(flagValue string, getenv func(string) string) (string, error) { if flagValue != "" { if err := cfg.ValidateStack(flagValue); err != nil { @@ -263,107 +173,6 @@ func resolveStackOverride(flagValue string, getenv func(string) string) (string, return "", nil } -// runBuildPure runs the nix2container build. stackOverride wins over the -// TOML-resolved stack when non-empty (CELL-93). Validation of the override -// happens at the caller (runBuild) so this function can stay focused on the -// build itself. -func runBuildPure(c config.Config, stackOverride string) error { - cellCfg, cfgErr := cfg.LoadFromOSWithDirs(c.ConfigDir, c.BaseDir) - if cfgErr != nil { - return fmt.Errorf("loading config: %w", cfgErr) - } - stack := cellCfg.Cell.ResolvedStack() - if stackOverride != "" { - stack = stackOverride - } - - if err := config.EnsureBuildDir(c.BuildDir); err != nil { - return fmt.Errorf("ensure build dir: %w", err) - } - - resolved := runner.ResolvePureNixhomeRef(runner.PureNixhomeInputs{ - TomlNixhome: cellCfg.Nix.NixhomePath, - BaseDir: c.BaseDir, - Version: version.Version, - }) - - // Local source: sync into BuildDir so the flake path is stable across - // runs and the user can inspect/edit .devcell/nixhome/ for debugging. - // Remote source: skip the sync — nix handles the fetch and cache. - flakeRef := resolved.FlakeRef - if !resolved.Remote { - if err := scaffold.SyncNixhome(resolved.LocalPath, c.BuildDir); err != nil { - return fmt.Errorf("sync nixhome: %w", err) - } - flakeRef = "path:" + c.BuildDir + "/nixhome" - ux.Debugf("Pure build using local nixhome: %s (synced from %s)", flakeRef, resolved.LocalPath) - } else { - ux.Debugf("Pure build using remote nixhome: %s", flakeRef) - } - - // ── Platform compatibility preflight ────────────────────────────────── - { - targetSystem := runner.DetectArch() + "-linux" - preLabel := fmt.Sprintf("Platform compatibility check (%s)", targetSystem) - sp := ux.NewProgressSpinner(preLabel) - if err := runner.PreflightPlatformCheck(context.Background(), flakeRef, targetSystem); err != nil { - sp.Fail(preLabel) - return err - } - sp.Success(preLabel) - } - - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - - var buf bytes.Buffer - var out io.Writer = &buf - if ux.Verbose { - out = os.Stdout - } - // Wrap with a layer counter so the --debug summary below can report - // new (transferred) vs cached (already-at-destination) blob counts - // captured straight from skopeo's per-blob log lines. - lc := runner.NewLayerCounter(out) - out = lc - - explicitStack := stackOverride != "" || cellCfg.Cell.StackExplicit() - label := runner.BuildLabel("Building devcell image (nix2container)", stack, explicitStack) - sp := ux.NewProgressSpinner(label) - err := runner.BuildImagePure(ctx, runner.PureBuildSpec{ - FlakeRef: flakeRef, - StackName: stack, - // Anchor the out-link inside BuildDir so we don't drop result-* - // symlinks in the user's cwd (especially relevant for the github: - // fallback where there's no local nixhome dir to anchor next to). - OutLink: filepath.Join(c.BuildDir, fmt.Sprintf("result-%s-pure", stack)), - }, runner.UserImageTagPure(), ux.Verbose, out) - if err != nil { - sp.Fail(label + " failed") - if !ux.Verbose && buf.Len() > 0 { - fmt.Fprint(os.Stderr, buf.String()) - } - return err - } - // Append the loaded image size to the spinner's success line. - // skopeo's per-blob progress is empty when stdout isn't a TTY, so this - // synthesizes a single summary number from `docker image inspect` — - // always available regardless of skopeo's terminal heuristics. - tag := runner.UserImageTagPure() - successLabel := label - if size := runner.LocalImageSize(ctx, tag); size > 0 { - successLabel = fmt.Sprintf("%s — %s loaded", label, runner.HumanBytes(size)) - } - sp.Success(successLabel) - - // --debug summary — answers "is this a fresh build, and how much was cached?" - // Only fires under --debug (ux.Debugf is a no-op otherwise). - if ux.Verbose { - printBuildDebugSummary(ctx, tag, lc.Stats()) - } - return nil -} - // runBuildThin builds a thin image (CELL-156): // 1. Ensure core image exists (pull or use cached) // 2. docker run core with nix volume + docker socket: @@ -376,6 +185,7 @@ func runBuildThin(c config.Config, stackOverride, imageOverride string, forceRec if err := runner.DockerDaemonReachable(context.Background()); err != nil { return err } + logDockerDiagnostics(context.Background(), c) cellCfg, cfgErr := cfg.LoadFromOSWithDirs(c.ConfigDir, c.BaseDir) if cfgErr != nil { @@ -386,36 +196,25 @@ func runBuildThin(c config.Config, stackOverride, imageOverride string, forceRec stack = stackOverride } - // Resolve nixhome source — shared with pure path: - // 1. [nix].nixhome (TOML/env) — local path - // 2. /nixhome on disk — dev/dogfood convenience - // 3. github:DimmKirr/devcell/?dir=nixhome — prebaked upstream (CELL-38) - resolved := runner.ResolvePureNixhomeRef(runner.PureNixhomeInputs{ - TomlNixhome: cellCfg.Nix.NixhomePath, - BaseDir: c.BaseDir, - Version: version.Version, - }) - - // Materialise nixhome locally into .devcell/nixhome (handles github refs - // via git clone; local paths via cp). Gives us a known on-disk source for - // home-manager AND a known entrypoint.sh location. if err := config.EnsureBuildDir(c.BuildDir); err != nil { return fmt.Errorf("ensure build dir: %w", err) } - syncSrc := resolved.LocalPath - if resolved.Remote { - syncSrc = resolved.FlakeRef // SyncNixhome routes github: refs through git clone - } - if err := scaffold.SyncNixhome(syncSrc, c.BuildDir); err != nil { + nixhomeSrc := runner.ResolveNixhomeRef(version.Version) + if err := scaffold.SyncNixhome(nixhomeSrc, c.BuildDir); err != nil { return fmt.Errorf("sync nixhome: %w", err) } + // Validate [packages.nix] before generating the flake. + if err := cfg.ValidateNixPackages(cellCfg.Packages.Nix); err != nil { + return err + } + // Write the overlay flake at .devcell/flake.nix — same generator as pure // path. Imports path:./nixhome (the just-synced upstream) + enables the // merged TOML modules. home-manager will switch against this overlay's // `devcell-local` output, not the upstream stack outputs directly, // so [cell].modules takes effect in thin builds (CELL-38 + CELL-61). - overlayFlake := scaffold.GenerateFlakeNix(stack, cellCfg.Cell.Modules, version.Version, true) + overlayFlake := scaffold.GenerateFlakeNix(stack, cellCfg.Cell.Modules, version.Version, true, cellCfg.Packages.Nix) overlayPath := filepath.Join(c.BuildDir, "flake.nix") if err := os.WriteFile(overlayPath, []byte(overlayFlake), 0o644); err != nil { return fmt.Errorf("write overlay flake: %w", err) @@ -456,11 +255,12 @@ func runBuildThin(c config.Config, stackOverride, imageOverride string, forceRec volumeName := runner.ThinStoreVolume() containerName := "devcell-thin-builder" - // ── Ensure core image exists ──────────────────────────────────────────── - if !runner.ImageExists(ctx, coreImage) { - pullLabel := fmt.Sprintf("Pulling core image %s", coreImage) + // ── Ensure core image exists for target platform ─────────────────────── + targetPlatform := runner.DockerPlatform(runner.DetectArch()) + if !runner.ImageExistsForPlatform(ctx, coreImage, targetPlatform) { + pullLabel := fmt.Sprintf("Pulling core image %s (%s)", coreImage, targetPlatform) sp := ux.NewProgressSpinner(pullLabel) - if err := runner.PullImage(ctx, coreImage, ux.Verbose); err != nil { + if err := runner.PullImageForPlatform(ctx, coreImage, targetPlatform, ux.Verbose); err != nil { sp.Fail(pullLabel + " failed") return fmt.Errorf("pull core image: %w", err) } @@ -487,8 +287,64 @@ func runBuildThin(c config.Config, stackOverride, imageOverride string, forceRec // "local" — that's a flake-output naming detail, not user content. modulesCSV := strings.Join(cellCfg.Cell.Modules, ",") projectName := filepath.Base(c.BaseDir) + + // [build] TOML → env, before argv construction reads them. An explicit + // env var wins over TOML (env > toml > derived default). + applyBuildEnv := func(envVar, val string) { + if val != "" && os.Getenv(envVar) == "" { + os.Setenv(envVar, val) + } + } + applyBuildEnv("DEVCELL_BUILD_MEMORY", cellCfg.Build.Memory) + applyBuildEnv("DEVCELL_BUILD_CPUS", cellCfg.Build.CPUs) + if cellCfg.Build.MaxJobs > 0 { + applyBuildEnv("DEVCELL_NIX_MAX_JOBS", strconv.Itoa(cellCfg.Build.MaxJobs)) + } + if cellCfg.Build.Cores > 0 { + applyBuildEnv("DEVCELL_NIX_CORES", strconv.Itoa(cellCfg.Build.Cores)) + } + argv := runner.ThinBuildArgvFull(coreImage, containerName, volumeName, nixhomeRef, tag, homeManagerTarget, runner.DetectArch(), stack, modulesCSV, projectName) + // Log the resolved build resource config under --debug. + if lim := runner.ResolveBuildLimits(); lim.Memory != "" || lim.CPUs != "" { + maxJobs := "auto" + if lim.MaxJobs > 0 { + maxJobs = fmt.Sprintf("%d", lim.MaxJobs) + } + cores := "default" + if lim.Cores > 0 { + cores = fmt.Sprintf("%d", lim.Cores) + } + ux.Debugf("build limits: --memory=%s --cpus=%s nix max-jobs=%s cores=%s", lim.Memory, lim.CPUs, maxJobs, cores) + } else { + ux.Debugf("build limits: uncapped (daemon too small for a ceiling)") + } + + // Stream the overlay through Docker stdin. Unlike a bind mount, this is + // resolved by the local cell process and works when the selected daemon is + // inside Docker Desktop, Colima, or on a remote host. + archive, err := os.CreateTemp("", "devcell-thin-nixhome-*.tar") + if err != nil { + sp.Fail(buildLabel + " failed") + return fmt.Errorf("create thin nixhome archive: %w", err) + } + archivePath := archive.Name() + defer func() { + _ = archive.Close() + _ = os.Remove(archivePath) + }() + if err := runner.WriteThinBuildContext(archive, c.BuildDir); err != nil { + sp.Fail(buildLabel + " failed") + return fmt.Errorf("archive thin nixhome: %w", err) + } + size, _ := archive.Seek(0, io.SeekCurrent) + if _, err := archive.Seek(0, io.SeekStart); err != nil { + sp.Fail(buildLabel + " failed") + return fmt.Errorf("rewind thin nixhome archive: %w", err) + } + ux.Debugf("thin nixhome transport: tar-stdin source=%q bytes=%d", c.BuildDir, size) + var buf bytes.Buffer var out io.Writer = &buf if ux.Verbose { @@ -496,6 +352,7 @@ func runBuildThin(c config.Config, stackOverride, imageOverride string, forceRec } cmd := exec.CommandContext(ctx, argv[0], argv[1:]...) + cmd.Stdin = archive cmd.Stdout = out cmd.Stderr = out if err := cmd.Run(); err != nil { @@ -514,41 +371,3 @@ func runBuildThin(c config.Config, stackOverride, imageOverride string, forceRec return nil } -// printBuildDebugSummary prints the post-build debug block: image ID + -// created timestamp + size + total layers + new/cached split. Surfaces the -// "did my rebuild actually produce a new image, or is the cache stale?" -// question that motivated the feature (CELL-86 debugging session). -func printBuildDebugSummary(ctx context.Context, tag string, layers runner.LayerStats) { - info, err := runner.InspectImageDebug(ctx, tag) - if err != nil { - ux.Debugf("post-build inspect failed: %v", err) - return - } - ux.Debugf("image: %s", info.Tag) - ux.Debugf("image ID: %s", shortID(info.ID)) - ux.Debugf("created: %s", info.Created) - ux.Debugf("size: %s", runner.HumanBytes(info.SizeBytes)) - ux.Debugf("layers total: %d", info.LayerCount) - // New + Cached may not sum to LayerCount: skopeo's log is only emitted - // for the registry-push leg, and skipped-line wording varies across - // versions. We surface raw counts and the leftover as "unaccounted" - // rather than fabricate a guarantee that doesn't hold. - ux.Debugf("layers new: %d", layers.New) - ux.Debugf("layers cached:%d", layers.Cached) - if rest := info.LayerCount - layers.New - layers.Cached; rest > 0 { - ux.Debugf("layers other: %d (not classified by skopeo log)", rest) - } -} - -// shortID renders sha256:abcdef… as abcdef12 to match `docker images`. -func shortID(id string) string { - const prefix = "sha256:" - s := id - if len(s) > len(prefix) && s[:len(prefix)] == prefix { - s = s[len(prefix):] - } - if len(s) > 12 { - return s[:12] - } - return s -} diff --git a/cmd/build_df.go b/cmd/build_df.go index 404fa1a..19dd95c 100644 --- a/cmd/build_df.go +++ b/cmd/build_df.go @@ -8,6 +8,7 @@ import ( "syscall" "github.com/DimmKirr/devcell/internal/runner" + "github.com/DimmKirr/devcell/internal/telemetry" "github.com/spf13/cobra" ) @@ -46,6 +47,8 @@ func runBuildDf(cmd *cobra.Command, _ []string) error { jsonOut, _ := cmd.Flags().GetBool("json") all, _ := cmd.Flags().GetBool("all") kinds, _ := cmd.Flags().GetStringSlice("kind") + + telemetry.Track("build_df", map[string]any{"json": jsonOut, "all": all, "kinds": kinds}) if all { topN = 0 } diff --git a/cmd/build_prune.go b/cmd/build_prune.go index 52f5cdc..289a7d7 100644 --- a/cmd/build_prune.go +++ b/cmd/build_prune.go @@ -10,6 +10,8 @@ import ( "syscall" "github.com/DimmKirr/devcell/internal/runner" + "github.com/DimmKirr/devcell/internal/telemetry" + "github.com/DimmKirr/devcell/internal/ux" "github.com/mattn/go-isatty" "github.com/spf13/cobra" ) @@ -62,6 +64,8 @@ func runBuildPrune(cmd *cobra.Command, _ []string) error { force, _ := cmd.Flags().GetBool("force") yes, _ := cmd.Flags().GetBool("yes") + telemetry.Track("build_prune", map[string]any{"pure": pure, "force": force}) + homeDir, _ := os.UserHomeDir() opts := runner.PruneOpts{ GOOS: runtime.GOOS, @@ -76,6 +80,24 @@ func runBuildPrune(cmd *cobra.Command, _ []string) error { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() + // CELL-334 preflight gate: resolve every RUNNING cell's closure so the + // plan can stamp its GC roots before the sweep. A cell whose closure + // cannot be resolved aborts the prune (named in the error) — proceeding + // on "some roots exist" is exactly the gap this closes. + if pure && !force { + closures, err := runner.CollectLiveClosures( + func() ([]string, error) { return runner.DockerRunningDevcellContainers(ctx) }, + func(container, link string) (string, error) { + return runner.DockerResolveContainerLink(ctx, container, link) + }, + ux.Debugf, + ) + if err != nil { + return err + } + opts.LiveClosures = closures + } + return runner.RunPrune(runner.RunPruneArgs{ Opts: opts, Exec: func(step runner.PruneStep) error { return execStep(ctx, step) }, @@ -103,4 +125,3 @@ func detectRootlessDocker() bool { } return strings.Contains(strings.ToLower(string(out)), "rootless") } - diff --git a/cmd/build_qemu.go b/cmd/build_qemu.go new file mode 100644 index 0000000..1db9c12 --- /dev/null +++ b/cmd/build_qemu.go @@ -0,0 +1,1142 @@ +//go:build darwin || linux + +package main + +import ( + "context" + "fmt" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/devcell-sh/go-winkit/diag" + "github.com/devcell-sh/go-winkit/unattend" + "github.com/devcell-sh/go-winkit/wim" + "github.com/devcell-sh/go-winkit/winpe" + + "github.com/DimmKirr/devcell/internal/cfg" + "github.com/DimmKirr/devcell/internal/config" + "github.com/DimmKirr/devcell/internal/ux" + "github.com/DimmKirr/devcell/internal/vm/qemu" + "github.com/devcell-sh/go-winkit/isokit" +) + +// runBuildQemu creates a fully provisioned Windows VM template via QEMU. +// +// Mirrors the tart build flow: init scaffolds config/keys, build creates and +// provisions the template image. The VM is booted for Windows installation + +// provisioning and shut down when done — cell shell clones and starts it again. +func runBuildQemu(cellName, hostHome, baseDir, stack string, force, noCache, dryRun bool, cellCfg cfg.CellSection) error { + // Modules fork the template: a cell with extra nix modules gets different + // guest contents, so it needs its own disk and its own provisioned marker. + // Passing nil here collapsed every module set onto one template, where the + // first build won and the rest silently reused or clobbered it. + modules := cellCfg.Modules + templateDir := qemu.TemplateDir(hostHome, stack, modules) + templateDisk := filepath.Join(templateDir, qemu.ImageName(stack, modules)) + varsPath := filepath.Join(templateDir, "vars.fd") + sshDir := qemuKeyDir(hostHome, cellName) + privKeyPath := filepath.Join(sshDir, "id_ed25519") + pubKeyPath := filepath.Join(sshDir, "id_ed25519.pub") + marker := qemu.ProvisionedMarker(hostHome, stack, modules) + + ux.Debugf("build qemu: cell=%s stack=%s force=%v noCache=%v", cellName, stack, force, noCache) + ux.Debugf("templateDir=%s templateDisk=%s", templateDir, templateDisk) + + budget := qemuBuildBudget(cellCfg) + + if dryRun { + fmt.Printf("Would build Windows VM template: %s\n", qemu.TemplateVMName(stack, modules)) + fmt.Printf(" Stack: %s\n", stack) + fmt.Printf(" Template disk: %s\n", templateDisk) + fmt.Printf(" Accelerator: %s (%s)\n", budget.Accel, budget.AccelReason) + fmt.Printf(" Memory: %d GB\n", budget.MemoryGB) + fmt.Printf(" SSH deadline: %s\n", budget.SSHDeadline) + return nil + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + defer signal.Stop(sigCh) + + go func() { + select { + case <-sigCh: + ux.Debugf("caught signal — cancelling build context") + cancel() + case <-ctx.Done(): + } + }() + + runTS := time.Now().UTC().Format("20060102T150405Z") + runDir := filepath.Join(baseDir, ".scratch", "debug", runTS) + if err := os.MkdirAll(runDir, 0755); err != nil { + return fmt.Errorf("creating run debug dir: %w", err) + } + ux.Debugf("run debug dir: %s", runDir) + + pr := &ux.PhaseRunner{} + obs := &phaseObserver{logf: ux.Debugf, runner: pr} + + // --- Phase 1: Ensure SSH keys exist --- + if _, err := os.Stat(privKeyPath); err != nil { + ux.Debugf("SSH key not found — running auto-init") + fmt.Println(ux.StyleSection.Render(" SSH keys not found — running init")) + if initErr := runInitQemu(cellName, hostHome, stack, false); initErr != nil { + return fmt.Errorf("auto-init failed: %w", initErr) + } + } + + pubKeyBytes, err := os.ReadFile(pubKeyPath) + if err != nil { + return fmt.Errorf("reading SSH public key: %w", err) + } + pubKey := strings.TrimSpace(string(pubKeyBytes)) + ux.Debugf("loaded SSH pub key from %s", pubKeyPath) + + if homeDir, _ := os.UserHomeDir(); homeDir != "" { + if extra := collectSSHPubKeys(filepath.Join(homeDir, ".ssh")); extra != "" { + pubKey = pubKey + "\n" + extra + ux.Debugf("added existing ~/.ssh pub keys") + } + } + + // --- Phase 2: Check existing template --- + if _, err := os.Stat(templateDisk); err == nil { + if !force { + return fmt.Errorf("template %s already exists — use --force to rebuild", templateDisk) + } + ux.Debugf("template exists, --force — removing") + os.Remove(templateDisk) + os.Remove(varsPath) + os.Remove(marker) + } + + // --- Phase 3: Download VirtIO drivers --- + var virtioISO string + if err := pr.PhaseDetailed("Downloading VirtIO drivers", func() (string, error) { + path, err := qemu.DownloadVirtioDrivers(ctx, hostHome, noCache, obs) + if err != nil { + return "", err + } + virtioISO = path + return path, nil + }); err != nil { + return err + } + + // --- Phase 3b: OpenSSH release --- + // Windows servicing cannot install OpenSSH Server from this media (the + // capability is Staged with no payload, failing 0x80070002 even with + // Windows Update reachable), so the standalone release ships with the + // answer file. A download failure is not fatal: the bootstrap still tries + // the capability, and the guest reports which path it took. + var opensshPayload []byte + if err := pr.PhaseDetailed("Fetching OpenSSH release", func() (string, error) { + path, err := qemu.DownloadOpenSSH(ctx, hostHome, noCache, obs) + if err != nil { + ux.Debugf("OpenSSH release unavailable (%v) — bootstrap will fall back to the capability", err) + return "unavailable, will fall back to Add-WindowsCapability", nil + } + data, readErr := os.ReadFile(path) + if readErr != nil { + ux.Debugf("reading OpenSSH payload: %v", readErr) + return "unreadable, will fall back to Add-WindowsCapability", nil + } + opensshPayload = data + return fmt.Sprintf("%s (%.1f MB)", path, float64(len(data))/(1024*1024)), nil + }); err != nil { + return err + } + + // --- Phase 3c: PowerShell 7 for WinPE --- + // Stock WinPE lacks powershell.exe. The self-contained pwsh.exe ships on + // the answer volume so the bootstrap.cmd shim can launch PowerShell scripts. + var pwshFiles map[string][]byte + if err := pr.PhaseDetailed("Fetching PowerShell for WinPE", func() (string, error) { + zipPath, err := qemu.DownloadPwsh(ctx, hostHome, noCache, obs) + if err != nil { + return "", fmt.Errorf("downloading PowerShell: %w", err) + } + files, err := winpe.ExtractPwshFiles(zipPath) + if err != nil { + return "", fmt.Errorf("extracting PowerShell zip: %w", err) + } + pwshFiles = files + var totalSize int64 + for _, data := range files { + totalSize += int64(len(data)) + } + return fmt.Sprintf("%s (%d files, %.1f MB)", zipPath, len(files), float64(totalSize)/(1024*1024)), nil + }); err != nil { + return err + } + + // --- Phase 4: Ensure Windows ISO --- + var windowsISO string + if err := pr.PhaseDetailed("Ensuring Windows ISO", func() (string, error) { + if envISO := os.Getenv("DEVCELL_QEMU_WINDOWS_ISO"); envISO != "" { + if _, err := os.Stat(envISO); err != nil { + return "", fmt.Errorf("Windows ISO not found at %s: %w", envISO, err) + } + if err := qemu.ValidateISO(envISO); err != nil { + return "", fmt.Errorf("invalid ISO at %s: %w", envISO, err) + } + windowsISO = envISO + } else { + path, err := qemu.DownloadWindowsISO(ctx, hostHome, "en-us", noCache, obs) + if err != nil { + return "", err + } + windowsISO = path + } + meta := qemu.ParseISOFilename(filepath.Base(windowsISO)) + detail := windowsISO + if meta.Version != "" { + detail = fmt.Sprintf("%s (version %s, %s)", windowsISO, meta.Version, meta.Arch) + } + return detail, nil + }); err != nil { + return err + } + + // --- Phase 5: Preflight check --- + var qemuVersion string + if err := pr.PhaseDetailed("QEMU preflight check", func() (string, error) { + if err := qemu.PreflightCheckHost(); err != nil { + return "", err + } + binPath, err := qemu.QEMUBinaryPath() + if err != nil { + return "", err + } + qemuVersion, _ = qemu.QEMUVersion(binPath) + accel := qemu.Accelerator() + + if info, err := winpe.ISOPreflight(windowsISO); err != nil { + ux.Debugf("ISO preflight: %v", err) + } else { + ux.Debugf("ISO preflight: format=%s size=%d hasBootEFI=%v", info.Format, info.Size, info.HasBootEFI) + } + ux.Debugf("ISO diagnosis:\n%s", isokit.DiagnoseISO(windowsISO)) + + return fmt.Sprintf("QEMU %s (%s)", qemuVersion, accel), nil + }); err != nil { + return err + } + + // --- Phase 5b: Prep WIM (Hyper-V + OpenSSH offline servicing) --- + var devcellWimPath string + if err := pr.PhaseDetailed("Preparing devcell.wim (DISM offline servicing)", func() (string, error) { + cachedWim := filepath.Join(templateDir, "devcell.wim") + if _, err := os.Stat(cachedWim); err == nil && !noCache { + devcellWimPath = cachedWim + return fmt.Sprintf("cached: %s", cachedWim), nil + } + + path, err := runWimBuilder(ctx, templateDir, windowsISO, virtioISO, runDir, obs, pwshFiles) + if err != nil { + ux.Debugf("WIM builder failed: %v — build continues without devcell.wim", err) + return fmt.Sprintf("skipped: %v", err), nil + } + devcellWimPath = path + return path, nil + }); err != nil { + return err + } + _ = devcellWimPath // will be used when the install phase consumes the custom WIM + + // --- Phase 6: Create template disk --- + diskSizeGB := 64 + if err := pr.PhaseDetailed("Creating template disk", func() (string, error) { + if err := os.MkdirAll(templateDir, 0755); err != nil { + return "", fmt.Errorf("creating template dir: %w", err) + } + if err := qemu.CreateDisk(templateDisk, diskSizeGB); err != nil { + return "", err + } + return fmt.Sprintf("%s (%dGB)", templateDisk, diskSizeGB), nil + }); err != nil { + return err + } + + // --- Phase 7: Prepare UEFI firmware vars --- + firmwarePath := qemu.FirmwarePath() + if err := pr.PhaseDetailed("Preparing UEFI firmware", func() (string, error) { + if _, err := os.Stat(firmwarePath); err != nil { + return "", fmt.Errorf("EDK2 UEFI firmware not found at %s — install QEMU (brew install qemu)", firmwarePath) + } + if err := qemu.PrepareVarsFile(firmwarePath, varsPath); err != nil { + return "", err + } + return firmwarePath, nil + }); err != nil { + return err + } + + // --- Phase 8: Generate autounattend ISO --- + var autounattendISO string + if err := pr.PhaseDetailed("Generating autounattend ISO", func() (string, error) { + cfg := unattend.DefaultConfig() + cfg.SSHPubKey = pubKey + // The guest's ComputerName was the literal "devcell-win" for every + // template and every cell. Name it after the cell, the way Docker cells + // are named, and honour the same override chain + // (DEVCELL_HOSTNAME > [cell] hostname > computed). + cfg.Hostname = cellCfg.ResolvedHostname(winpe.GuestHostname(cellName)) + // The template is what `cell rdp` connects to, and the host side + // (port allocation, forwarding, discovery) already ships — RDP just + // has to be on inside Windows (CELL-369). + cfg.EnableRDP = true + cfg.VirtIODrivers = append(unattend.NetKVMDriverPaths(), unattend.VioserialDriverPaths()...) + if len(opensshPayload) > 0 { + cfg.OpenSSHPayload = unattend.OpenSSHPayloadName + cfg.OpenSSHPayloadData = opensshPayload + cfg.OpenSSHPayloadSize = len(opensshPayload) + } + cfg.PwshFiles = pwshFiles + + // ARM64 WinPE has no inbox vioscsi, so Setup cannot see the + // virtio-scsi installer CD without this drvload payload — the + // install would burn its full cycle at "media driver missing" + // (CELL-429). Hard error: there is no fallback bus (ahci: no EDK2 + // boot option; usb-bot: kills USB on QEMU 11/HVF; usb-storage + // mirror: cdboot crash). + drivers, err := winpe.LoadWinPEStorageDrivers(virtioISO) + if err != nil { + return "", fmt.Errorf("extracting WinPE storage drivers: %w", err) + } + cfg.AnswerDrivers = drivers + + if winpeAgentDebugEnabled(os.Getenv) { + cfg.WinPEAgent = true + cfg.AgentCommand = winpe.DiagCommand + ux.Debugf("DEVCELL_QEMU_WINPE_AGENT=1: shipping WinPE agent + one-shot read-only diagnostic") + } + + bootloader, err := winpe.InstallerBootloader(windowsISO) + if err != nil { + ux.Debugf("could not extract BOOTAA64.EFI from ISO (startup.nsh fallback will rely on CD reads): %v", err) + } else if blInfo, err := winpe.ValidateBootloaderPE(bootloader); err != nil { + ux.Debugf("extracted BOOTAA64.EFI but it failed validation: %v", err) + } else { + cfg.EFIBootLoader = bootloader + major, _ := qemu.ParseMajorVersion(qemuVersion) + ux.Debugf("embedded BOOTAA64.EFI (%d bytes, arch=%s) on answer volume — QEMU %s (v%d), needed for v11+ HVF CD-ROM workaround", + blInfo.Size, blInfo.Arch, qemuVersion, major) + } + + imgPath := filepath.Join(templateDir, "autounattend.img") + if err := unattend.BuildAnswerVolume(cfg, imgPath); err != nil { + return "", fmt.Errorf("writing autounattend image: %w", err) + } + autounattendISO = imgPath + return imgPath, nil + }); err != nil { + return err + } + + // --- Phase 9: Install Windows --- + c := config.Load(baseDir, os.Getenv) + taken := config.DockerAllocatedPorts() + ports := qemu.AllocatePorts(c.PortPrefix, taken) + + // The two channels a guest can use before it has a network. Created up + // front so the firmware's very first line is captured — by the time a boot + // has failed, there is nothing left to attach to. + serialLog, guestProgressLog := qemuDiagnosticPaths(runDir) + ux.Debugf("serial log: %s", serialLog) + ux.Debugf("guest progress log: %s", guestProgressLog) + // Not just for --dry-run: the accelerator decides whether this build takes + // 30 minutes or 3 hours, and a user watching a slow install deserves to see + // which one they got without re-reading the plan. + fmt.Printf(" Accelerator: %s (%s)\n", budget.Accel, budget.AccelReason) + fmt.Printf(" Memory: %d GB\n", budget.MemoryGB) + fmt.Printf(" SSH deadline: %s\n", budget.SSHDeadline) + fmt.Printf(" Ports: %s\n", formatAllocatedPorts(ports)) + buildPorts := qemuBuildSpecPorts(ports) + + buildSpec := qemu.Spec{ + VMName: "devcell-qemu-build", + CPUs: 2, + SerialLogPath: serialLog, + GuestProgressLogPath: guestProgressLog, + // Windows cells run WSL2/Hyper-V inside the guest, which needs more + // than EL2: a GICv3 with ITS and a secure world. Set here so + // `cell build --engine=qemu` produces the same machine the dev-env + // pipeline is validated against. + NestedVirt: true, + MemoryGB: budget.MemoryGB, + DiskCacheMode: budget.DiskCacheMode, + DiskPath: templateDisk, + FirmwarePath: firmwarePath, + VarsPath: varsPath, + VirtioISO: virtioISO, + // QEMU 11 on HVF: the firmware cannot boot USB CD-ROMs (CELL-429). + // SCSI CDs on a dedicated virtio-scsi-pci controller work — the + // answer volume's BOOTAA64.EFI chainloads the installer, and + // vioscsi drvload gives WinPE access to the SCSI CDs. + CDBus: "scsi", + SSHPort: buildPorts.SSHPort, + VNCPort: buildPorts.VNCPort, + RDPPort: buildPorts.RDPPort, + SSHHost: cellCfg.ResolvedQemuSSHHost(), + SSHUser: qemuBuildSSHUser(), + SSHKeyPath: privKeyPath, + MACAddr: qemu.DeterministicMAC("build-" + stack), + DisplayType: qemuBuildDisplay(cellCfg), + QMPSocketDir: templateDir, + KVM: cellCfg.ResolvedKVM(), + } + buildSpec.ApplyDefaults() + + var vm *qemu.VM + if err := pr.PhaseDetailed("Installing Windows (this may take 20-40 minutes)", func() (string, error) { + vm = qemu.NewVM(buildSpec, obs, "") + if err := vm.StartInstall(ctx, windowsISO, autounattendISO); err != nil { + return "", fmt.Errorf("starting Windows install: %w", err) + } + return "VM started, waiting for install to complete", nil + }); err != nil { + return err + } + + stopVM := func() { + ux.Debugf("stopping build VM") + shutCtx, shutCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer shutCancel() + if err := vm.Shutdown(shutCtx); err != nil { + ux.Debugf("graceful shutdown failed: %v — forcing", err) + vm.ForceStop() + } + } + + go func() { + <-ctx.Done() + ux.Debugf("context cancelled — stopping build VM") + stopVM() + }() + + // --- Phase 10: Wait for SSH (installation + first-boot + SSH setup) --- + // Capture periodic screenshots via QMP while waiting for Windows install + screenshotDir := filepath.Join(runDir, "screenshots") + os.MkdirAll(screenshotDir, 0755) + screenshotStop := make(chan struct{}) + go func() { + ticker := time.NewTicker(15 * time.Second) + defer ticker.Stop() + qmpSock := vm.QMPSockPath() + for { + select { + case <-screenshotStop: + return + case <-ticker.C: + ts := time.Now().UTC().Format("20060102T150405Z") + ppmFile := filepath.Join(screenshotDir, ts+".ppm") + if err := qemu.QMPScreendump(qmpSock, ppmFile); err != nil { + ux.Debugf("screenshot failed: %v", err) + continue + } + pngFile := filepath.Join(screenshotDir, ts+".png") + if err := qemu.ConvertPPMtoPNG(ppmFile, pngFile); err != nil { + ux.Debugf("PPM→PNG conversion failed: %v", err) + } else { + os.Remove(ppmFile) + ux.Debugf("screenshot saved: %s", pngFile) + } + } + } + }() + + // Fail fast on a guest that never starts installing. Three detectors: + // + // 1. Serial log watcher: tails the firmware serial output for the + // "EFI Internal Shell" marker. Fires within ~1 s of the firmware + // giving up on all boot entries. This is the fastest path. + // + // 2. StallTracker (QMP: disk reads + PC) polls every 5 s with a 15 s + // budget. Fallback when serial is unavailable or the failure mode + // doesn't hit the shell (e.g. firmware dead-loop). + // + // 3. WriteProgressTracker (QMP: cumulative writes) polls every 60 s + // with a 20-minute window. Catches a VM that booted the installer + // but stopped making progress. + // Watch for the EFI shell (informational) and startup.nsh failure (fatal). + // The answer volume carries startup.nsh, which chainloads BOOTAA64.EFI if + // the firmware's own boot manager can't (CELL-427: QEMU 11/HVF regression). + // Killing on the shell marker alone would abort before startup.nsh runs. + efiShellCh := qemu.WatchSerialForEFIShell(serialLog, screenshotStop) + nshFailCh := qemu.WatchSerialForStartupNSHFail(serialLog, screenshotStop) + + // Live-stream bootstrap progress from the virtio-serial port so step + // outcomes appear in the CLI output as they happen — without this, a + // network-check throw only surfaces after the SSH deadline expires. + qemu.TailProgressLog(guestProgressLog, func(line string) { + fmt.Printf(" [guest] %s\n", line) + }, screenshotStop) + + go func() { + select { + case <-screenshotStop: + return + case reason, ok := <-efiShellCh: + if !ok { + return + } + ux.Debugf("serial: EFI shell appeared, waiting for startup.nsh recovery: %s", reason) + // Don't kill — startup.nsh will attempt to chainload BOOTAA64.EFI. + // If it also fails, nshFailCh fires below. + } + }() + go func() { + select { + case <-screenshotStop: + return + case reason, ok := <-nshFailCh: + if !ok { + return + } + ux.Debugf("serial: startup.nsh could not find BOOTAA64.EFI: %s", reason) + fmt.Printf("\n%s\n%s\n", + ux.StyleSection.Render(" Boot failed"), + "Firmware dropped to EFI shell and startup.nsh could not find BOOTAA64.EFI.\n"+ + "The installer ISO was not recognized by the firmware.") + vm.ForceStop() + } + }() + + qmpSock := vm.QMPSockPath() + go func() { + const ( + stallPoll = 5 * time.Second + stallBudget = 15 // seconds + ) + stallLimit := qemu.StallPollsFor(stallBudget, int(stallPoll.Seconds())) + var stall qemu.StallTracker + var qmpFails int + ticker := time.NewTicker(stallPoll) + defer ticker.Stop() + for { + select { + case <-screenshotStop: + return + case <-ticker.C: + if vm.State() == qemu.StateStopped || vm.State() == qemu.StateError { + ux.Debugf("stall-detect: VM exited (state=%s)", vm.State()) + fmt.Printf("\n%s\n%s\n", + ux.StyleSection.Render(" VM exited"), + "QEMU process terminated unexpectedly — check debug logs") + return + } + var sig qemu.StallSignal + var gotQMP bool + if stats, err := qemu.QMPBlockStats(qmpSock); err == nil { + gotQMP = true + for _, s := range stats { + sig.ReadBytes += s.ReadBytes + } + } + if regs, err := qemu.QMPHumanMonitor(qmpSock, "info registers"); err == nil { + gotQMP = true + sig.PC = diag.ExtractRegister(regs, "PC=") + } + if !gotQMP { + qmpFails++ + ux.Debugf("stall-detect: QMP unreachable (%d consecutive)", qmpFails) + if qmpFails >= stallLimit { + ux.Debugf("stall-detect: QMP failed %d times — VM likely crashed", qmpFails) + fmt.Printf("\n%s\n%s\n", + ux.StyleSection.Render(" VM exited"), + "QEMU process is unreachable — it may have crashed") + vm.ForceStop() + return + } + continue + } + qmpFails = 0 + n := stall.Observe(sig) + ux.Debugf("stall-detect: rd=%d PC=%s consec=%d/%d", + sig.ReadBytes, sig.PC, n, stallLimit) + if stall.Stalled(stallLimit) { + ux.Debugf("boot stall detected: %d consecutive unchanged polls (%ds each)", + stall.Consecutive(), int(stallPoll.Seconds())) + fmt.Printf("\n%s\n%s\n", + ux.StyleSection.Render(" Boot stalled"), + "VM stuck at UEFI shell — the installer ISO was not recognized as bootable") + vm.ForceStop() + return + } + } + } + }() + go func() { + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + start := time.Now() + progress := &qemu.WriteProgressTracker{Window: installStallWindow} + for { + select { + case <-screenshotStop: + return + case <-ticker.C: + stats, err := qemu.QMPBlockStats(qmpSock) + if err != nil { + continue + } + var written int64 + for _, s := range stats { + written += s.WriteBytes + } + if progress.Observe(written, time.Since(start)) { + ux.Debugf("install stalled: %s", progress.Reason()) + fmt.Printf("\n%s\n%s\n", ux.StyleSection.Render(" Install stalled"), progress.Reason()) + vm.ForceStop() + return + } + } + } + }() + + if err := pr.PhaseDetailed("Waiting for SSH (Windows install + first boot)", func() (string, error) { + ux.Debugf("waiting for SSH on %s:%d (deadline %s, accelerator %s)", + buildSpec.SSHHost, buildSpec.SSHPort, budget.SSHDeadline, budget.Accel) + if err := qemu.WaitForSSH(buildSpec.SSHHost, buildSpec.SSHPort, budget.SSHDeadline, 10*time.Second, obs, vm.State); err != nil { + if lastOut := vm.LastOutput(); lastOut != "" { + ux.Debugf("QEMU output at failure:\n%s", lastOut) + } + // The guest cannot talk to us, so the only account of what it did + // is what it wrote to the answer volume in WinPE and at first + // logon. Surface it here rather than making the caller go dig. + dumpGuestLogs(autounattendISO) + return "", fmt.Errorf("SSH not available after Windows install: %w", err) + } + return "SSH ready", nil + }); err != nil { + close(screenshotStop) + stopVM() + return err + } + close(screenshotStop) + + // SSH answering proves first logon happened (sshd only starts from + // bootstrap's first-logon run), so OOBE is over. Windows auto-opens the + // Start menu at that first sign-in and an unattended VM never sends the + // input that would close it — it sits over every later screenshot. One + // Esc dismisses it. Best-effort: screen cosmetics must not fail a build. + if err := qemu.QMPDismissFirstLogonUI(vm.QMPSockPath()); err != nil { + ux.Debugf("dismiss first-logon UI: %v", err) + } else { + ux.Debugf("OOBE finished (first logon reached) — sent Esc to close the Start menu") + } + + // The guest reached us, but what it did before that is still only written + // on the answer volume. Under --debug, print it: it is the record of the + // unattended pass, the driver install and first-logon provisioning. + if ux.Verbose { + dumpGuestLogs(autounattendISO) + } + + // --- Phase 11: Provision via SSH --- + // One runner for all guest-side work (internal/vm/qemu.RunGuestStages): + // retries, reboots, disconnect-tolerant stages, per-stage deadlines and + // component logs streamed while they run. The dev-env test drives the same + // function, so what it proves is what this command does — previously each + // had its own loop and this one was the weaker. + steps := qemu.DefaultProvisionSteps(pubKey, unattend.SessionUsername(), unattend.DefaultSessionUser) + ux.Debugf("provisioning: %d steps via SSH", len(steps)) + + if err := pr.PhaseDetailed(fmt.Sprintf("Provisioning (%d steps)", len(steps)), func() (string, error) { + runErr := qemu.RunGuestStages(ctx, buildSpec, steps, qemu.StageRunOptions{ + SSHUser: buildSpec.SSHUser, + SSHKeyPath: buildSpec.SSHKeyPath, + LogDir: filepath.Dir(guestProgressLog), + Observer: obs, + Reboot: func(ctx context.Context, reason string) error { + ux.Debugf("guest reboot requested: %s", reason) + return qemu.GuestReboot(ctx, buildSpec, buildSpec.SSHUser, + buildSpec.SSHKeyPath, budget.SSHDeadline, obs, vm.State) + }, + }) + if runErr != nil { + return "", runErr + } + return fmt.Sprintf("%d steps", len(steps)), nil + }); err != nil { + stopVM() + return err + } + + // --- Phase 12: Stamp provisioned marker --- + if err := pr.PhaseDetailed("Stamping provisioned marker", func() (string, error) { + if err := os.WriteFile(marker, []byte("provisioned\n"), 0644); err != nil { + return "", fmt.Errorf("writing marker: %w", err) + } + ux.Debugf("provisioned marker: %s", marker) + return marker, nil + }); err != nil { + stopVM() + return err + } + + // --- Phase 13: Shutdown --- + if err := pr.PhaseDetailed("Shutting down build VM", func() (string, error) { + stopVM() + return "shutdown complete", nil + }); err != nil { + return err + } + + // --- Phase 14: Finalize dev environment (WSL2 + NixOS + nix + home-manager) --- + // The same disk, rebooted on the EL3 machine (secure=on + kernel-loaded + // relocatable firmware) — the only environment Windows' hypervisor, and + // therefore WSL2, runs in (docs/spec/QEMU-ARM64-WINDOWS11-WSL2-NIX.md). + // Both prerequisites are host artifacts; without them the build still + // yields the classic ssh-able template, and says exactly what to install. + kernelFW, fwErr := qemu.KernelFirmwarePath() + fsdBin, fsdErr := qemu.VirtiofsdPath() + if fwErr != nil || fsdErr != nil { + if err := pr.PhaseDetailed("Finalizing dev environment", func() (string, error) { + reason := fwErr + if reason == nil { + reason = fsdErr + } + return fmt.Sprintf("skipped — %v", reason), nil + }); err != nil { + return err + } + pr.Seal(fmt.Sprintf("qemu template %s built (ssh-able; dev-env finalization skipped)", + qemu.TemplateVMName(stack, modules))) + return nil + } + + if err := runQemuDevEnvFinalize(ctx, pr, obs, buildSpec, kernelFW, fsdBin, + hostHome, baseDir, stack, modules, budget.SSHDeadline, runDir); err != nil { + return err + } + + pr.Seal(fmt.Sprintf("qemu template %s built (WSL2 + nix + home-manager)", qemu.TemplateVMName(stack, modules))) + return nil +} + +// runQemuDevEnvFinalize boots the template on the EL3 machine and runs the +// production dev-env stage table, ending in a guest-clean power-off and the +// base-profile image save. Separated so the phase list above stays readable. +func runQemuDevEnvFinalize(ctx context.Context, pr *ux.PhaseRunner, obs qemu.Observer, + buildSpec qemu.Spec, kernelFW, fsdBin, hostHome, baseDir, stack string, + modules []string, sshDeadline time.Duration, runDir string) error { + + const shareTag = "devcell" + const shareDrive = "Z:" + templateDir := qemu.TemplateDir(hostHome, stack, modules) + debugDir := filepath.Join(runDir, "devenv") + + fin := qemu.FinalizeSpec(buildSpec, kernelFW) + fin.VirtioFSSocketPath = filepath.Join(templateDir, "virtiofs.sock") + fin.VirtioFSTag = shareTag + fin.ApplyDefaults() + if err := fin.Validate(); err != nil { + return fmt.Errorf("finalize spec: %w", err) + } + + // Host side of the project share. virtiofsd exits when its client + // disconnects, so it belongs to exactly this VM boot. + _ = os.Remove(fin.VirtioFSSocketPath) + fsd := qemu.VirtiofsdCommand(fsdBin, fin.VirtioFSSocketPath, baseDir) + fsdLog, err := os.OpenFile(filepath.Join(debugDir, "virtiofsd.log"), + os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return fmt.Errorf("virtiofsd log: %w", err) + } + defer fsdLog.Close() + fsd.Stdout, fsd.Stderr = fsdLog, fsdLog + if err := fsd.Start(); err != nil { + return fmt.Errorf("starting virtiofsd: %w", err) + } + defer func() { + if fsd.Process != nil { + _ = fsd.Process.Kill() + } + _ = fsd.Wait() + }() + + var vm *qemu.VM + if err := pr.PhaseDetailed("Booting on the WSL2 machine (secure=on)", func() (string, error) { + vm = qemu.NewVM(fin, obs, "") + if err := vm.Start(ctx); err != nil { + return "", fmt.Errorf("starting finalize VM: %w", err) + } + if err := qemu.WaitForSSH(fin.SSHHost, fin.SSHPort, sshDeadline, + 5*time.Second, obs, vm.State); err != nil { + _ = vm.ForceStop() + return "", err + } + return "EL3 machine up, SSH ready", nil + }); err != nil { + return err + } + defer func() { _ = vm.ForceStop() }() + + steps := qemu.DevEnvStages(unattend.SessionUsername(), shareTag, shareDrive) + if err := pr.PhaseDetailed(fmt.Sprintf("Dev environment (%d stages)", len(steps)), func() (string, error) { + runErr := qemu.RunGuestStages(ctx, fin, steps, qemu.StageRunOptions{ + SSHUser: fin.SSHUser, + SSHKeyPath: fin.SSHKeyPath, + LogDir: debugDir, + Observer: obs, + Reboot: func(ctx context.Context, reason string) error { + ux.Debugf("guest reboot requested: %s", reason) + return qemu.GuestReboot(ctx, fin, fin.SSHUser, fin.SSHKeyPath, + sshDeadline, obs, vm.State) + }, + }) + if runErr != nil { + return "", runErr + } + return fmt.Sprintf("%d stages", len(steps)), nil + }); err != nil { + return err + } + + // Guest-clean power-off before the disk is copied: NTFS must be quiesced, + // and a TCG guest can take up to 25 minutes to get there. SIGTERM-ing + // QEMU here would trade a finished build for a dirty image. + if err := pr.PhaseDetailed("Saving base-profile image", func() (string, error) { + offArgv := qemu.BuildSSHExecArgv(fin.SSHHost, fin.SSHPort, fin.SSHUser, fin.SSHKeyPath, + qemu.PowerShellEncodedCommand("Stop-Computer -Force")) + _ = exec.CommandContext(ctx, offArgv[0], offArgv[1:]...).Run() + deadline := time.Now().Add(25 * time.Minute) + for vm.State() == qemu.StateRunning { + if time.Now().After(deadline) { + _ = vm.ForceStop() + return "", fmt.Errorf("guest did not power off in 25m — not saving a dirty image") + } + time.Sleep(5 * time.Second) + } + dest := qemu.BaseProfileImagePath(hostHome, stack, modules) + if err := qemu.SaveBaseProfileImage(buildSpec.DiskPath, dest); err != nil { + return "", err + } + return dest, nil + }); err != nil { + return err + } + return nil +} + +// runWimBuilder boots a builder WinPE that runs DISM offline servicing to +// produce devcell.wim with Hyper-V, WSL2, OpenSSH, and virtio drivers +// (NetKVM, vioserial, vioscsi) baked in. Returns the path to the cached +// devcell.wim on success. +func runWimBuilder(ctx context.Context, templateDir, windowsISO, virtioISO, runDir string, obs qemu.Observer, pwshFiles map[string][]byte) (string, error) { + tmpDir, err := os.MkdirTemp("", "devcell-wim-builder-*") + if err != nil { + return "", fmt.Errorf("creating temp dir: %w", err) + } + defer os.RemoveAll(tmpDir) + + wimDebugDir := filepath.Join(runDir, "wim-builder") + if err := os.MkdirAll(filepath.Join(wimDebugDir, "screenshots"), 0755); err != nil { + return "", fmt.Errorf("creating wim-builder debug dir: %w", err) + } + wimSerialLog := filepath.Join(wimDebugDir, "serial.log") + wimProgressLog := filepath.Join(wimDebugDir, "guest-progress.log") + + // 1. Extract boot.wim and EFI boot files from Windows ISO + stageDir := filepath.Join(tmpDir, "stage") + if err := winpe.ExtractStage(windowsISO, stageDir); err != nil { + return "", fmt.Errorf("extracting WinPE stage: %w", err) + } + + // 2. Extract vioserial + vioscsi drivers for WinPE injection + vioserialDrivers, err := winpe.LoadWinPEVioserialDrivers(virtioISO) + if err != nil { + return "", fmt.Errorf("loading vioserial drivers: %w", err) + } + vioscsiDrivers, err := winpe.LoadWinPEStorageDrivers(virtioISO) + if err != nil { + return "", fmt.Errorf("loading vioscsi drivers: %w", err) + } + + // 3. Read boot.wim and create the shared FAT volume + bootWimPath := filepath.Join(stageDir, "sources", "boot.wim") + bootWimData, err := os.ReadFile(bootWimPath) + if err != nil { + return "", fmt.Errorf("reading boot.wim: %w", err) + } + + // Extract BOOTAA64.EFI for the startup.nsh chainload path. + // EDK2 pflash can't read ISO9660 on SCSI CDs, so the FAT volume + // ships the bootloader and startup.nsh does the chainload. + var efiBootLoader []byte + if bl, err := winpe.InstallerBootloader(windowsISO); err != nil { + ux.Debugf("wim-builder: could not extract BOOTAA64.EFI: %v", err) + } else if _, err := winpe.ValidateBootloaderPE(bl); err != nil { + ux.Debugf("wim-builder: BOOTAA64.EFI validation failed: %v", err) + } else { + efiBootLoader = bl + ux.Debugf("wim-builder: embedded BOOTAA64.EFI (%d bytes) on shared volume", len(bl)) + } + + var ops []winpe.WimPrepOp + ops = append(ops, winpe.HyperVPrepOps()...) + ops = append(ops, winpe.WSL2PrepOps()...) + ops = append(ops, winpe.OpenSSHPrepOps()...) + ops = append(ops, winpe.VirtIODriverPrepOps()...) + cfg := winpe.WimPrepConfig{ + Ops: ops, + } + sharedFiles := winpe.SharedVolumeFiles(cfg, efiBootLoader, pwshFiles) + sharedFiles["/boot.wim"] = bootWimData + + sharedImg := filepath.Join(tmpDir, "shared.qcow2") + if err := qemu.CreateFATQcow2(sharedImg, sharedFiles, 20*1024*1024*1024); err != nil { + return "", fmt.Errorf("creating shared volume: %w", err) + } + + // 4. Inject agent into boot.wim so it boots into the builder + injectDir := filepath.Join(tmpDir, "inject") + if err := os.MkdirAll(injectDir, 0755); err != nil { + return "", fmt.Errorf("creating inject dir: %w", err) + } + + for _, driverSet := range []map[string][]byte{vioserialDrivers, vioscsiDrivers} { + for answerPath, data := range driverSet { + hostPath := filepath.Join(injectDir, filepath.FromSlash(answerPath)) + if err := os.MkdirAll(filepath.Dir(hostPath), 0755); err != nil { + return "", err + } + if err := os.WriteFile(hostPath, data, 0644); err != nil { + return "", err + } + } + } + + payloadCfg := winpe.PayloadConfig{ + WPEInit: true, + ProgressPort: `\\.\Global\` + qemu.ProgressPortName, + PollSeconds: 5, + SyncAgent: true, + } + var driverINFs []string + if len(vioserialDrivers) > 0 { + driverINFs = append(driverINFs, `X:\devcell\drivers\vioserial\vioser.inf`) + } + if len(vioscsiDrivers) > 0 { + driverINFs = append(driverINFs, `X:\devcell\drivers\vioscsi\vioscsi.inf`) + } + payloadCfg.DriverINFs = driverINFs + + for name, gen := range map[string]func() []byte{ + "winpeshl.ini": func() []byte { return winpe.GenerateShellINI_NoSetup() }, + "bootstrap.ps1": func() []byte { return winpe.GenerateBootstrap(payloadCfg) }, + "agent.ps1": func() []byte { return winpe.GenerateAgent(payloadCfg) }, + } { + if err := os.WriteFile(filepath.Join(injectDir, name), gen(), 0644); err != nil { + return "", fmt.Errorf("writing %s: %w", name, err) + } + } + + if err := wim.InjectWinPEPayload(bootWimPath, injectDir); err != nil { + return "", fmt.Errorf("injecting WinPE payload: %w", err) + } + + // 5. Create WinPE ISO + winpeISO := filepath.Join(tmpDir, "winpe-builder.iso") + if err := isokit.CreateWindowsISO(winpeISO, stageDir, "WINPE"); err != nil { + return "", fmt.Errorf("creating WinPE ISO: %w", err) + } + + // 6. Build QEMU command + diskPath := filepath.Join(tmpDir, "scratch.qcow2") + if err := qemu.CreateDisk(diskPath, 8); err != nil { + return "", fmt.Errorf("creating scratch disk: %w", err) + } + + firmwarePath := qemu.FirmwarePath() + varsPath := filepath.Join(tmpDir, "vars.fd") + if err := qemu.PrepareVarsFile(firmwarePath, varsPath); err != nil { + return "", fmt.Errorf("preparing vars: %w", err) + } + + spec := qemu.Spec{ + VMName: "devcell-wim-builder", + CPUs: 2, + MemoryGB: 5, + DiskPath: diskPath, + FirmwarePath: firmwarePath, + VarsPath: varsPath, + QMPSocketDir: tmpDir, + DisplayType: "none", + NoReboot: true, + SerialLogPath: wimSerialLog, + GuestProgressLogPath: wimProgressLog, + CDBus: "scsi", + } + spec.ApplyDefaults() + if err := spec.Validate(); err != nil { + return "", fmt.Errorf("spec: %w", err) + } + + wbs := qemu.WimBuilderSpec{ + Spec: spec, + WinPEISO: winpeISO, + SharedImg: sharedImg, + WindowsISO: windowsISO, + VirtIOISO: virtioISO, + } + argv := qemu.BuildWimBuilderArgv(wbs) + + qemuBin, err := qemu.QEMUBinaryPath() + if err != nil { + return "", err + } + argv[0] = qemuBin + + // 7. Boot builder VM and poll for completion + ux.Debugf("wim-builder: starting QEMU: %s", strings.Join(argv, " ")) + ux.Debugf("wim-builder: serial log: %s", wimSerialLog) + ux.Debugf("wim-builder: screenshots: %s", filepath.Join(wimDebugDir, "screenshots")) + os.WriteFile(filepath.Join(wimDebugDir, "qemu-argv.txt"), []byte(strings.Join(argv, " \\\n ")+"\n"), 0644) + cmd := exec.Command(argv[0], argv[1:]...) + cmd.Stdout = nil + cmd.Stderr = nil + if err := cmd.Start(); err != nil { + return "", fmt.Errorf("starting QEMU: %w", err) + } + killVM := func() { + cmd.Process.Kill() + cmd.Wait() + } + defer killVM() + + qmpSock := qemu.QMPSocketPath(spec) + // Wait for QMP socket + deadline := time.Now().Add(30 * time.Second) + for time.Now().Before(deadline) { + if _, err := os.Stat(qmpSock); err == nil { + break + } + time.Sleep(500 * time.Millisecond) + } + + // Capture periodic screenshots from the wim-builder VM + wimScreenDir := filepath.Join(wimDebugDir, "screenshots") + wimScreenStop := make(chan struct{}) + go func() { + ticker := time.NewTicker(15 * time.Second) + defer ticker.Stop() + for { + select { + case <-wimScreenStop: + return + case <-ticker.C: + ts := time.Now().UTC().Format("20060102T150405Z") + ppmFile := filepath.Join(wimScreenDir, ts+".ppm") + if err := qemu.QMPScreendump(qmpSock, ppmFile); err != nil { + ux.Debugf("wim-builder screenshot failed: %v", err) + continue + } + pngFile := filepath.Join(wimScreenDir, ts+".png") + if err := qemu.ConvertPPMtoPNG(ppmFile, pngFile); err != nil { + ux.Debugf("wim-builder PPM->PNG failed: %v", err) + } else { + os.Remove(ppmFile) + ux.Debugf("wim-builder screenshot: %s", pngFile) + } + } + } + }() + + const ( + overallDeadline = 15 * time.Minute + pollInterval = 15 * time.Second + ) + start := time.Now() + var doneMarker string + for time.Since(start) < overallDeadline { + select { + case <-ctx.Done(): + close(wimScreenStop) + return "", ctx.Err() + case <-time.After(pollInterval): + } + + elapsed := time.Since(start).Round(time.Second) + ux.Debugf("wim-builder: polling for completion (%s elapsed)", elapsed) + + doneMarker = readFATFile(sharedImg, "/"+winpe.WimBuilderDoneFile) + if doneMarker != "" { + ux.Debugf("wim-builder: done marker: %q (after %s)", + strings.TrimSpace(doneMarker), elapsed) + break + } + } + select { + case <-wimScreenStop: + default: + close(wimScreenStop) + } + + cmd.Process.Kill() + cmd.Wait() + + if doneMarker == "" { + return "", fmt.Errorf("builder timed out after %s", overallDeadline) + } + + agentOut := readFATFile(sharedImg, "/"+winpe.AgentResultFile) + ux.Debugf("wim-builder output:\n%s", agentOut) + + result := strings.TrimSpace(doneMarker) + if result != "SUCCESS" { + return "", fmt.Errorf("builder reported %s — DISM offline servicing may not work in WinPE", result) + } + + // 8. Extract devcell.wim from the shared volume and cache it + cachedWim := filepath.Join(templateDir, "devcell.wim") + wimData, err := qemu.ReadFileFromFATQcow2(sharedImg, "/devcell.wim") + if err != nil { + return "", fmt.Errorf("reading devcell.wim from shared volume: %w", err) + } + if err := os.WriteFile(cachedWim, wimData, 0644); err != nil { + return "", fmt.Errorf("caching devcell.wim: %w", err) + } + + // 9. Apply registry patches — DISM created the service entries, now + // set correct Start values so they load at boot. + if err := wim.PatchDevcellWim(cachedWim, 2, wim.HyperVBootPatches()); err != nil { + ux.Debugf("post-DISM registry patching failed: %v — devcell.wim may boot without Hyper-V services", err) + } + + return cachedWim, nil +} + +// readFATFile reads a file from a FAT image (raw or qcow2), returning empty +// string on any error. +func readFATFile(imgPath, filePath string) string { + var data []byte + var err error + if strings.HasSuffix(imgPath, ".qcow2") { + data, err = qemu.ReadFileFromFATQcow2(imgPath, filePath) + } else { + data, err = isokit.ReadFileFromFAT(imgPath, filePath) + } + if err != nil { + return "" + } + return string(data) +} + +// winpeAgentDebugEnabled reports whether the debug WinPE agent should ship +// on the answer volume (DEVCELL_QEMU_WINPE_AGENT=1, set by +// `task debug:autobuild`). +func winpeAgentDebugEnabled(getenv func(string) string) bool { + return getenv("DEVCELL_QEMU_WINPE_AGENT") == "1" +} diff --git a/cmd/build_qemu_budget.go b/cmd/build_qemu_budget.go new file mode 100644 index 0000000..422807a --- /dev/null +++ b/cmd/build_qemu_budget.go @@ -0,0 +1,189 @@ +//go:build darwin || linux + +package main + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/devcell-sh/go-winkit/unattend" + "github.com/devcell-sh/go-winkit/winpe" + + "github.com/DimmKirr/devcell/internal/cfg" + "github.com/DimmKirr/devcell/internal/ux" + "github.com/DimmKirr/devcell/internal/vm/qemu" +) + +// runtimeGOOS is a variable so tests can resolve a budget for the other +// platform without cross-compiling. +var runtimeGOOS = runtime.GOOS + +// installStallWindow is how long the guest may write nothing before the build +// calls it dead. Generous on purpose: under TCG Windows goes quiet for minutes +// at a time between phases, and a false stall throws away a real install. Ten +// times any pause observed in a healthy run. +const installStallWindow = 20 * time.Minute + +// qemuDiagnosticPaths returns where the build records the two channels a guest +// can talk on before it has a network: the firmware's serial console and the +// guest's own virtio-serial progress port. +// +// Both live beside the screenshots in the project's debug directory, so +// everything about a failed build is in one place. +func qemuDiagnosticPaths(debugDir string) (serial, guestProgress string) { + return filepath.Join(debugDir, "serial.log"), filepath.Join(debugDir, "guest-progress.log") +} + +// formatAllocatedPorts renders the ports a build took. +// +// The allocator starts from the preferred port and walks past anything already +// bound, so these are a result, not a constant — and every later question +// ("why did SSH not answer?", "which VM is on 10023?") starts from knowing +// them. +func formatAllocatedPorts(p qemu.AllocatedPorts) string { + return fmt.Sprintf("ssh=%s vnc=%s rdp=%s", p.SSHPort, p.VNCPort, p.RDPPort) +} + +// qemuBuildDisplay is the QEMU display backend the build renders to. +// +// Headless by default — most builds run without an X server, and one that dies +// because it cannot open a window is worse than one nobody watches. But when a +// display is configured, honour it: `cell shell` already does, and the same +// setting meaning two different things depending on the subcommand is a trap. +// With DEVCELL_QEMU_DISPLAY=gtk the install is a real window with working +// keyboard and mouse, instead of a series of screendumps. +func qemuBuildDisplay(cellCfg cfg.CellSection) string { + return cellCfg.ResolvedQemuDisplay() +} + +// qemuBuildSpecPorts puts every allocated port on the spec. +// +// Allocating a port and not forwarding it is worse than not allocating it: the +// build printed rdp=10089, reserved it against other cells, and forwarded +// nothing, so `cell rdp` failed against a guest whose RDP service was running +// fine. command.go only adds the 3389 forward when RDPPort is set. +func qemuBuildSpecPorts(ports qemu.AllocatedPorts) qemu.Spec { + return qemu.Spec{ + SSHPort: ports.SSHPortUint16(), + VNCPort: ports.VNCPortUint16(), + RDPPort: ports.RDPPortUint16(), + } +} + +// qemuKeyDir is where a cell's VM SSH keypair lives. +// +// ~/.devcell//.ssh — per cell, and engine-neutral: libvirt boots the same +// templates with the same keys, so naming the directory after qemu was an +// accident of which engine needed keys first. +// +// A cell that already has a key under the legacy qemu/ path keeps it. The +// public half is baked into a built template, so relocating the private half +// would leave a multi-hour template nothing can log into. +func qemuKeyDir(home, cellName string) string { + legacy := filepath.Join(home, ".devcell", cellName, "qemu") + if _, err := os.Stat(filepath.Join(legacy, "id_ed25519")); err == nil { + return legacy + } + return filepath.Join(home, ".devcell", cellName, ".ssh") +} + +// formatProvisionStep renders one provisioning attempt. +// +// Pass/fail alone cannot distinguish a slow step from a stuck one, and under +// TCG that is the whole diagnosis: `Add-WindowsCapability` legitimately runs +// for over an hour while emitting nothing. The duration is what tells an +// operator whether to wait or intervene, and a failure carries its cause on the +// same line so the reason never has to be correlated across entries. +func formatProvisionStep(name string, attempt, attempts int, took time.Duration, err error) string { + status := "ok" + if err != nil { + status = "FAILED: " + err.Error() + } + return fmt.Sprintf("provision %s [%d/%d] %s — %s", + name, attempt, attempts, took.Round(time.Second), status) +} + +// qemuBuildSSHUser is the guest account the build authenticates as. +// +// It must track the answer file, which creates unattend.SessionUsername(): the +// guest bootstrap writes the SSH key into that account's authorized_keys (and +// the administrators file it belongs to), so any other name is a guaranteed +// publickey rejection. +func qemuBuildSSHUser() string { + return unattend.SessionUsername() +} + +// dumpGuestLogs prints everything the guest wrote to the answer volume. +// +// When the install fails, the guest usually cannot talk to us — no network, no +// SSH, no agent — and the FAT answer volume is the only channel left. Printing +// it here means a failed `cell build` explains itself on stdout instead of +// leaving an image file for someone to mount by hand. +func dumpGuestLogs(answerImagePath string) { + logs := winpe.CollectGuestLogs(answerImagePath) + fmt.Printf("\n%s\n", ux.StyleSection.Render(" Guest logs (read from the answer volume)")) + fmt.Print(winpe.FormatGuestLogs(logs)) +} + +// qemuBuildResources is what the accelerator choice implies for the rest of the +// build: how much guest RAM to give it and how long to wait for the guest to +// answer SSH. +type qemuBuildResources struct { + Accel string + AccelReason string + MemoryGB uint64 + SSHDeadline time.Duration + DiskCacheMode string +} + +// Hardware-virtualization budget. A Windows install under HVF/KVM finishes in +// 20-40 minutes and 4 GB is comfortable. +const ( + acceleratedMemoryGB = 4 + acceleratedSSHDeadline = 45 * time.Minute +) + +// Software-emulation budget. TCG runs roughly 20x slower: a full install +// measured 2h42m in this project's own test runs, so a 45-minute deadline +// expires while Setup is still applying the image. Memory is 8 GB rather than +// 4 because QEMU's RSS under TCG runs well past guest RAM (translation buffers +// plus block cache) — 4 GB starves the install, and values above 8 risk the +// OOM killer on a shared host. +const ( + emulatedMemoryGB = 8 + emulatedSSHDeadline = 5 * time.Hour +) + +// qemuBuildBudget resolves the accelerator and scales the build to it. +// +// The accelerator is not a detail the rest of the build can ignore: the same +// install is a 30-minute job on HVF and a 3-hour job under TCG, and a plan that +// quotes hardware numbers while running emulated fails at the SSH wait with no +// hint that the deadline, not the guest, was wrong. +func qemuBuildBudget(cellCfg cfg.CellSection) qemuBuildResources { + explicit := os.Getenv("DEVCELL_QEMU_ACCEL") + if explicit != "" { + ux.Debugf("DEVCELL_QEMU_ACCEL=%s — overriding auto-detected accelerator", explicit) + } + accel, reason := qemu.ResolveAccel(explicit, cellCfg.ResolvedKVM(), runtimeGOOS, qemu.ProbeKVM) + r := qemuBuildResources{ + Accel: accel, + AccelReason: reason, + MemoryGB: acceleratedMemoryGB, + SSHDeadline: acceleratedSSHDeadline, + } + if strings.HasPrefix(accel, "tcg") { + r.MemoryGB = emulatedMemoryGB + r.SSHDeadline = emulatedSSHDeadline + r.DiskCacheMode = "unsafe" + // Windows has a large code footprint and TCG re-translates whatever + // falls out of its 32MB default cache — pure waste on a multi-hour + // install. Measured worth it only under emulation. + r.Accel = accel + ",tb-size=512" + } + return r +} diff --git a/cmd/build_qemu_stub.go b/cmd/build_qemu_stub.go new file mode 100644 index 0000000..e41e934 --- /dev/null +++ b/cmd/build_qemu_stub.go @@ -0,0 +1,14 @@ +//go:build !(darwin || linux) + +package main + +import ( + "fmt" + "runtime" + + "github.com/DimmKirr/devcell/internal/cfg" +) + +func runBuildQemu(cellName, hostHome, baseDir, stack string, force, noCache, dryRun bool, _ cfg.CellSection) error { + return fmt.Errorf("cell build --engine=qemu requires macOS or Linux (current: %s/%s)", runtime.GOOS, runtime.GOARCH) +} diff --git a/cmd/build_qemu_test.go b/cmd/build_qemu_test.go new file mode 100644 index 0000000..6e7d7a1 --- /dev/null +++ b/cmd/build_qemu_test.go @@ -0,0 +1,142 @@ +package main_test + +import ( + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strings" + "testing" +) + +// The qemu engine used to be gated to darwin/arm64 at compile time, so the only +// way to exercise a Windows install in a Linux dev container was to bypass the +// CLI and drive internal/vm/qemu directly from a test. That divergence is +// exactly the kind that hides bugs: the argv, spec and provisioning the test +// proved were never the ones `cell build` builds. +// +// The engine is portable — PreflightCheck already accepts linux, FirmwarePath +// already resolves a Linux firmware, and QEMUBinaryPath is a PATH lookup — so +// the gate belongs at runtime (no accelerator, wrong arch), not at compile time. +func TestBuildQemu_RunsOnLinuxNotJustDarwin(t *testing.T) { + if runtime.GOOS != "linux" && runtime.GOOS != "darwin" { + t.Skipf("qemu engine is supported on linux and darwin, not %s", runtime.GOOS) + } + bin := buildCellBinary(t) + + out, _ := runCell(t, bin, t.TempDir(), "build", "--engine=qemu", "--dry-run") + + assertNotContains(t, out, "requires macOS on Apple Silicon", + "the qemu engine must not refuse to run on this platform") + assertContains(t, out, "Windows VM template", + "--dry-run must describe the template it would build") +} + +// A Linux dev container has no /dev/kvm (Docker Desktop cannot provide it), so +// the engine falls back to TCG. TCG is ~20x slower: the darwin-tuned 45-minute +// SSH deadline expires mid-install, and 4 GB was already shown to invite the +// OOM killer under TCG's translation overhead. The plan must say so. +func TestBuildQemu_DryRunReportsAcceleratorAndTCGBudget(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("TCG budget reporting is only interesting where hardware virt is absent") + } + bin := buildCellBinary(t) + + out, _ := runCell(t, bin, t.TempDir(), "build", "--engine=qemu", "--dry-run", "--debug") + + assertContains(t, out, "Accelerator:", "the plan must name the accelerator it resolved") + if strings.Contains(out, "tcg") { + assertContains(t, out, "SSH deadline:", + "a TCG build must state the deadline it allows for the install") + } +} + +// --- helpers --------------------------------------------------------------- + +// buildCellBinary compiles the real CLI once per test binary. Tests that assert +// on CLI behaviour must run the CLI: asserting on the functions behind it is +// how the engine drifted from the test in the first place. +func buildCellBinary(t *testing.T) string { + t.Helper() + bin := filepath.Join(t.TempDir(), "cell") + cmd := exec.Command("go", "build", "-o", bin, ".") + cmd.Dir = repoRootFromTest(t) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("building cell binary: %v\n%s", err, out) + } + return bin +} + +func repoRootFromTest(t *testing.T) string { + t.Helper() + wd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + // cmd/ is the main package directory. + return wd +} + +func runCell(t *testing.T, bin, projectDir string, args ...string) (string, error) { + t.Helper() + cmd := exec.Command(bin, args...) + cmd.Dir = projectDir + cmd.Env = append(os.Environ(), "HOME="+t.TempDir()) + out, err := cmd.CombinedOutput() + return string(out), err +} + +func assertContains(t *testing.T, haystack, needle, msg string) { + t.Helper() + if !strings.Contains(haystack, needle) { + t.Errorf("%s\nwant substring: %q\ngot:\n%s", msg, needle, haystack) + } +} + +func assertNotContains(t *testing.T, haystack, needle, msg string) { + t.Helper() + if strings.Contains(haystack, needle) { + t.Errorf("%s\nunwanted substring: %q\ngot:\n%s", msg, needle, haystack) + } +} + +// TCG needs 8 GB: QEMU's RSS under TCG runs well past guest RAM (translation +// buffers + block cache), and 4 GB starved the install. Values above 8 risked +// the OOM killer on shared hosts, so 8 is the sweet spot. +func TestTCGBudget_Allocates8GB(t *testing.T) { + src, err := os.ReadFile("build_qemu_budget.go") + if err != nil { + t.Fatalf("reading build_qemu_budget.go: %v", err) + } + if !regexp.MustCompile(`emulatedMemoryGB\s*=\s*8\b`).Match(src) { + t.Error("emulatedMemoryGB must be 8 — TCG needs the headroom for translation buffers") + } +} + +// TCG builds must use cache=unsafe to eliminate sync flushes that are pure +// waste under software emulation (no data-integrity benefit in a build VM +// that gets discarded on failure). +func TestQEMUBuildSpec_SetsDiskCacheModeForTCG(t *testing.T) { + src, err := os.ReadFile("build_qemu.go") + if err != nil { + t.Fatalf("reading build_qemu.go: %v", err) + } + if !regexp.MustCompile(`DiskCacheMode:`).Match(src) { + t.Error("cell build must set Spec.DiskCacheMode so TCG builds use cache=unsafe") + } +} + +// The machine features Windows' hypervisor needs must be set by the CLI, not +// only by the dev-env test: `cell build --engine=qemu` is what users run, and +// a guest built without them cannot start WSL2 no matter what the test proves. +func TestQEMUBuildSpec_RequestsNestedVirt(t *testing.T) { + src, err := os.ReadFile("build_qemu.go") + if err != nil { + t.Fatalf("reading build_qemu.go: %v", err) + } + // Match the field, not gofmt's alignment. + if !regexp.MustCompile(`NestedVirt:\s+true`).Match(src) { + t.Error("cell build must set Spec.NestedVirt so the guest can host a hypervisor") + } +} diff --git a/cmd/build_qemu_user_test.go b/cmd/build_qemu_user_test.go new file mode 100644 index 0000000..a49defd --- /dev/null +++ b/cmd/build_qemu_user_test.go @@ -0,0 +1,224 @@ +//go:build darwin || linux + +package main + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/devcell-sh/go-winkit/unattend" + + "github.com/DimmKirr/devcell/internal/cfg" + "github.com/DimmKirr/devcell/internal/vm/qemu" +) + +// The account the build connects to must be the account the answer file +// creates. autounattend.xml creates unattend.SessionUsername() — the host's $USER, +// mirroring HOST_USER in every other engine — and the guest bootstrap +// authorizes the SSH key for that account and no other. +// +// Hardcoding "devcell" here meant a build that installed Windows perfectly, +// reached SSH, and then failed every provisioning step with +// +// devcell@127.0.0.1: Permission denied (publickey,password,keyboard-interactive) +// +// after a 2h47m install (run 20260730T222409). +func TestQemuBuildSSHUser_MatchesTheAccountTheAnswerFileCreates(t *testing.T) { + t.Setenv("USER", "dmitry") + + if got, want := qemuBuildSSHUser(), unattend.SessionUsername(); got != want { + t.Errorf("build connects as %q but the guest account is %q — provisioning cannot authenticate", got, want) + } +} + +// With no $USER to mirror, both sides must still agree — on the default. +func TestQemuBuildSSHUser_FallsBackToTheDefaultSessionUser(t *testing.T) { + os.Unsetenv("USER") + t.Setenv("USER", "") + + if got, want := qemuBuildSSHUser(), unattend.DefaultSessionUser; got != want { + t.Errorf("with no $USER the build must connect as %q, got %q", want, got) + } +} + +// The firmware talks on the serial port and nowhere else. Every boot-level root +// cause found in this project came from that log — the cdboot stack overflow, +// "Image type X64 can't be loaded", the wrong-device boot that parked at +// "Start boot option". A build that does not capture it leaves the one class of +// failure it cannot otherwise explain completely invisible. +// +// The guest's own progress channel (virtio-serial) is the matching outbound path: +// it is the only way a guest with no network reports on itself while installing. +func TestQemuDiagnosticPaths_CaptureSerialAndGuestProgress(t *testing.T) { + serial, guestProgress := qemuDiagnosticPaths("/project/.scratch/debug/20260101T000000Z") + + if serial == "" { + t.Fatal("the build must capture firmware serial output") + } + if guestProgress == "" { + t.Fatal("the build must capture the guest progress channel") + } + if serial == guestProgress { + t.Errorf("the two channels must not share a file: %s", serial) + } + for _, p := range []string{serial, guestProgress} { + if !strings.HasPrefix(p, "/project/.scratch/debug/") { + t.Errorf("diagnostics belong under the debug directory, got %s", p) + } + } +} + +// Which ports a build actually took is not cosmetic: the allocator walks past +// anything already bound, so the SSH port is 10022 only when nothing else holds +// it. When a build fails, "which port was this VM on?" decides whether you are +// looking at the right VM at all — and until now it appeared only inside a +// debug line about waiting for SSH. +func TestFormatAllocatedPorts_NamesEveryPortTheBuildTook(t *testing.T) { + summary := formatAllocatedPorts(qemu.AllocatedPorts{ + SSHPort: "10023", VNCPort: "10050", RDPPort: "10089", + }) + + for _, want := range []string{"ssh=10023", "vnc=10050", "rdp=10089"} { + if !strings.Contains(summary, want) { + t.Errorf("port summary must state %s, got %q", want, summary) + } + } +} + +// A build that renders nowhere is the right default for CI, but it makes a +// desktop user watch a 3-hour install through periodic screendumps. The runner +// already honours `[cell] qemu_display` / DEVCELL_QEMU_DISPLAY; the build +// hardcoded "none", so the same config meant two different things depending on +// which command you ran. +func TestQemuBuildDisplay_HonoursTheConfiguredDisplay(t *testing.T) { + t.Setenv("DEVCELL_QEMU_DISPLAY", "gtk") + + if got := qemuBuildDisplay(cfg.CellSection{}); got != "gtk" { + t.Errorf("build must honour the configured display, got %q", got) + } +} + +// Headless stays the default: most builds run without an X server, and a build +// that dies because it cannot open a window is worse than one you cannot watch. +func TestQemuBuildDisplay_DefaultsToHeadless(t *testing.T) { + t.Setenv("DEVCELL_QEMU_DISPLAY", "") + + if got := qemuBuildDisplay(cfg.CellSection{}); got != "none" { + t.Errorf("build must default to headless, got %q", got) + } +} + +// The build allocates an ssh/vnc/rdp trio but only ever set SSHPort on the +// spec, and command.go adds the 3389 forward only when RDPPort > 0. So the +// build reserved rdp=10089, printed it, and forwarded nothing — `cell rdp` +// against a build VM could not connect, while the guest's RDP service was +// running and answering (verified: TLS negotiated over a manual hostfwd_add). +func TestQemuBuildPorts_ForwardEveryPortTheyAllocate(t *testing.T) { + ports := qemu.AllocatedPorts{SSHPort: "10022", VNCPort: "10050", RDPPort: "10089"} + + spec := qemuBuildSpecPorts(ports) + + if spec.SSHPort == 0 { + t.Error("ssh must be forwarded") + } + if spec.RDPPort == 0 { + t.Error("rdp must be forwarded — a reserved port nothing listens on is worse than none") + } + if spec.VNCPort == 0 { + t.Error("vnc must be set so QEMU serves the console itself") + } +} + +// Keys lived at ~/.devcell//qemu/, but they are not qemu's: libvirt reuses +// the same pair, and .ssh is where anyone looks first. The engine name in the +// path was an accident of which engine happened to need keys first. +func TestQemuKeyDir_PrefersDotSSH(t *testing.T) { + home := t.TempDir() + + dir := qemuKeyDir(home, "DIMM") + + if want := filepath.Join(home, ".devcell", "DIMM", ".ssh"); dir != want { + t.Errorf("new cells must use %s, got %s", want, dir) + } +} + +// A template already built has the old key baked into the guest. Moving the +// path must not orphan it — that would silently turn a 3-hour template into +// one nothing can log into. +func TestQemuKeyDir_KeepsUsingTheLegacyPathWhenAKeyIsAlreadyThere(t *testing.T) { + home := t.TempDir() + legacy := filepath.Join(home, ".devcell", "DIMM", "qemu") + if err := os.MkdirAll(legacy, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(legacy, "id_ed25519"), []byte("key"), 0o600); err != nil { + t.Fatal(err) + } + + if dir := qemuKeyDir(home, "DIMM"); dir != legacy { + t.Errorf("an existing key must keep its path, got %s", dir) + } +} + +// Every qemu template path passes modules=nil, so two cells on the same stack +// with different module sets resolve to one disk-base.qcow2 and one +// .provisioned marker. The first build wins; the second either reuses a +// template missing its modules or, with --force, destroys the first. StackTag +// exists precisely to keep them apart — tart passes modules, qemu does not. +func TestQemuTemplatePaths_SeparateTemplatesPerModuleSet(t *testing.T) { + home := t.TempDir() + + bare := qemu.TemplateDir(home, "base", nil) + withMods := qemu.TemplateDir(home, "base", []string{"docker", "node"}) + + if bare == withMods { + t.Fatalf("module sets must not share a template dir: both resolved to %s", bare) + } + if qemu.ImageName("base", nil) == qemu.ImageName("base", []string{"docker", "node"}) { + t.Error("module sets must not share a disk image name") + } + if qemu.ProvisionedMarker(home, "base", nil) == qemu.ProvisionedMarker(home, "base", []string{"docker", "node"}) { + t.Error("module sets must not share a provisioned marker") + } +} + +// Module order is not meaningful, so it must not fork the template. +func TestQemuTemplatePaths_ModuleOrderDoesNotMatter(t *testing.T) { + home := t.TempDir() + + if a, b := qemu.TemplateDir(home, "base", []string{"node", "docker"}), + qemu.TemplateDir(home, "base", []string{"docker", "node"}); a != b { + t.Errorf("the same modules in a different order must be one template: %s vs %s", a, b) + } +} + +// Provisioning reports only pass/fail today, so a step that takes an hour and a +// step that fails instantly read the same in the log. Under TCG the difference +// between "slow" and "stuck" is the whole diagnosis, and on 2026-07-31 a step +// that had already FAILED was reported as "still running" for three hours. +func TestFormatProvisionStep_NamesStepAttemptAndDuration(t *testing.T) { + line := formatProvisionStep("Install dev tools", 2, 3, 95*time.Second, nil) + + for _, want := range []string{"Install dev tools", "2/3", "1m35s"} { + if !strings.Contains(line, want) { + t.Errorf("step line must state %q, got %q", want, line) + } + } +} + +// A failure must carry its cause on the same line — the reason a build failed +// should not require correlating two log entries. +func TestFormatProvisionStep_CarriesTheFailureCause(t *testing.T) { + line := formatProvisionStep("Configure SSH", 1, 3, time.Second, errors.New("exit status 255")) + + if !strings.Contains(line, "exit status 255") { + t.Errorf("a failed step must state its error, got %q", line) + } + if !strings.Contains(strings.ToUpper(line), "FAIL") { + t.Errorf("a failed step must be visibly a failure, got %q", line) + } +} diff --git a/cmd/build_qemu_winpe_diag_test.go b/cmd/build_qemu_winpe_diag_test.go new file mode 100644 index 0000000..1671dae --- /dev/null +++ b/cmd/build_qemu_winpe_diag_test.go @@ -0,0 +1,22 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// DEVCELL_QEMU_WINPE_AGENT=1 (set by `task debug:autobuild`) ships the WinPE +// agent and a one-shot diagnostic command whose output the agent writes to +// devcell-out.txt on the answer volume — the only look inside a WinPE where +// the $WinPEDriver$ vioscsi load may have failed silently (CELL-429, run +// 20260812T141319). +func TestWinPEAgentDebugEnabled(t *testing.T) { + assert.True(t, winpeAgentDebugEnabled(func(k string) string { + if k == "DEVCELL_QEMU_WINPE_AGENT" { + return "1" + } + return "" + })) + assert.False(t, winpeAgentDebugEnabled(func(string) string { return "" })) +} diff --git a/cmd/build_tart_darwin.go b/cmd/build_tart_darwin.go index d2e9fdd..a451c10 100644 --- a/cmd/build_tart_darwin.go +++ b/cmd/build_tart_darwin.go @@ -14,6 +14,7 @@ import ( "github.com/DimmKirr/devcell/internal/runner" "github.com/DimmKirr/devcell/internal/ux" + "github.com/DimmKirr/devcell/internal/version" "github.com/DimmKirr/devcell/internal/vm/tart" ) @@ -22,7 +23,7 @@ import ( // Mirrors the Docker build flow: init scaffolds config/keys (no images), // build creates and provisions the image. The VM is booted for provisioning // and shut down when done — cell shell starts it again for the session. -func runBuildTart(cellName, hostHome, projectDir, stack string, modules []string, nixhomePath string, force, noCache, dryRun bool, tartOCIImage string) error { +func runBuildTart(cellName, hostHome, projectDir, stack string, modules []string, force, noCache, dryRun bool, tartOCIImage string) error { cfg := tart.BuildConfig{ CellName: cellName, HomeDir: hostHome, @@ -37,18 +38,12 @@ func runBuildTart(cellName, hostHome, projectDir, stack string, modules []string templateName := tart.TemplateVMName(stack, modules) buildVM := "devcell-build-tmp" - // Resolve nixhome to absolute — tart --dir requires paths the host can resolve. - if !filepath.IsAbs(nixhomePath) { - abs, err := filepath.Abs(nixhomePath) - if err == nil { - nixhomePath = abs - } - } + nixhomeRef := runner.ResolveNixhomeRef(version.Version) ux.Debugf("build config: cell=%s stack=%s cpus=%d mem=%dGB sshPort=%d", cfg.CellName, cfg.Stack, cfg.CPUs, cfg.MemoryGB, cfg.SSHPort) ux.Debugf("template: %s buildVM: %s force=%v noCache=%v", templateName, buildVM, force, noCache) - ux.Debugf("nixhome: %s projectDir: %s", nixhomePath, projectDir) + ux.Debugf("nixhome: %s projectDir: %s", nixhomeRef, projectDir) if dryRun { fmt.Printf("Would build macOS VM template: %s\n", templateName) @@ -69,7 +64,7 @@ func runBuildTart(cellName, hostHome, projectDir, stack string, modules []string if _, err := os.Stat(sshPaths.PrivateKey); err != nil { ux.Debugf("SSH key not found at %s — running auto-init", sshPaths.PrivateKey) fmt.Println(ux.StyleSection.Render(" SSH keys not found — running init")) - if initErr := runInitTart(cellName, hostHome, projectDir, stack, nixhomePath, false, false); initErr != nil { + if initErr := runInitTart(cellName, hostHome, projectDir, stack, false, false); initErr != nil { return fmt.Errorf("auto-init failed: %w", initErr) } } @@ -92,7 +87,7 @@ func runBuildTart(cellName, hostHome, projectDir, stack string, modules []string // --- Platform compatibility preflight --- if err := pr.PhaseDetailed("Platform compatibility check", func() (string, error) { - flakeRef := "path:" + nixhomePath + flakeRef := runner.ResolveNixhomeRef(version.Version) if err := runner.PreflightPlatformCheck(ctx, flakeRef, "aarch64-darwin"); err != nil { return "", err } @@ -190,15 +185,9 @@ func runBuildTart(cellName, hostHome, projectDir, stack string, modules []string getOut, _ := exec.CommandContext(ctx, "tart", "get", buildVM).CombinedOutput() ux.Debugf("tart get %s (pre-boot):\n%s", buildVM, string(getOut)) } - if info, err := os.Stat(nixhomePath); err != nil { - ux.Debugf("WARNING: nixhomePath stat failed: %v", err) - } else { - ux.Debugf("nixhomePath verified: dir=%v mode=%s", info.IsDir(), info.Mode()) - } - // --- Phase 4: Boot VM --- sharedDirs := map[string]string{ - "nixhome": nixhomePath, + "nixhome": nixhomeRef, "home": cellHome, } disks := []string{nixVolumePath} diff --git a/cmd/build_tart_stub.go b/cmd/build_tart_stub.go index 6344571..9014b15 100644 --- a/cmd/build_tart_stub.go +++ b/cmd/build_tart_stub.go @@ -4,6 +4,6 @@ package main import "fmt" -func runBuildTart(cellName, hostHome, projectDir, stack string, modules []string, nixhomePath string, force, noCache, dryRun bool, tartOCIImage string) error { +func runBuildTart(cellName, hostHome, projectDir, stack string, modules []string, force, noCache, dryRun bool, tartOCIImage string) error { return fmt.Errorf("cell build --engine=tart requires macOS on Apple Silicon (darwin/arm64)") } diff --git a/cmd/build_test.go b/cmd/build_test.go index bb63cd9..1d19e04 100644 --- a/cmd/build_test.go +++ b/cmd/build_test.go @@ -81,8 +81,9 @@ func TestUserImageTag_BareLocal_PureSuffixOnVariant(t *testing.T) { } // PickImageTag — post-flip direction (CELL-183) + CELL-165 vocab: -// false (default) → pure tag -// true (--impure, alias --debian) → bare tag +// +// false (default) → pure tag +// true (--impure, alias --debian) → bare tag func TestPickImageTag_FlippedDirection(t *testing.T) { saved := os.Getenv("DEVCELL_USER_IMAGE") defer os.Setenv("DEVCELL_USER_IMAGE", saved) diff --git a/cmd/chrome.go b/cmd/chrome.go index cf1e26f..baaf976 100644 --- a/cmd/chrome.go +++ b/cmd/chrome.go @@ -13,14 +13,15 @@ import ( "time" "github.com/DimmKirr/devcell/internal/config" + "github.com/DimmKirr/devcell/internal/telemetry" "github.com/DimmKirr/devcell/internal/ux" "github.com/spf13/cobra" ) var ( - chromeSyncOnly bool - chromeNoSync bool - chromeForce bool + chromeSyncOnly bool + chromeNoSync bool + chromeForce bool ) var chromeCmd = &cobra.Command{ @@ -32,6 +33,16 @@ cookies are exported as a Playwright storage-state.json that the cell mounts read-only — so authenticated sessions carry over to browser automation inside the container. +Cookie flow: + + 1. You log in via a clean host-side Chrome (no CDP, no bot detection). + 2. Cookies are extracted via CDP into ~/.devcell//.playwright/storage-state.json. + 3. patchright MCP is killed in every running cell that shares this cell-home. + 4. Claude's MCP client respawns patchright, which reads --storage-state from + the fresh file and injects cookies into the in-memory BrowserContext. + The container's Chromium profile (~/.chrome//) receives the cookies + at runtime via Playwright — no file copy into the profile directory. + Each app-name gets its own isolated Chrome profile stored at ~/.devcell//.chrome//. When only one cell is running the app-name is optional. Pass a URL after -- to land directly on a @@ -80,6 +91,8 @@ func chromeBinary() (string, error) { func runChrome(cmd *cobra.Command, args []string) error { applyOutputFlagsWithLog("chrome") + telemetry.Track("auth_chrome", map[string]any{"sync_only": chromeSyncOnly, "no_sync": chromeNoSync, "force": chromeForce}) + c, err := config.LoadFromOS() if err != nil { return fmt.Errorf("load config: %w", err) @@ -125,8 +138,6 @@ func runChrome(cmd *cobra.Command, args []string) error { return nil } - ux.Info("Cookies ready. Use Playwright to browse with your authenticated session.") - return nil } @@ -153,8 +164,8 @@ type localStorageEntry struct { } type storageState struct { - Cookies []storageStateCookie `json:"cookies"` - Origins []storageStateOrigin `json:"origins"` + Cookies []storageStateCookie `json:"cookies"` + Origins []storageStateOrigin `json:"origins"` } // openExtractAndClose opens Chrome for the user to log in (no CDP, no special @@ -250,6 +261,7 @@ func openExtractAndClose(profile, storageStatePath string, urls []string, noSync sp.Fail(fmt.Sprintf("cookie extraction failed: %v", err)) } else { sp.Success(fmt.Sprintf("Exported %d cookies for %s", count, sites)) + ux.Info(fmt.Sprintf("Cookies saved to %s", storageStatePath)) // Kick patchright MCP in every running cell that shares this // cell-home bind mount — they cached the pre-relog @@ -270,7 +282,11 @@ func openExtractAndClose(profile, storageStatePath string, urls []string, noSync killMcp: dockerKillPatchrightMcp, }) if len(kicked) > 0 { - ux.Debugf("kicked patchright MCP in %d cell(s): %v", len(kicked), kicked) + containerProfile := "/home/" + kickHostUser + "/.chrome/${APP_NAME:-cell}" + ux.Info(fmt.Sprintf("Restarted patchright MCP in %d cell(s) via `docker exec pkill -f mcp-server-patchright`", len(kicked))) + ux.Info(fmt.Sprintf("MCP will inject cookies into %s on next browser tool call", containerProfile)) + } else { + ux.Info("No running cells found — cookies will be loaded on next cell start") } } } diff --git a/cmd/chrome_kick_mcp.go b/cmd/chrome_kick_mcp.go index 993af7a..50e2f20 100644 --- a/cmd/chrome_kick_mcp.go +++ b/cmd/chrome_kick_mcp.go @@ -10,11 +10,11 @@ import ( // kickDeps wires the docker dependencies for kickMcpInCellsSharingCellHome. // Tests inject pure-Go fakes so they don't shell out. type kickDeps struct { - cellHome string // host path, e.g. /Users/dmitry/.devcell/FAM - hostUser string // session user, e.g. dmitry - listContainers func() ([]string, error) // list running cell-* container IDs + cellHome string // host path, e.g. /Users/dmitry/.devcell/FAM + hostUser string // session user, e.g. dmitry + listContainers func() ([]string, error) // list running cell-* container IDs mountSource func(id string) (string, error) // resolve a container's /home/ mount source - killMcp func(id string) error // pkill -f mcp-server-patchright inside the container + killMcp func(id string) error // pkill -f mcp-server-patchright inside the container } // kickMcpInCellsSharingCellHome SIGTERMs patchright in every running cell that diff --git a/cmd/claude.go b/cmd/claude.go index b78f579..426dbaf 100644 --- a/cmd/claude.go +++ b/cmd/claude.go @@ -26,6 +26,10 @@ Use --ollama to route Claude Code through a local ollama instance to point at ollama on the host. Can also be enabled permanently via use_ollama = true in the [llm] section of devcell.toml. +Use --openrouter to route Claude Code through OpenRouter. Requires +OPENROUTER_API_KEY env var. Can also be enabled permanently via +use_openrouter = true in the [llm] section of devcell.toml. + The model is resolved in order: 1. [llm.models] default in devcell.toml (e.g. "ollama/qwen3:30b") 2. Best-ranked model from the running ollama instance (auto-detect) @@ -34,7 +38,8 @@ Examples: cell claude cell claude --resume - cell claude --ollama`, + cell claude --ollama + cell claude --openrouter`, DisableFlagParsing: true, RunE: func(cmd *cobra.Command, args []string) error { return runAgent("claude", []string{"--dangerously-skip-permissions"}, args, claudeEnv()) @@ -45,42 +50,131 @@ Examples: // When --ollama flag or [llm] use_ollama=true is set, it injects env vars // that redirect Claude Code's API calls to a local ollama instance and // sets ANTHROPIC_MODEL to the configured or best-available model. +// When --openrouter flag or [llm] use_openrouter=true is set, it injects +// env vars that redirect Claude Code's API calls through OpenRouter. func claudeEnv() map[string]string { dbg := scanFlag("--debug") useOllama := scanFlag("--ollama") + useOpenRouter := scanFlag("--openrouter") - // Always load config — needed for both use_ollama and model selection. + // Always load config — needed for use_ollama, use_openrouter, and model selection. var configModel string + var models cfg.LLMModelsSection c, err := config.LoadFromOS() if err == nil { cellCfg := cfg.LoadFromOS(c.ConfigDir, c.BaseDir) if !useOllama { useOllama = cellCfg.LLM.UseOllama } + if !useOpenRouter { + useOpenRouter = cellCfg.LLM.UseOpenRouter + } configModel = cellCfg.LLM.Models.Default + models = cellCfg.LLM.Models + } + + // Base env vars for all claude sessions. + env := map[string]string{} + + if useOpenRouter { + for k, v := range openrouterEnv(configModel, models, dbg) { + env[k] = v + } + return env } if !useOllama { - return nil + return env } if dbg { fmt.Fprintf(os.Stderr, " claude: ollama mode enabled, redirecting API to host ollama\n") } + env["ANTHROPIC_BASE_URL"] = "http://host.docker.internal:11434" + env["ANTHROPIC_AUTH_TOKEN"] = "ollama" + env["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] = "1" + + if model := resolveOllamaModel(configModel, dbg); model != "" { + env["ANTHROPIC_MODEL"] = model + } + + return env +} + +// openrouterEnv returns env vars that redirect Claude Code through OpenRouter. +// The API key is resolved lazily (after 1Password) via ResolveOpenRouterKey. +// +// Model resolution order: +// 1. [llm.models] default with "openrouter/" prefix (explicit openrouter default) +// 2. [llm.models] default without provider prefix (provider-neutral default) +// 3. First model in [llm.models.providers.openrouter] models list +// 4. No model override (Claude Code uses its own default) +func openrouterEnv(configModel string, models cfg.LLMModelsSection, dbg bool) map[string]string { + if dbg { + fmt.Fprintf(os.Stderr, " claude: openrouter mode enabled, redirecting API to openrouter.ai\n") + } + env := map[string]string{ - "ANTHROPIC_BASE_URL": "http://host.docker.internal:11434", - "ANTHROPIC_AUTH_TOKEN": "ollama", - "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1", + "ANTHROPIC_BASE_URL": openRouterAnthropicBaseURL, + "ANTHROPIC_API_KEY": "", + "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY": "1", + "CLAUDE_CODE_SKIP_FAST_MODE_ORG_CHECK": "1", } - if model := resolveOllamaModel(configModel, dbg); model != "" { + model := resolveOpenRouterModel(configModel, models, dbg) + if model != "" { env["ANTHROPIC_MODEL"] = model } return env } +// resolveOpenRouterModel picks the model for OpenRouter mode. +func resolveOpenRouterModel(configModel string, models cfg.LLMModelsSection, dbg bool) string { + // Priority 1: global default with openrouter/ prefix. + if strings.HasPrefix(configModel, "openrouter/") { + model := strings.TrimPrefix(configModel, "openrouter/") + if dbg { + fmt.Fprintf(os.Stderr, " claude: openrouter model from config default: %s\n", model) + } + return model + } + + // Priority 2: global default without any provider prefix (e.g. "google/gemini-2.5-pro"). + if configModel != "" && !strings.HasPrefix(configModel, "ollama/") { + if dbg { + fmt.Fprintf(os.Stderr, " claude: openrouter model from config default: %s\n", configModel) + } + return configModel + } + + // Priority 3: first model in [llm.models.providers.openrouter]. + if p, ok := models.Providers["openrouter"]; ok && len(p.Models) > 0 { + model := p.Models[0] + if dbg { + fmt.Fprintf(os.Stderr, " claude: openrouter model from providers list: %s\n", model) + } + return model + } + + // No model override: skip ollama model, let Claude Code use its default. + if configModel != "" && dbg { + fmt.Fprintf(os.Stderr, " claude: ignoring ollama model %q in openrouter mode, using Claude Code default\n", configModel) + } + return "" +} + +// ResolveOpenRouterKey fills ANTHROPIC_AUTH_TOKEN and OPENROUTER_API_KEY from +// the environment. Called after 1Password resolution so the key is available. +func ResolveOpenRouterKey(env map[string]string) error { + if err := FillOpenRouterKey(env); err != nil { + return err + } + env["ANTHROPIC_AUTH_TOKEN"] = env["OPENROUTER_API_KEY"] + return nil +} + // resolveOllamaModel returns the bare ollama model name to use as ANTHROPIC_MODEL. // Priority: config [llm.models] default > best-ranked model from running ollama. // Returns "" if no model can be determined (ollama unreachable, no models). diff --git a/cmd/claude_test.go b/cmd/claude_test.go index c9734ea..6a31ffa 100644 --- a/cmd/claude_test.go +++ b/cmd/claude_test.go @@ -223,6 +223,217 @@ func TestClaude_OllamaNoModel_NoAnthropicModel(t *testing.T) { } } +// TestClaude_OpenRouterFlag_InjectsEnv verifies that "cell claude --openrouter --dry-run" +// injects ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN, and OPENROUTER_API_KEY into docker argv. +func TestClaude_OpenRouterFlag_InjectsEnv(t *testing.T) { + home := scaffoldedHome(t) + + cmd := exec.Command(binaryPath, "claude", "--openrouter", "--dry-run") + cmd.Dir = home + cmd.Env = append(os.Environ(), "DEVCELL_BUNK=1", "HOME="+home, "OPENROUTER_API_KEY=sk-or-test-key") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("claude --openrouter --dry-run failed: %v\noutput: %s", err, out) + } + + argv := string(out) + if !strings.Contains(argv, "ANTHROPIC_BASE_URL=https://openrouter.ai/api") { + t.Errorf("expected ANTHROPIC_BASE_URL=https://openrouter.ai/api in argv:\n%s", argv) + } + if !strings.Contains(argv, "ANTHROPIC_AUTH_TOKEN=sk-or-test-key") { + t.Errorf("expected ANTHROPIC_AUTH_TOKEN=sk-or-test-key in argv:\n%s", argv) + } + if !strings.Contains(argv, "OPENROUTER_API_KEY=sk-or-test-key") { + t.Errorf("expected OPENROUTER_API_KEY=sk-or-test-key in argv:\n%s", argv) + } + if !strings.Contains(argv, "ANTHROPIC_API_KEY=") { + t.Errorf("expected ANTHROPIC_API_KEY= (empty) in argv:\n%s", argv) + } +} + +// TestClaude_OpenRouterFlag_Stripped verifies --openrouter is NOT forwarded to claude binary. +func TestClaude_OpenRouterFlag_Stripped(t *testing.T) { + home := scaffoldedHome(t) + + cmd := exec.Command(binaryPath, "claude", "--openrouter", "--dry-run") + cmd.Dir = home + cmd.Env = append(os.Environ(), "DEVCELL_BUNK=1", "HOME="+home, "OPENROUTER_API_KEY=sk-or-test-key") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("claude --openrouter --dry-run failed: %v\noutput: %s", err, out) + } + + parts := strings.Fields(strings.TrimSpace(string(out))) + for _, p := range parts { + if p == "--openrouter" { + t.Errorf("--openrouter should be stripped from argv, but found it:\n%s", out) + } + } +} + +// TestClaude_ConfigUseOpenRouter_InjectsEnv verifies that [llm] use_openrouter=true +// in devcell.toml injects the openrouter env vars. +func TestClaude_ConfigUseOpenRouter_InjectsEnv(t *testing.T) { + home := scaffoldedHome(t) + + cfgDir := filepath.Join(home, ".config", "devcell") + tomlContent := `[cell] +[llm] +use_openrouter = true +` + if err := os.WriteFile(filepath.Join(cfgDir, "devcell.toml"), []byte(tomlContent), 0644); err != nil { + t.Fatal(err) + } + + cmd := exec.Command(binaryPath, "claude", "--dry-run") + cmd.Dir = home + cmd.Env = append(os.Environ(), "DEVCELL_BUNK=1", "HOME="+home, "OPENROUTER_API_KEY=sk-or-test-key") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("claude --dry-run failed: %v\noutput: %s", err, out) + } + + argv := string(out) + if !strings.Contains(argv, "ANTHROPIC_BASE_URL=https://openrouter.ai/api") { + t.Errorf("expected ANTHROPIC_BASE_URL from config:\n%s", argv) + } + if !strings.Contains(argv, "OPENROUTER_API_KEY=sk-or-test-key") { + t.Errorf("expected OPENROUTER_API_KEY from config:\n%s", argv) + } +} + +// TestClaude_OpenRouterConfigModel verifies that [llm.models] default with openrouter/ +// prefix is stripped and injected as ANTHROPIC_MODEL. +func TestClaude_OpenRouterConfigModel(t *testing.T) { + home := scaffoldedHome(t) + + cfgDir := filepath.Join(home, ".config", "devcell") + tomlContent := `[cell] +[llm] +use_openrouter = true + +[llm.models] +default = "openrouter/google/gemini-2.5-pro" +` + if err := os.WriteFile(filepath.Join(cfgDir, "devcell.toml"), []byte(tomlContent), 0644); err != nil { + t.Fatal(err) + } + + cmd := exec.Command(binaryPath, "claude", "--dry-run") + cmd.Dir = home + cmd.Env = append(os.Environ(), "DEVCELL_BUNK=1", "HOME="+home, "OPENROUTER_API_KEY=sk-or-test-key") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("claude --dry-run failed: %v\noutput: %s", err, out) + } + + argv := string(out) + if !strings.Contains(argv, "ANTHROPIC_MODEL=google/gemini-2.5-pro") { + t.Errorf("expected ANTHROPIC_MODEL=google/gemini-2.5-pro (prefix stripped), got:\n%s", argv) + } +} + +// TestClaude_OpenRouterNoKey_Error verifies that --openrouter without OPENROUTER_API_KEY +// exits with an error. +func TestClaude_OpenRouterNoKey_Error(t *testing.T) { + home := scaffoldedHome(t) + + cmd := exec.Command(binaryPath, "claude", "--openrouter", "--dry-run") + cmd.Dir = home + env := []string{"DEVCELL_BUNK=1", "HOME=" + home, "PATH=" + os.Getenv("PATH")} + cmd.Env = env + out, err := cmd.CombinedOutput() + if err == nil { + t.Fatalf("expected error when OPENROUTER_API_KEY is missing, but got success:\n%s", out) + } + + if !strings.Contains(string(out), "OPENROUTER_API_KEY") { + t.Errorf("expected error mentioning OPENROUTER_API_KEY, got:\n%s", out) + } +} + +// TestClaude_OpenRouterProviderFallback verifies that when default is an ollama model, +// openrouter mode falls back to the first model in [llm.models.providers.openrouter]. +func TestClaude_OpenRouterProviderFallback(t *testing.T) { + home := scaffoldedHome(t) + + cfgDir := filepath.Join(home, ".config", "devcell") + tomlContent := `[cell] +[llm] +use_openrouter = true + +[llm.models] +default = "ollama/qwen3-coder:30b" + +[llm.models.providers.openrouter] +models = ["moonshotai/kimi-k3", "google/gemini-2.5-pro", "x-ai/grok-4.6"] +` + if err := os.WriteFile(filepath.Join(cfgDir, "devcell.toml"), []byte(tomlContent), 0644); err != nil { + t.Fatal(err) + } + + cmd := exec.Command(binaryPath, "claude", "--dry-run") + cmd.Dir = home + cmd.Env = append(os.Environ(), "DEVCELL_BUNK=1", "HOME="+home, "OPENROUTER_API_KEY=sk-or-test-key") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("claude --dry-run failed: %v\noutput: %s", err, out) + } + + argv := string(out) + if !strings.Contains(argv, "ANTHROPIC_MODEL=moonshotai/kimi-k3") { + t.Errorf("expected ANTHROPIC_MODEL=moonshotai/kimi-k3 (first from providers.openrouter), got:\n%s", argv) + } +} + +// TestClaude_BaseEnv_DisableMouse verifies that CLAUDE_CODE_DISABLE_MOUSE=1 +// is always injected (all modes), so tmux text selection works. +func TestClaude_BaseEnv_DisableMouse(t *testing.T) { + home := scaffoldedHome(t) + + cmd := exec.Command(binaryPath, "claude", "--dry-run") + cmd.Dir = home + cmd.Env = append(os.Environ(), "DEVCELL_BUNK=1", "HOME="+home) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("claude --dry-run failed: %v\noutput: %s", err, out) + } + + argv := string(out) + if !strings.Contains(argv, "CLAUDE_CODE_DISABLE_MOUSE=1") { + t.Errorf("expected CLAUDE_CODE_DISABLE_MOUSE=1 in argv:\n%s", argv) + } +} + +// TestClaude_BaseEnv_DisableNonessentialTraffic verifies the telemetry kill-switch +// is present in every mode, not just ollama. +func TestClaude_BaseEnv_DisableNonessentialTraffic(t *testing.T) { + home := scaffoldedHome(t) + + cases := []struct { + name string + args []string + env []string + }{ + {name: "default", args: []string{"claude", "--dry-run"}}, + {name: "openrouter", args: []string{"claude", "--openrouter", "--dry-run"}, env: []string{"OPENROUTER_API_KEY=sk-or-test-key"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cmd := exec.Command(binaryPath, tc.args...) + cmd.Dir = home + cmd.Env = append(append(os.Environ(), "DEVCELL_BUNK=1", "HOME="+home), tc.env...) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("claude %v failed: %v\noutput: %s", tc.args, err, out) + } + if !strings.Contains(string(out), "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1") { + t.Errorf("expected CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 in argv:\n%s", out) + } + }) + } +} + // TestClaude_OllamaWithUserArgs verifies that --ollama + user args work together. func TestClaude_OllamaWithUserArgs(t *testing.T) { home := scaffoldedHome(t) diff --git a/cmd/cleanup.go b/cmd/cleanup.go new file mode 100644 index 0000000..e73fdfd --- /dev/null +++ b/cmd/cleanup.go @@ -0,0 +1,67 @@ +package main + +import ( + "context" + "os" + "os/signal" + "syscall" + + "github.com/DimmKirr/devcell/internal/runner" + "github.com/DimmKirr/devcell/internal/telemetry" + "github.com/DimmKirr/devcell/internal/ux" + "github.com/mattn/go-isatty" + "github.com/spf13/cobra" +) + +// `cell cleanup` — reap GC roots that no RUNNING container references. +// See CELL-334. Retention rule: "in use" = running (docker ps), not merely +// existing. Roots of stopped cells are reaped; those cells rebuild via the +// hydration gate (CELL-38) on next start. +var cleanupCmd = &cobra.Command{ + Use: "cleanup", + Short: "Reap nix GC roots no running cell references (shared volume hygiene)", + Long: `Reap GC root symlinks under /nix/var/nix/gcroots/devcell/ whose closure +no RUNNING devcell container references. + +Only root symlinks (and their -meta files) are removed — the nix store itself +is untouched. Run ` + "`cell build prune --pure`" + ` afterwards to garbage-collect +the store paths the reaped roots were anchoring. + +Roots of stopped cells are reaped too: a stopped cell rebuilds automatically +on its next start. A running cell can never lose its roots — its closure is +resolved live from inside the container before anything is removed.`, + RunE: runCleanup, +} + +func init() { + cleanupCmd.Flags().BoolP("yes", "y", false, "skip the confirmation prompt") + rootCmd.AddCommand(cleanupCmd) +} + +func runCleanup(cmd *cobra.Command, _ []string) error { + telemetry.Track("cleanup", nil) + yes, _ := cmd.Flags().GetBool("yes") + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + closures, err := runner.CollectLiveClosures( + func() ([]string, error) { return runner.DockerRunningDevcellContainers(ctx) }, + func(container, link string) (string, error) { + return runner.DockerResolveContainerLink(ctx, container, link) + }, + ux.Debugf, + ) + if err != nil { + return err + } + + return runner.RunCleanup(runner.RunCleanupArgs{ + Closures: closures, + Exec: func(step runner.PruneStep) error { return execStep(ctx, step) }, + Out: os.Stdout, + In: os.Stdin, + SkipYes: yes, + IsTTY: isatty.IsTerminal(os.Stdin.Fd()), + }) +} diff --git a/cmd/cleanup_test.go b/cmd/cleanup_test.go new file mode 100644 index 0000000..12cfada --- /dev/null +++ b/cmd/cleanup_test.go @@ -0,0 +1,35 @@ +package main + +import "testing" + +// CELL-334: `cell cleanup` — reap GC roots no RUNNING container references. + +func TestCleanupCmd_RegisteredOnRoot(t *testing.T) { + for _, c := range rootCmd.Commands() { + if c.Name() == "cleanup" { + return + } + } + t.Fatal("`cell cleanup` command not registered on root") +} + +func TestCleanupCmd_YesFlagExists(t *testing.T) { + if cleanupCmd.Flags().Lookup("yes") == nil { + t.Error("cleanup must support --yes to skip the confirmation prompt") + } +} + +// CELL-390: `cell claude --auto-cleanup` (etc.) opts into running the +// CELL-334 reaper at cell start. The flag is devcell's, not the agent's — +// it must never be forwarded to the inner binary. +func TestStripCellFlags_StripsAutoCleanup(t *testing.T) { + got := stripCellFlags([]string{"--auto-cleanup", "prompt text"}) + for _, a := range got { + if a == "--auto-cleanup" { + t.Error("--auto-cleanup must be stripped from forwarded args") + } + } + if len(got) != 1 || got[0] != "prompt text" { + t.Errorf("non-cell args must survive stripping, got %v", got) + } +} diff --git a/cmd/codex.go b/cmd/codex.go index 96d901a..70d75ee 100644 --- a/cmd/codex.go +++ b/cmd/codex.go @@ -32,7 +32,7 @@ Examples: cell codex --model o3`, DisableFlagParsing: true, RunE: func(cmd *cobra.Command, args []string) error { - extraFlags, extraEnv := codexOllamaConfig() + extraFlags, extraEnv := codexProviderConfig() return runAgent("codex", append([]string{"--dangerously-bypass-approvals-and-sandbox"}, extraFlags...), args, extraEnv) @@ -59,21 +59,32 @@ func init() { codexCmd.AddCommand(codexResumeCmd) } -// codexOllamaConfig returns extra CLI flags and env vars when ollama mode is -// active (use_ollama=true in devcell.toml, or --ollama flag). -// Returns nil, nil when ollama is not configured — Codex runs normally. -func codexOllamaConfig() (flags []string, env map[string]string) { +// codexProviderConfig returns extra CLI flags and env vars for the active +// provider mode. OpenRouter (--openrouter or use_openrouter=true) wins over +// ollama; with neither configured Codex runs normally against the cloud +// provider. Returns nil, nil in that default case. +func codexProviderConfig() (flags []string, env map[string]string) { dbg := scanFlag("--debug") useOllama := scanFlag("--ollama") + useOpenRouter := scanFlag("--openrouter") var model string - if !useOllama { - c, err := config.LoadFromOS() - if err == nil { - cellCfg := cfg.LoadFromOS(c.ConfigDir, c.BaseDir) + var models cfg.LLMModelsSection + c, err := config.LoadFromOS() + if err == nil { + cellCfg := cfg.LoadFromOS(c.ConfigDir, c.BaseDir) + if !useOllama { useOllama = cellCfg.LLM.UseOllama - model = cellCfg.LLM.Models.Default } + if !useOpenRouter { + useOpenRouter = cellCfg.LLM.UseOpenRouter + } + model = cellCfg.LLM.Models.Default + models = cellCfg.LLM.Models + } + + if useOpenRouter { + return codexOpenRouterConfig(model, models, dbg) } if !useOllama { @@ -93,3 +104,27 @@ func codexOllamaConfig() (flags []string, env map[string]string) { "CODEX_OSS_BASE_URL": "http://host.docker.internal:11434/v1", } } + +// codexOpenRouterConfig returns -c config overrides that point Codex at +// OpenRouter's OpenAI-compat endpoint. Codex needs wire_api=responses — +// OpenRouter translates to Chat Completions for models that lack native +// Responses support. The API key is resolved lazily (after 1Password) via +// FillOpenRouterKey, requested by the empty OPENROUTER_API_KEY placeholder. +func codexOpenRouterConfig(configModel string, models cfg.LLMModelsSection, dbg bool) (flags []string, env map[string]string) { + if dbg { + fmt.Fprintf(os.Stderr, " codex: openrouter mode enabled\n") + } + + flags = []string{ + "-c", "model_provider=openrouter", + "-c", "model_providers.openrouter.name=OpenRouter", + "-c", "model_providers.openrouter.base_url=" + openRouterOpenAIBaseURL, + "-c", "model_providers.openrouter.env_key=OPENROUTER_API_KEY", + "-c", "model_providers.openrouter.wire_api=responses", + } + if model := resolveOpenRouterModel(configModel, models, dbg); model != "" { + flags = append(flags, "--model", model) + } + + return flags, map[string]string{"OPENROUTER_API_KEY": ""} +} diff --git a/cmd/codex_test.go b/cmd/codex_test.go index 82c9f18..8d87c59 100644 --- a/cmd/codex_test.go +++ b/cmd/codex_test.go @@ -138,3 +138,129 @@ default = "qwen2.5-coder:32b" t.Errorf("expected --model qwen2.5-coder:32b in argv:\n%s", argv) } } + +// TestCodex_OpenRouterFlag_InjectsConfig verifies "cell codex --openrouter --dry-run" +// passes -c model_providers overrides for OpenRouter and resolves the API key. +func TestCodex_OpenRouterFlag_InjectsConfig(t *testing.T) { + home := scaffoldedHome(t) + + cmd := exec.Command(binaryPath, "codex", "--openrouter", "--dry-run") + cmd.Dir = home + cmd.Env = append(os.Environ(), "DEVCELL_BUNK=1", "HOME="+home, "OPENROUTER_API_KEY=sk-or-test-key") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("codex --openrouter --dry-run failed: %v\noutput: %s", err, out) + } + + argv := string(out) + for _, want := range []string{ + "-c model_provider=openrouter", + "-c model_providers.openrouter.name=OpenRouter", + "-c model_providers.openrouter.base_url=https://openrouter.ai/api/v1", + "-c model_providers.openrouter.env_key=OPENROUTER_API_KEY", + "-c model_providers.openrouter.wire_api=responses", + "OPENROUTER_API_KEY=sk-or-test-key", + } { + if !strings.Contains(argv, want) { + t.Errorf("expected %q in argv:\n%s", want, argv) + } + } +} + +// TestCodex_OpenRouterFlag_Stripped verifies --openrouter is not forwarded to codex. +func TestCodex_OpenRouterFlag_Stripped(t *testing.T) { + home := scaffoldedHome(t) + + cmd := exec.Command(binaryPath, "codex", "--openrouter", "--dry-run") + cmd.Dir = home + cmd.Env = append(os.Environ(), "DEVCELL_BUNK=1", "HOME="+home, "OPENROUTER_API_KEY=sk-or-test-key") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("codex --openrouter --dry-run failed: %v\noutput: %s", err, out) + } + + for _, p := range strings.Fields(strings.TrimSpace(string(out))) { + if p == "--openrouter" { + t.Errorf("--openrouter should be stripped from argv, but found it:\n%s", out) + } + } +} + +// TestCodex_ConfigUseOpenRouter_WithModel verifies [llm] use_openrouter=true plus +// an openrouter/-prefixed default model produces --model with the bare slug. +func TestCodex_ConfigUseOpenRouter_WithModel(t *testing.T) { + home := scaffoldedHome(t) + + cfgDir := filepath.Join(home, ".config", "devcell") + tomlContent := `[cell] +[llm] +use_openrouter = true +[llm.models] +default = "openrouter/moonshotai/kimi-k3" +` + if err := os.WriteFile(filepath.Join(cfgDir, "devcell.toml"), []byte(tomlContent), 0644); err != nil { + t.Fatal(err) + } + + cmd := exec.Command(binaryPath, "codex", "--dry-run") + cmd.Dir = home + cmd.Env = append(os.Environ(), "DEVCELL_BUNK=1", "HOME="+home, "OPENROUTER_API_KEY=sk-or-test-key") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("codex --dry-run failed: %v\noutput: %s", err, out) + } + + argv := string(out) + if !strings.Contains(argv, "--model moonshotai/kimi-k3") { + t.Errorf("expected --model moonshotai/kimi-k3 in argv:\n%s", argv) + } + if !strings.Contains(argv, "-c model_provider=openrouter") { + t.Errorf("expected -c model_provider=openrouter in argv:\n%s", argv) + } +} + +// TestCodex_OpenRouterBeatsOllama verifies --openrouter wins when use_ollama=true. +func TestCodex_OpenRouterBeatsOllama(t *testing.T) { + home := scaffoldedHome(t) + + cfgDir := filepath.Join(home, ".config", "devcell") + tomlContent := `[cell] +[llm] +use_ollama = true +` + if err := os.WriteFile(filepath.Join(cfgDir, "devcell.toml"), []byte(tomlContent), 0644); err != nil { + t.Fatal(err) + } + + cmd := exec.Command(binaryPath, "codex", "--openrouter", "--dry-run") + cmd.Dir = home + cmd.Env = append(os.Environ(), "DEVCELL_BUNK=1", "HOME="+home, "OPENROUTER_API_KEY=sk-or-test-key") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("codex --openrouter --dry-run failed: %v\noutput: %s", err, out) + } + + argv := string(out) + if strings.Contains(argv, "--oss") { + t.Errorf("--oss (ollama mode) must not appear in openrouter mode:\n%s", argv) + } + if !strings.Contains(argv, "-c model_provider=openrouter") { + t.Errorf("expected openrouter config in argv:\n%s", argv) + } +} + +// TestCodex_OpenRouterNoKey_Error verifies a missing OPENROUTER_API_KEY fails the boot. +func TestCodex_OpenRouterNoKey_Error(t *testing.T) { + home := scaffoldedHome(t) + + cmd := exec.Command(binaryPath, "codex", "--openrouter", "--dry-run") + cmd.Dir = home + cmd.Env = []string{"DEVCELL_BUNK=1", "HOME=" + home, "PATH=" + os.Getenv("PATH")} + out, err := cmd.CombinedOutput() + if err == nil { + t.Fatalf("expected error when OPENROUTER_API_KEY is missing, but got success:\n%s", out) + } + if !strings.Contains(string(out), "OPENROUTER_API_KEY") { + t.Errorf("expected error mentioning OPENROUTER_API_KEY, got:\n%s", out) + } +} diff --git a/cmd/codexpromptflags.go b/cmd/codexpromptflags.go new file mode 100644 index 0000000..126fee2 --- /dev/null +++ b/cmd/codexpromptflags.go @@ -0,0 +1,25 @@ +package main + +import ( + "strings" + + "github.com/DimmKirr/devcell/internal/cfg" + "github.com/DimmKirr/devcell/internal/config" + "github.com/DimmKirr/devcell/internal/runner" +) + +// codexPromptFlags builds the overlay prompt (container context + TOML append +// prompt) and returns it as inline argv for Codex's -c developer_instructions. +// +// Unlike claudePromptFlags, which writes files and passes --system-prompt-file +// / --append-system-prompt-file, Codex has no file-based flag — the content +// travels as a TOML config value on the command line. +func codexPromptFlags(c config.Config, cellCfg cfg.CellConfig, opts runner.ResolveOpts) ([]string, error) { + content, err := runner.AssembleOverlayPrompt(c, cellCfg, opts) + if err != nil { + return nil, err + } + escaped := strings.ReplaceAll(content, `\`, `\\`) + escaped = strings.ReplaceAll(escaped, `"`, `\"`) + return []string{"-c", "developer_instructions=" + escaped}, nil +} diff --git a/cmd/codexpromptflags_test.go b/cmd/codexpromptflags_test.go new file mode 100644 index 0000000..e187884 --- /dev/null +++ b/cmd/codexpromptflags_test.go @@ -0,0 +1,147 @@ +package main_test + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// TestCodex_DeveloperInstructions_ContainerContext verifies that cell codex +// injects -c developer_instructions=... containing container context markers. +func TestCodex_DeveloperInstructions_ContainerContext(t *testing.T) { + home := scaffoldedHome(t) + + cmd := exec.Command(binaryPath, "codex", "--dry-run") + cmd.Dir = home + cmd.Env = append(os.Environ(), "DEVCELL_BUNK=1", "HOME="+home) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("codex --dry-run failed: %v\noutput: %s", err, out) + } + + argv := string(out) + if !strings.Contains(argv, "-c") { + t.Errorf("expected -c flag in argv:\n%s", argv) + } + if !strings.Contains(argv, "developer_instructions=") { + t.Errorf("expected developer_instructions= in argv:\n%s", argv) + } + if !strings.Contains(argv, "Docker container") { + t.Errorf("expected 'Docker container' in developer_instructions:\n%s", argv) + } + if !strings.Contains(argv, "Bind mounts") { + t.Errorf("expected 'Bind mounts' in developer_instructions:\n%s", argv) + } +} + +// TestCodex_DeveloperInstructions_AppendPrompt verifies that +// [llm].append_system_prompt content appears in developer_instructions. +func TestCodex_DeveloperInstructions_AppendPrompt(t *testing.T) { + home := scaffoldedHome(t) + + cfgDir := filepath.Join(home, ".config", "devcell") + tomlContent := `[cell] +[llm] +append_system_prompt = "Custom instructions from TOML" +` + if err := os.WriteFile(filepath.Join(cfgDir, "devcell.toml"), []byte(tomlContent), 0644); err != nil { + t.Fatal(err) + } + + cmd := exec.Command(binaryPath, "codex", "--dry-run") + cmd.Dir = home + cmd.Env = append(os.Environ(), "DEVCELL_BUNK=1", "HOME="+home) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("codex --dry-run failed: %v\noutput: %s", err, out) + } + + argv := string(out) + if !strings.Contains(argv, "Custom instructions from TOML") { + t.Errorf("expected append_system_prompt content in developer_instructions:\n%s", argv) + } +} + +// TestCodex_DeveloperInstructions_NoAppendPrompt verifies that container +// context is injected even when no TOML prompt is configured. +func TestCodex_DeveloperInstructions_NoAppendPrompt(t *testing.T) { + home := scaffoldedHome(t) + + cmd := exec.Command(binaryPath, "codex", "--dry-run") + cmd.Dir = home + cmd.Env = append(os.Environ(), "DEVCELL_BUNK=1", "HOME="+home) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("codex --dry-run failed: %v\noutput: %s", err, out) + } + + argv := string(out) + if !strings.Contains(argv, "developer_instructions=") { + t.Errorf("expected developer_instructions even without TOML prompt:\n%s", argv) + } + if !strings.Contains(argv, "Docker container") { + t.Errorf("expected container context even without TOML prompt:\n%s", argv) + } +} + +// TestCodex_DeveloperInstructions_EscapesSpecialChars verifies that quotes +// and backslashes in the append prompt are escaped for the TOML CLI value. +func TestCodex_DeveloperInstructions_EscapesSpecialChars(t *testing.T) { + home := scaffoldedHome(t) + + cfgDir := filepath.Join(home, ".config", "devcell") + tomlContent := `[cell] +[llm] +append_system_prompt = 'say "hello" and use C:\path' +` + if err := os.WriteFile(filepath.Join(cfgDir, "devcell.toml"), []byte(tomlContent), 0644); err != nil { + t.Fatal(err) + } + + cmd := exec.Command(binaryPath, "codex", "--dry-run") + cmd.Dir = home + cmd.Env = append(os.Environ(), "DEVCELL_BUNK=1", "HOME="+home) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("codex --dry-run failed: %v\noutput: %s", err, out) + } + + argv := string(out) + if !strings.Contains(argv, `\"hello\"`) { + t.Errorf("expected escaped quotes in developer_instructions:\n%s", argv) + } + if !strings.Contains(argv, `C:\\path`) { + t.Errorf("expected escaped backslash in developer_instructions:\n%s", argv) + } +} + +// TestCodex_SystemPromptWarning verifies that when [llm].system_prompt is +// configured, cell codex emits a warning that it cannot replace the built-in +// prompt. +func TestCodex_SystemPromptWarning(t *testing.T) { + home := scaffoldedHome(t) + + cfgDir := filepath.Join(home, ".config", "devcell") + tomlContent := `[cell] +[llm] +system_prompt = "Replace me" +` + if err := os.WriteFile(filepath.Join(cfgDir, "devcell.toml"), []byte(tomlContent), 0644); err != nil { + t.Fatal(err) + } + + cmd := exec.Command(binaryPath, "codex", "--dry-run") + cmd.Dir = home + cmd.Env = append(os.Environ(), "DEVCELL_BUNK=1", "HOME="+home) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("codex --dry-run failed: %v\noutput: %s", err, out) + } + + output := string(out) + if !strings.Contains(output, "system_prompt is set but Codex has no way to replace") { + t.Errorf("expected base-prompt warning in output:\n%s", output) + } +} diff --git a/cmd/default_command_test.go b/cmd/default_command_test.go new file mode 100644 index 0000000..2e3e52b --- /dev/null +++ b/cmd/default_command_test.go @@ -0,0 +1,70 @@ +package main + +import ( + "reflect" + "testing" +) + +// rewriteDefaultCommand receives os.Args[1:] (everything after the binary +// name) and the resolved default command. It must inject the default command +// in front of user args so flags like `-c` reach the inner binary — the bug +// was `cell -c` dying at cobra flag parsing while `cell claude -c` worked. + +func testKnownCmds() map[string]bool { + return map[string]bool{ + "claude": true, "codex": true, "opencode": true, "gemini": true, + "shell": true, "build": true, "init": true, "help": true, + } +} + +func TestRewriteDefaultCommand_ForwardsFlags(t *testing.T) { + got := rewriteDefaultCommand([]string{"-c"}, "claude", testKnownCmds()) + want := []string{"claude", "-c"} + if !reflect.DeepEqual(got, want) { + t.Errorf("cell -c with default claude: got %v, want %v", got, want) + } +} + +func TestRewriteDefaultCommand_ForwardsPositionalArgs(t *testing.T) { + got := rewriteDefaultCommand([]string{"--resume", "abc"}, "claude", testKnownCmds()) + want := []string{"claude", "--resume", "abc"} + if !reflect.DeepEqual(got, want) { + t.Errorf("got %v, want %v", got, want) + } +} + +func TestRewriteDefaultCommand_BareInvocation(t *testing.T) { + got := rewriteDefaultCommand(nil, "claude", testKnownCmds()) + want := []string{"claude"} + if !reflect.DeepEqual(got, want) { + t.Errorf("bare cell with default claude: got %v, want %v", got, want) + } +} + +func TestRewriteDefaultCommand_NoDefaultUnchanged(t *testing.T) { + got := rewriteDefaultCommand([]string{"-c"}, "", testKnownCmds()) + want := []string{"-c"} + if !reflect.DeepEqual(got, want) { + t.Errorf("no default_command must leave args alone: got %v, want %v", got, want) + } +} + +func TestRewriteDefaultCommand_ExplicitSubcommandWins(t *testing.T) { + got := rewriteDefaultCommand([]string{"build", "--stack", "go"}, "claude", testKnownCmds()) + want := []string{"build", "--stack", "go"} + if !reflect.DeepEqual(got, want) { + t.Errorf("explicit subcommand must not be shadowed: got %v, want %v", got, want) + } +} + +func TestRewriteDefaultCommand_HelpAndVersionUntouched(t *testing.T) { + for _, args := range [][]string{ + {"--help"}, {"-h"}, {"--version"}, {"help"}, + {"completion", "zsh"}, {"__complete", "cl"}, {"__completeNoDesc", "cl"}, + } { + got := rewriteDefaultCommand(args, "claude", testKnownCmds()) + if !reflect.DeepEqual(got, args) { + t.Errorf("%v must bypass default command: got %v", args, got) + } + } +} diff --git a/cmd/docker_debug.go b/cmd/docker_debug.go new file mode 100644 index 0000000..cd80e4e --- /dev/null +++ b/cmd/docker_debug.go @@ -0,0 +1,37 @@ +package main + +import ( + "context" + "os" + + "github.com/DimmKirr/devcell/internal/config" + "github.com/DimmKirr/devcell/internal/runner" + "github.com/DimmKirr/devcell/internal/ux" +) + +func logDockerDiagnostics(ctx context.Context, c config.Config) { + if !ux.Verbose { + return + } + info, err := runner.CollectDockerDebugInfo(ctx) + if err != nil { + ux.Debugf("docker diagnostics: %v", err) + return + } + ux.Debugf("docker client: context=%q endpoint=%q DOCKER_HOST=%q", + info.Context, info.Endpoint, info.DockerHostEnv) + ux.Debugf("docker daemon: runtime=%s name=%q version=%s os=%q arch=%s cpus=%d memory=%s root=%s", + info.Runtime, info.Name, info.ServerVersion, info.OperatingOS, + info.Architecture, info.CPUs, runner.HumanBytes(info.MemoryBytes), info.RootDir) + ux.Debugf("docker socket: path=%q resolved=%q", info.Socket, info.SocketTarget) + + hostProject := os.Getenv("DEVCELL_HOST_PROJECT_DIR") + ux.Debugf("docker paths: base=%q build=%q DEVCELL_HOST_PROJECT_DIR=%q daemon-build-source=%q", + c.BaseDir, c.BuildDir, hostProject, runner.DockerHostPath(c.BuildDir)) + volume := runner.ThinStoreVolume() + if detail := runner.DockerVolumeDebug(ctx, volume); detail != "" { + ux.Debugf("docker nix volume: %s", detail) + } else { + ux.Debugf("docker nix volume: name=%s absent", volume) + } +} diff --git a/cmd/hmoptgen.go b/cmd/hmoptgen.go new file mode 100644 index 0000000..898cd2a --- /dev/null +++ b/cmd/hmoptgen.go @@ -0,0 +1,34 @@ +//go:build ignore + +// hmoptgen writes the generated home-manager option declarations for the +// devcell.toml schema. Wired into `task hm:generate` (a dep of cell:build) +// so nix/home-manager/options.nix is regenerated on every build and cannot +// drift from internal/cfg.CellConfig. +// +// Usage: go run cmd/hmoptgen.go [-out path] +// With no -out, the module is printed to stdout. +// Excluded from normal builds by the ignore tag above, like cmd/gendoc.go. +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/DimmKirr/devcell/internal/cfg" +) + +func main() { + out := flag.String("out", "", "output path (default: stdout)") + flag.Parse() + + module := cfg.HMOptionsNix() + if *out == "" { + fmt.Print(module) + return + } + if err := os.WriteFile(*out, []byte(module), 0o644); err != nil { + fmt.Fprintf(os.Stderr, "hmoptgen: %v\n", err) + os.Exit(1) + } +} diff --git a/cmd/init.go b/cmd/init.go index a4b863a..c77d89d 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -4,8 +4,8 @@ import ( "fmt" "os" - "github.com/DimmKirr/devcell/internal/cfg" "github.com/DimmKirr/devcell/internal/config" + "github.com/DimmKirr/devcell/internal/telemetry" "github.com/DimmKirr/devcell/internal/ux" "github.com/spf13/cobra" ) @@ -22,9 +22,6 @@ func init() { initCmd.Flags().Bool("macos", false, "Set up a macOS VM box via UTM + Vagrant") initCmd.Flags().Bool("force", false, "Overwrite existing files and update flake inputs (implies --update)") initCmd.Flags().Bool("update", false, "update nix flake inputs (pull latest) instead of just resolving") - initCmd.Flags().String("nixhome", "", "nixhome source: local path or git URL (default: upstream repo)") - initCmd.Flags().String("local-nixhome", "", "deprecated: use --nixhome instead") - _ = initCmd.Flags().MarkHidden("local-nixhome") initCmd.Flags().Bool("no-cache", false, "Force re-download of cached IPSW restore image (tart only)") initCmd.Flags().String("stack", "", "stack name (base, dev [seed, ~3 GB], ultimate [~15 GB]; legacy: go, node, python, fullstack, electronics)") initCmd.Flags().StringSlice("modules", nil, "explicit module list (comma-separated, e.g. go,infra,electronics)") @@ -34,6 +31,7 @@ func runInit(cmd *cobra.Command, _ []string) error { applyOutputFlagsWithLog("init") engine := scanStringFlag("--engine") + telemetry.Track("init", map[string]any{"engine": engine, "stack": cmd.Flags().Lookup("stack").Value.String()}) if engine == "tart" { c, err := config.LoadFromOS() if err != nil { @@ -42,13 +40,19 @@ func runInit(cmd *cobra.Command, _ []string) error { stack, _ := cmd.Flags().GetString("stack") force, _ := cmd.Flags().GetBool("force") noCache, _ := cmd.Flags().GetBool("no-cache") - nixhomePath := c.BaseDir + "/nixhome" - if nh, _ := cmd.Flags().GetString("nixhome"); nh != "" { - nixhomePath = nh - } else if nh := os.Getenv("DEVCELL_NIXHOME_PATH"); nh != "" { - nixhomePath = nh + return runInitTart(c.CellName, c.HostHome, c.BaseDir, stack, force, noCache) + } + + if engine == "qemu" || engine == "libvirt" { + // libvirt reuses the qemu scaffold: init only creates directories, + // an SSH keypair, and VirtIO drivers on the shared mount (CELL-372). + c, err := config.LoadFromOS() + if err != nil { + return fmt.Errorf("load config: %w", err) } - return runInitTart(c.CellName, c.HostHome, c.BaseDir, stack, nixhomePath, force, noCache) + stack, _ := cmd.Flags().GetString("stack") + force, _ := cmd.Flags().GetBool("force") + return runInitQemu(c.CellName, c.HostHome, stack, force) } macos, _ := cmd.Flags().GetBool("macos") @@ -77,47 +81,15 @@ func runInit(cmd *cobra.Command, _ []string) error { ux.Debugf("stack: %s (--stack flag)", stack) } - // Nixhome source: --nixhome > --local-nixhome (deprecated) > env > global config > git. - nixhomeSrc, _ := cmd.Flags().GetString("nixhome") - nixhomeSrcOrigin := "" - if nixhomeSrc != "" { - nixhomeSrcOrigin = "--nixhome flag" - } - if nixhomeSrc == "" { - nixhomeSrc, _ = cmd.Flags().GetString("local-nixhome") - if nixhomeSrc != "" { - nixhomeSrcOrigin = "--local-nixhome flag (deprecated)" - } - } - if nixhomeSrc == "" { - nixhomeSrc = os.Getenv("DEVCELL_NIXHOME_PATH") - if nixhomeSrc != "" { - nixhomeSrcOrigin = "DEVCELL_NIXHOME_PATH env" - } - } - if nixhomeSrc == "" { - globalCfg, _ := cfg.LoadFile(c.ConfigDir + "/devcell.toml") - nixhomeSrc = globalCfg.Nix.NixhomePath - if nixhomeSrc != "" { - nixhomeSrcOrigin = "global config (" + c.ConfigDir + "/devcell.toml)" - } - } - if nixhomeSrc == "" { - nixhomeSrcOrigin = "upstream git (default)" - } - ux.Debugf("nixhome source: %s (%s)", nixhomeSrc, nixhomeSrcOrigin) - modules, _ := cmd.Flags().GetStringSlice("modules") - // Shared init flow: resolve nixhome, pick stack/modules, scaffold. result, err := RunInitFlow(InitFlowOptions{ - BaseDir: c.BaseDir, - ConfigDir: c.ConfigDir, - NixhomeSrc: nixhomeSrc, - Stack: stack, - Modules: modules, - Yes: yes, - Force: force, + BaseDir: c.BaseDir, + ConfigDir: c.ConfigDir, + Stack: stack, + Modules: modules, + Yes: yes, + Force: force, }) if err != nil { return err diff --git a/cmd/init_macos.go b/cmd/init_macos.go index 9b11d90..508a901 100644 --- a/cmd/init_macos.go +++ b/cmd/init_macos.go @@ -7,7 +7,6 @@ import ( "os" "os/exec" "path/filepath" - "runtime" "strings" "time" @@ -282,13 +281,6 @@ func verifySSHReachable(hostname string) error { // --------------------------------------------------------------------------- func sshRunNixInstall(hostname string) error { - // Locate the Vagrantfile.macOS nix-install script relative to this binary. - // In dev, use the images/ dir from the repo root. - vagrantfileDir := imagesDir() - - // Extract nix-install script from images/Vagrantfile.macOS and run via SSH. - // We inline the script body directly rather than invoking vagrant, because the - // VM was created manually (no vagrant state directory exists). nixScript := strings.Join([]string{ "set -euo pipefail", "if command -v nix >/dev/null 2>&1; then", @@ -301,8 +293,6 @@ func sshRunNixInstall(hostname string) error { ". /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh", "echo \"Nix $(nix --version) installed successfully.\"", }, "\n") - _ = vagrantfileDir // documents the source of truth; script is kept in sync manually - keyPaths := []string{ filepath.Join(os.Getenv("HOME"), ".vagrant.d", "insecure_private_keys", "vagrant.key.ed25519"), filepath.Join(os.Getenv("HOME"), ".vagrant.d", "insecure_private_keys", "vagrant.key.rsa"), @@ -332,20 +322,6 @@ func sshRunNixInstall(hostname string) error { return cmd.Run() } -// imagesDir returns the absolute path to the images/ directory in the devcell repo. -// In dev builds it walks up from the source file; in release builds it falls back -// to a path relative to the binary. -func imagesDir() string { - _, file, _, ok := runtime.Caller(0) - if ok { - // cmd/init_macos.go → cmd/ → repo root → images/ - return filepath.Clean(filepath.Join(filepath.Dir(file), "..", "images")) - } - // Fallback: same directory as the binary - exe, _ := os.Executable() - return filepath.Join(filepath.Dir(exe), "images") -} - // --------------------------------------------------------------------------- // Phase 5: Box packaging // --------------------------------------------------------------------------- diff --git a/cmd/init_qemu.go b/cmd/init_qemu.go new file mode 100644 index 0000000..03fe1af --- /dev/null +++ b/cmd/init_qemu.go @@ -0,0 +1,149 @@ +//go:build darwin || linux + +package main + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/DimmKirr/devcell/internal/ux" + "github.com/DimmKirr/devcell/internal/vm/qemu" +) + +// runInitQemu prepares directories, SSH keypair, and downloads VirtIO drivers +// for a QEMU Windows VM. Mirrors runInitTart: scaffold config, no VM creation. +// The actual VM creation happens in `cell build --engine=qemu`. +func runInitQemu(cellName, hostHome, stack string, force bool) error { + sshDir := qemuKeyDir(hostHome, cellName) + templateDir := qemu.TemplateDir(hostHome, stack, nil) + instanceDir := qemu.InstanceDir(hostHome, cellName) + + ux.Debugf("init qemu: cell=%s stack=%s", cellName, stack) + ux.Debugf("ssh dir: %s", sshDir) + + pr := &ux.PhaseRunner{} + + // --- Phase 1: Create directories --- + if err := pr.PhaseDetailed("Preparing directories", func() (string, error) { + for _, dir := range []string{sshDir, templateDir, instanceDir} { + if err := os.MkdirAll(dir, 0755); err != nil { + return "", fmt.Errorf("creating %s: %w", dir, err) + } + } + return sshDir, nil + }); err != nil { + return err + } + + // --- Phase 2: Generate SSH keypair --- + privKeyPath := filepath.Join(sshDir, "id_ed25519") + pubKeyPath := filepath.Join(sshDir, "id_ed25519.pub") + if err := pr.PhaseDetailed("Generating SSH keypair", func() (string, error) { + if !force { + if _, err := os.Stat(privKeyPath); err == nil { + ux.Debugf("SSH keypair exists, skipping (use --force to regenerate)") + return privKeyPath, nil + } + } + + os.Remove(privKeyPath) + os.Remove(pubKeyPath) + cmd := exec.Command("ssh-keygen", "-t", "ed25519", "-f", privKeyPath, "-N", "", "-q") + if out, err := cmd.CombinedOutput(); err != nil { + return "", fmt.Errorf("ssh-keygen: %w\n%s", err, out) + } + ux.Debugf("SSH keypair generated: %s", privKeyPath) + + // Collect existing ~/.ssh pub keys to add to authorized_keys + pubKey, err := os.ReadFile(pubKeyPath) + if err != nil { + return "", fmt.Errorf("reading public key: %w", err) + } + allKeys := strings.TrimSpace(string(pubKey)) + + homeDir, _ := os.UserHomeDir() + if homeDir != "" { + existing := collectSSHPubKeys(filepath.Join(homeDir, ".ssh")) + if existing != "" { + allKeys = allKeys + "\n" + existing + ux.Debugf("added existing ~/.ssh pub keys") + } + } + + authKeysPath := filepath.Join(sshDir, "authorized_keys") + if err := os.WriteFile(authKeysPath, []byte(allKeys+"\n"), 0644); err != nil { + return "", fmt.Errorf("writing authorized_keys: %w", err) + } + + return privKeyPath, nil + }); err != nil { + return err + } + + // --- Phase 3: Download VirtIO drivers --- + if err := pr.PhaseDetailed("Downloading VirtIO drivers", func() (string, error) { + obs := &phaseObserver{logf: ux.Debugf, runner: pr} + path, err := qemu.DownloadVirtioDrivers(context.Background(), hostHome, force, obs) + if err != nil { + return "", err + } + return path, nil + }); err != nil { + return err + } + + // --- Phase 4: Download Windows ARM64 ISO --- + if err := pr.PhaseDetailed("Downloading Windows ARM64 ISO", func() (string, error) { + obs := &phaseObserver{logf: ux.Debugf, runner: pr} + path, err := qemu.DownloadWindowsISO(context.Background(), hostHome, "en-us", force, obs) + if err != nil { + return "", err + } + return path, nil + }); err != nil { + return err + } + + pr.Seal("qemu artifacts ready") + fmt.Println(" Run: cell build --engine=qemu") + return nil +} + +// collectSSHPubKeys reads all *.pub files from sshDir. +func collectSSHPubKeys(sshDir string) string { + matches, err := filepath.Glob(filepath.Join(sshDir, "*.pub")) + if err != nil || len(matches) == 0 { + return "" + } + var keys []string + for _, path := range matches { + data, err := os.ReadFile(path) + if err != nil { + continue + } + line := strings.TrimSpace(string(data)) + if line != "" { + keys = append(keys, line) + } + } + return strings.Join(keys, "\n") +} + +// phaseObserver adapts qemu.Observer to ux.Debugf + PhaseRunner spinner updates. +type phaseObserver struct { + logf func(string, ...any) + runner *ux.PhaseRunner +} + +func (o *phaseObserver) Logf(format string, args ...any) { + o.logf(format, args...) +} +func (o *phaseObserver) Progress(_ float64, msg string) { + if o.runner != nil { + o.runner.UpdateText(msg) + } +} diff --git a/cmd/init_qemu_stub.go b/cmd/init_qemu_stub.go new file mode 100644 index 0000000..051ea49 --- /dev/null +++ b/cmd/init_qemu_stub.go @@ -0,0 +1,12 @@ +//go:build !(darwin || linux) + +package main + +import ( + "fmt" + "runtime" +) + +func runInitQemu(cellName, hostHome, stack string, force bool) error { + return fmt.Errorf("cell init --engine=qemu requires macOS on Apple Silicon (current: %s/%s)", runtime.GOOS, runtime.GOARCH) +} diff --git a/cmd/init_tart_darwin.go b/cmd/init_tart_darwin.go index c866c14..5665a20 100644 --- a/cmd/init_tart_darwin.go +++ b/cmd/init_tart_darwin.go @@ -19,7 +19,7 @@ const tartImagePassword = "admin" // runInitTart prepares the local artifact directory and SSH keypair for a tart // VM. It mirrors what Docker init does: scaffold config, no images, no // containers. The actual VM creation happens in `cell build --engine=tart`. -func runInitTart(cellName, hostHome, projectDir, stack, nixhomePath string, force, noCache bool) error { +func runInitTart(cellName, hostHome, projectDir, stack string, force, noCache bool) error { cfg := tart.InitConfig{ CellName: cellName, HomeDir: hostHome, diff --git a/cmd/init_tart_stub.go b/cmd/init_tart_stub.go index 1d4c79c..5c3bb4a 100644 --- a/cmd/init_tart_stub.go +++ b/cmd/init_tart_stub.go @@ -7,6 +7,6 @@ import ( "runtime" ) -func runInitTart(cellName, hostHome, projectDir, stack, nixhomePath string, force, noCache bool) error { +func runInitTart(cellName, hostHome, projectDir, stack string, force, noCache bool) error { return fmt.Errorf("cell init --engine=tart requires macOS on Apple Silicon (current: %s/%s)", runtime.GOOS, runtime.GOARCH) } diff --git a/cmd/initflow.go b/cmd/initflow.go index 7035d16..99de40e 100644 --- a/cmd/initflow.go +++ b/cmd/initflow.go @@ -8,7 +8,6 @@ import ( "sort" "strings" - "github.com/DimmKirr/devcell/internal/cfg" "github.com/DimmKirr/devcell/internal/ollama" "github.com/DimmKirr/devcell/internal/scaffold" "github.com/DimmKirr/devcell/internal/ux" @@ -205,40 +204,6 @@ func validateNixhomeStructure(nixhomePath string) error { return nil } -// scanStacksFromNixhome scans .devcell/nixhome/ for stacks. -// Falls back to KnownStacks if nixhome isn't available. -// Returns SelectOption with Label (display) and Value (stack name). -func scanStacksFromNixhome(nixhomePath string) ([]ux.SelectOption, string) { - if stacks, err := scanLocalStacks(nixhomePath); err == nil && len(stacks) > 0 { - opts := make([]ux.SelectOption, 0, len(stacks)) - for _, s := range stacks { - mods := stackModulesFromNixhome(nixhomePath, s) - modStr := strings.Join(mods, ", ") - if len(mods) > 6 { - modStr = strings.Join(mods[:6], ", ") + fmt.Sprintf(", +%d more", len(mods)-6) - } - sz := "" - if szVal, ok := cfg.StackSize(s); ok { - sz = szVal - } - label := fmt.Sprintf("%-14s %-52s %s", s, modStr, sz) - opts = append(opts, ux.SelectOption{Label: label, Value: s}) - } - return opts, nixhomePath + "/stacks/*.nix" - } - // No nixhome on disk — fall back to known stack names with sizes. - known := cfg.KnownStacks() - opts := make([]ux.SelectOption, len(known)) - for i, s := range known { - label := s - if sz, ok := cfg.StackSize(s); ok { - label = fmt.Sprintf("%s (%s)", s, sz) - } - opts[i] = ux.SelectOption{Label: label, Value: s} - } - return opts, "built-in (nixhome not available)" -} - // scanModulesFromNixhome scans .devcell/nixhome/modules/ for available modules. // Returns nil if nixhome isn't available. func scanModulesFromNixhome(nixhomePath string) []string { diff --git a/cmd/libvirt_runner.go b/cmd/libvirt_runner.go new file mode 100644 index 0000000..a176194 --- /dev/null +++ b/cmd/libvirt_runner.go @@ -0,0 +1,235 @@ +package main + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/DimmKirr/devcell/internal/cfg" + "github.com/DimmKirr/devcell/internal/config" + "github.com/DimmKirr/devcell/internal/ux" + "github.com/DimmKirr/devcell/internal/vm/libvirt" + "github.com/DimmKirr/devcell/internal/vm/qemu" +) + +// DefaultLibvirtFirmware is the brew edk2 firmware path on the macOS host. +// The CLI never opens this file — it only lands in the domain XML — so it is +// a host path by definition. Override: DEVCELL_LIBVIRT_FIRMWARE. +const DefaultLibvirtFirmware = "/opt/homebrew/share/qemu/edk2-aarch64-code.fd" + +// runLibvirtAgent boots a prepped Windows template on the machine behind +// libvirtd and execs into it over SSH (CELL-377). +// +// Unlike tart/qemu there is no platform stub: this path is designed to run +// inside a Linux cell, driving QEMU+HVF on the macOS host through +// qemu+tcp://host.docker.internal/session. Template building stays on +// `cell build --engine=qemu` (macOS host). +func runLibvirtAgent( + binary string, + defaultFlags, userArgs []string, + cellCfg cfg.CellConfig, + baseDir, hostHome, cellName string, + dryRun, background, debug bool, +) error { + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + uri := cellCfg.Cell.ResolvedLibvirtURI() + firmware := os.Getenv("DEVCELL_LIBVIRT_FIRMWARE") + if firmware == "" { + firmware = DefaultLibvirtFirmware + } + + pathMap := libvirtPathMap(cellCfg, firmware) + + stack := cellCfg.Cell.ResolvedStack() + instanceDir := qemu.InstanceDir(hostHome, cellName) + templateDir := qemu.TemplateDir(hostHome, stack, cellCfg.Cell.Modules) + templateDisk := filepath.Join(templateDir, qemu.ImageName(stack, cellCfg.Cell.Modules)) + instanceDisk := filepath.Join(instanceDir, "disk.qcow2") + varsPath := filepath.Join(instanceDir, "vars.fd") + sshKeyPath := filepath.Join(hostHome, ".devcell", cellName, "qemu", "id_ed25519") + + c := config.Load(baseDir, os.Getenv) + ports := qemu.AllocatePorts(c.PortPrefix, config.DockerAllocatedPorts()) + sshPort := ports.SSHPortUint16() + if cellCfg.Cell.QemuSSHPort > 0 || os.Getenv("DEVCELL_QEMU_SSH_PORT") != "" { + sshPort = uint16(cellCfg.Cell.ResolvedQemuSSHPort()) + } + + spec := qemu.Spec{ + VMName: qemu.InstanceVMName(cellName), + CPUs: uint(cellCfg.Cell.ResolvedQemuCPUs()), + MemoryGB: uint64(cellCfg.Cell.ResolvedQemuMemoryGB()), + DiskPath: instanceDisk, + FirmwarePath: firmware, + VarsPath: varsPath, + SSHPort: sshPort, + VNCPort: ports.VNCPortUint16(), + RDPPort: ports.RDPPortUint16(), + SSHUser: "devcell", + SSHKeyPath: sshKeyPath, + MACAddr: qemu.DeterministicMAC(cellName), + Binary: binary, + DefaultFlags: defaultFlags, + UserArgs: userArgs, + EnvVars: buildQemuEnvVars(cellCfg, cellName), + ProjectDir: baseDir, + DisplayType: "none", + Accel: "hvf", // the VM runs on the macOS host regardless of where the CLI runs + } + + engine := libvirt.NewEngine(uri, spec, pathMap) + + if dryRun { + xml, err := engine.DomainXML() + if err != nil { + return fmt.Errorf("rendering domain XML: %w", err) + } + fmt.Println("libvirt engine (dry-run)") + fmt.Printf(" URI: %s\n", uri) + fmt.Printf(" binary: %s\n", binary) + fmt.Printf(" cell: %s\n", cellName) + fmt.Printf("%s\n", xml) + fmt.Printf("%s\n", strings.Join(engine.SSHArgv(binary, defaultFlags, userArgs), " ")) + return nil + } + + ux.Debugf("libvirt: preflight %s", uri) + if err := libvirt.Preflight(ctx, uri); err != nil { + return err + } + + // --- acquire instance disk (files live on the shared mount) --- + marker := qemu.ProvisionedMarker(hostHome, stack, cellCfg.Cell.Modules) + if _, err := os.Stat(marker); err != nil { + return fmt.Errorf("VM template not provisioned — run `cell build --engine=qemu` on the macOS host first (libvirt mode boots prepped templates only)") + } + if _, err := os.Stat(instanceDisk); err != nil { + ux.Debugf("libvirt: cloning template disk %s → %s", templateDisk, instanceDisk) + if err := qemu.CloneDisk(templateDisk, instanceDisk); err != nil { + return fmt.Errorf("cloning template disk: %w", err) + } + } + if _, err := os.Stat(varsPath); err != nil { + // The CLI cannot read the host's firmware to seed a fresh var store; + // copy the template's vars.fd, which `cell build --engine=qemu` left + // on the shared mount. + if err := copyFile(filepath.Join(templateDir, "vars.fd"), varsPath); err != nil { + return fmt.Errorf("copying template UEFI vars: %w", err) + } + } + + if err := qemu.WritePortMeta(instanceDir, qemu.PortMeta{ + SSHPort: spec.SSHPort, + VNCPort: spec.VNCPort, + RDPPort: spec.RDPPort, + }); err != nil { + ux.Debugf("libvirt: warning: failed to write port metadata: %v", err) + } + + ux.Debugf("libvirt: booting %s via %s", spec.VMName, uri) + if err := engine.Boot(ctx); err != nil { + return fmt.Errorf("booting domain via libvirt: %w", err) + } + if !background && !cellCfg.Cell.ResolvedBackground() { + defer func() { + shutCtx, shutCancel := context.WithTimeout(context.Background(), 60*time.Second) + defer shutCancel() + if err := engine.Shutdown(shutCtx); err != nil { + ux.Debugf("libvirt: shutdown: %v", err) + } + }() + } + + // Project sync (CELL-383): the guest's ~\ is otherwise empty. + syncMode := cellCfg.Cell.ResolvedQemuProjectSync() + syncSpec := spec + syncSpec.SSHHost = engine.SSHHost() + if syncMode != "off" { + if err := runProjectSync(qemu.BuildProjectPushArgv(syncSpec), "pushing project into guest"); err != nil { + return err + } + } + + sshArgv := engine.SSHArgv(binary, defaultFlags, userArgs) + ux.Debugf("libvirt: exec %s", strings.Join(sshArgv, " ")) + cmd := exec.Command(sshArgv[0], sshArgv[1:]...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + runErr := cmd.Run() + + if syncMode == "two-way" { + if err := runProjectSync(qemu.BuildProjectPullArgv(syncSpec), "pulling project back from guest"); err != nil { + ux.Debugf("libvirt: %v", err) + } + } + + if runErr != nil { + if exitErr, ok := runErr.(*exec.ExitError); ok { + ux.Debugf("libvirt: agent exited %d", exitErr.ExitCode()) + return nil + } + return runErr + } + return nil +} + +// runProjectSync executes one scp sync leg; a nil argv (no project dir) is a +// no-op. +func runProjectSync(argv []string, what string) error { + if argv == nil { + return nil + } + ux.Debugf("%s: %s", what, strings.Join(argv, " ")) + cmd := exec.Command(argv[0], argv[1:]...) + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("%s: %w", what, err) + } + return nil +} + +// libvirtPathMap assembles the container→host path map from config, plus an +// identity mapping for the host firmware (already a host path — it must pass +// the strict translator untouched). +func libvirtPathMap(cellCfg cfg.CellConfig, firmware string) libvirt.PathMap { + var m libvirt.PathMap + for from, to := range cellCfg.Cell.LibvirtPathMap { + m = append(m, libvirt.PathMapping{From: from, To: to}) + } + if len(m) > 0 { + dir := filepath.Dir(firmware) + m = append(m, libvirt.PathMapping{From: dir, To: dir}) + } + return m +} + +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { + return err + } + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + if _, err := io.Copy(out, in); err != nil { + return err + } + return out.Close() +} diff --git a/cmd/libvirt_test.go b/cmd/libvirt_test.go new file mode 100644 index 0000000..0fe2841 --- /dev/null +++ b/cmd/libvirt_test.go @@ -0,0 +1,220 @@ +package main_test + +import ( + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// --- libvirt engine dispatch (CELL-372) --- +// +// The libvirt branch is plumbing-first: `--engine=libvirt` (or `[cell] +// engine = "libvirt"`) must reach a libvirt-specific path instead of the +// docker runner. Until CELL-377 lands the non-dry-run path returns a clear +// "not implemented" error; --dry-run prints the resolved URI. + +func libvirtTestHome(t *testing.T, projectTOML string) string { + t.Helper() + home := t.TempDir() + cfgDir := filepath.Join(home, ".config", "devcell") + if err := os.MkdirAll(cfgDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cfgDir, "devcell.toml"), []byte("[cell]\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(home, ".devcell.toml"), []byte(projectTOML), 0644); err != nil { + t.Fatal(err) + } + return home +} + +func TestEngineLibvirt_DryRunPrintsDefaultURI(t *testing.T) { + home := libvirtTestHome(t, "[cell]\n") + cmd := exec.Command(binaryPath, "--engine=libvirt", "shell", "--dry-run") + cmd.Dir = home + cmd.Env = append(os.Environ(), "DEVCELL_BUNK=1", "HOME="+home) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("expected exit 0 in dry-run, got: %v\noutput: %s", err, out) + } + s := string(out) + if !strings.Contains(s, "qemu+tcp://host.docker.internal/session") { + t.Errorf("expected default libvirt URI in dry-run output, got:\n%s", s) + } + if !strings.Contains(s, " 0 { - nixstore.TotalSizeHint = v + nixoci.TotalSizeHint = v } } - return nixstore.Push(cmd.Context(), base, image, io.NopCloser(os.Stdin)) + return nixoci.Push(cmd.Context(), base, image, io.NopCloser(os.Stdin)) } func runNixStorePull(cmd *cobra.Command, args []string) error { @@ -142,7 +142,7 @@ func runNixStorePull(cmd *cobra.Command, args []string) error { return errFlag("--volume and --dir are mutually exclusive") } - resolved, err := nixstore.ResolveImage(cmd.Context(), image, fallback) + resolved, err := nixoci.ResolveImage(cmd.Context(), image, fallback) if err != nil { return err } @@ -153,9 +153,9 @@ func runNixStorePull(cmd *cobra.Command, args []string) error { fmt.Fprintf(os.Stderr, "cache HIT: %s\n", resolved) if volume != "" { - return nixstore.PullToDockerVolume(cmd.Context(), resolved, volume, strip) + return nixoci.PullToDockerVolume(cmd.Context(), resolved, volume, strip) } - return nixstore.Pull(cmd.Context(), resolved, dir, strip) + return nixoci.Pull(cmd.Context(), resolved, dir, strip) } // errFlag wraps a flag-usage error in a way that cobra's `Use:` help diff --git a/cmd/opencode.go b/cmd/opencode.go index 7dd4e71..ae35158 100644 --- a/cmd/opencode.go +++ b/cmd/opencode.go @@ -89,30 +89,48 @@ func opencodeConfigPath(cellHome string) string { // via OPENCODE_CONFIG_CONTENT. func opencodeEnv() map[string]string { dbg := scanFlag("--debug") + useOpenRouter := scanFlag("--openrouter") c, err := config.LoadFromOS() if err != nil { if dbg { fmt.Fprintf(os.Stderr, " opencode: config load failed, using minimal config\n") } - return map[string]string{ + env := map[string]string{ "OPENCODE_CONFIG_CONTENT": string(buildOpencodeJSON(cfg.LLMModelsSection{})), } + if useOpenRouter { + env["OPENROUTER_API_KEY"] = "" + } + return env } // Resolve models: devcell.toml [llm.models] > auto-detect ollama > empty. cellCfg := cfg.LoadFromOS(c.ConfigDir, c.BaseDir) + if !useOpenRouter { + useOpenRouter = cellCfg.LLM.UseOpenRouter + } models := cellCfg.LLM.Models if len(models.Providers) > 0 { if dbg { fmt.Fprintf(os.Stderr, " opencode: using models from devcell.toml [llm.models]\n") } - } else { + } else if !useOpenRouter { if dbg { fmt.Fprintf(os.Stderr, " opencode: no [llm.models] in devcell.toml, probing ollama...\n") } models = autoDetectOllamaModels() } + // OpenRouter mode: opencode has a built-in openrouter provider keyed off + // OPENROUTER_API_KEY, so only the default model needs the provider prefix. + if useOpenRouter { + if m := resolveOpenRouterModel(models.Default, models, dbg); m != "" { + models.Default = "openrouter/" + m + } else { + models.Default = "" + } + } + if dbg { if models.Default != "" { fmt.Fprintf(os.Stderr, " opencode: default model: %s\n", models.Default) @@ -140,9 +158,14 @@ func opencodeEnv() map[string]string { fmt.Fprintf(os.Stderr, " opencode: config written to %s\n", configPath) } - return map[string]string{ + env := map[string]string{ "OPENCODE_CONFIG_CONTENT": string(merged), } + if useOpenRouter { + // Empty placeholder — filled after 1Password by FillOpenRouterKey. + env["OPENROUTER_API_KEY"] = "" + } + return env } // mergeOpencodeConfig reads an existing .opencode.json and merges model/provider @@ -264,8 +287,8 @@ type opencodeJSON struct { } type opencodeProviderJSON struct { - NPM string `json:"npm"` - Options map[string]string `json:"options"` + NPM string `json:"npm,omitempty"` + Options map[string]string `json:"options,omitempty"` Models map[string]opencodeModelJSON `json:"models"` } @@ -293,16 +316,24 @@ func buildOpencodeJSON(ms cfg.LLMModelsSection) []byte { for _, name := range names { prov := ms.Providers[name] - baseURL := prov.BaseURL - if baseURL == "" { - baseURL = knownProviderDefaults[name] - } - models := make(map[string]opencodeModelJSON, len(prov.Models)) for _, m := range prov.Models { models[m] = opencodeModelJSON{Name: m} } + // openrouter is a built-in opencode provider — it ships its own SDK + // and base URL, keyed off OPENROUTER_API_KEY. Overriding npm here + // would detach it from that auth path. + if name == "openrouter" { + doc.Provider[name] = opencodeProviderJSON{Models: models} + continue + } + + baseURL := prov.BaseURL + if baseURL == "" { + baseURL = knownProviderDefaults[name] + } + doc.Provider[name] = opencodeProviderJSON{ NPM: "@ai-sdk/openai-compatible", Options: map[string]string{"baseURL": baseURL}, diff --git a/cmd/opencode_test.go b/cmd/opencode_test.go index 7048829..3f2ddd8 100644 --- a/cmd/opencode_test.go +++ b/cmd/opencode_test.go @@ -338,3 +338,114 @@ func extractEnvFromArgv(argv, key string) string { } return rest } + +// TestOpencode_OpenRouterFlag_InjectsEnv verifies "cell opencode --openrouter --dry-run" +// forwards the resolved OPENROUTER_API_KEY and emits the built-in openrouter provider +// (no npm override) in OPENCODE_CONFIG_CONTENT. +func TestOpencode_OpenRouterFlag_InjectsEnv(t *testing.T) { + home := scaffoldedHome(t) + + cfgDir := filepath.Join(home, ".config", "devcell") + tomlContent := `[cell] +[llm.models] +default = "openrouter/moonshotai/kimi-k3" +[llm.models.providers.openrouter] +models = ["moonshotai/kimi-k3", "deepseek/deepseek-v4-pro"] +` + if err := os.WriteFile(filepath.Join(cfgDir, "devcell.toml"), []byte(tomlContent), 0644); err != nil { + t.Fatal(err) + } + + cmd := exec.Command(binaryPath, "opencode", "--openrouter", "--dry-run") + cmd.Dir = home + cmd.Env = append(os.Environ(), "DEVCELL_BUNK=1", "HOME="+home, "OPENROUTER_API_KEY=sk-or-test-key") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("opencode --openrouter --dry-run failed: %v\noutput: %s", err, out) + } + + argv := string(out) + if !strings.Contains(argv, "OPENROUTER_API_KEY=sk-or-test-key") { + t.Errorf("expected OPENROUTER_API_KEY=sk-or-test-key in argv:\n%s", argv) + } + + jsonStr := extractEnvFromArgv(argv, "OPENCODE_CONFIG_CONTENT") + if jsonStr == "" { + t.Fatal("could not extract OPENCODE_CONFIG_CONTENT value") + } + var parsed map[string]interface{} + if err := json.Unmarshal([]byte(jsonStr), &parsed); err != nil { + t.Fatalf("invalid JSON: %v\ncontent: %s", err, jsonStr) + } + + // Default model keeps the openrouter/ prefix (opencode's provider/model format). + if parsed["model"] != "openrouter/moonshotai/kimi-k3" { + t.Errorf("expected model openrouter/moonshotai/kimi-k3, got: %v", parsed["model"]) + } + + provider, ok := parsed["provider"].(map[string]interface{}) + if !ok { + t.Fatalf("provider not a map: %v", parsed["provider"]) + } + or, ok := provider["openrouter"].(map[string]interface{}) + if !ok { + t.Fatalf("openrouter provider not found: %v", provider) + } + // Built-in provider: no npm override, opencode supplies its own SDK + baseURL. + if _, hasNPM := or["npm"]; hasNPM { + t.Errorf("openrouter provider must not override npm (built-in provider), got: %v", or["npm"]) + } + models, ok := or["models"].(map[string]interface{}) + if !ok { + t.Fatalf("openrouter models not a map: %v", or["models"]) + } + if _, ok := models["moonshotai/kimi-k3"]; !ok { + t.Errorf("moonshotai/kimi-k3 not in models: %v", models) + } + if _, ok := models["deepseek/deepseek-v4-pro"]; !ok { + t.Errorf("deepseek/deepseek-v4-pro not in models: %v", models) + } +} + +// TestOpencode_ConfigUseOpenRouter_InjectsEnv verifies [llm] use_openrouter=true +// activates openrouter mode without the flag. +func TestOpencode_ConfigUseOpenRouter_InjectsEnv(t *testing.T) { + home := scaffoldedHome(t) + + cfgDir := filepath.Join(home, ".config", "devcell") + tomlContent := `[cell] +[llm] +use_openrouter = true +` + if err := os.WriteFile(filepath.Join(cfgDir, "devcell.toml"), []byte(tomlContent), 0644); err != nil { + t.Fatal(err) + } + + cmd := exec.Command(binaryPath, "opencode", "--dry-run") + cmd.Dir = home + cmd.Env = append(os.Environ(), "DEVCELL_BUNK=1", "HOME="+home, "OPENROUTER_API_KEY=sk-or-test-key") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("opencode --dry-run failed: %v\noutput: %s", err, out) + } + + if !strings.Contains(string(out), "OPENROUTER_API_KEY=sk-or-test-key") { + t.Errorf("expected OPENROUTER_API_KEY=sk-or-test-key in argv:\n%s", out) + } +} + +// TestOpencode_OpenRouterNoKey_Error verifies a missing OPENROUTER_API_KEY fails the boot. +func TestOpencode_OpenRouterNoKey_Error(t *testing.T) { + home := scaffoldedHome(t) + + cmd := exec.Command(binaryPath, "opencode", "--openrouter", "--dry-run") + cmd.Dir = home + cmd.Env = []string{"DEVCELL_BUNK=1", "HOME=" + home, "PATH=" + os.Getenv("PATH")} + out, err := cmd.CombinedOutput() + if err == nil { + t.Fatalf("expected error when OPENROUTER_API_KEY is missing, but got success:\n%s", out) + } + if !strings.Contains(string(out), "OPENROUTER_API_KEY") { + t.Errorf("expected error mentioning OPENROUTER_API_KEY, got:\n%s", out) + } +} diff --git a/cmd/openrouter.go b/cmd/openrouter.go new file mode 100644 index 0000000..90e76c7 --- /dev/null +++ b/cmd/openrouter.go @@ -0,0 +1,26 @@ +package main + +import ( + "fmt" + "os" +) + +// OpenRouter exposes two API surfaces: the Anthropic-compat endpoint used by +// Claude Code (ANTHROPIC_BASE_URL, no /v1 — Claude Code appends it) and the +// OpenAI-compat endpoint used by Codex and OpenCode SDKs. +const ( + openRouterAnthropicBaseURL = "https://openrouter.ai/api" + openRouterOpenAIBaseURL = "https://openrouter.ai/api/v1" +) + +// FillOpenRouterKey fills OPENROUTER_API_KEY from the environment. Called +// after 1Password resolution so the key is available. Env builders that need +// the key set OPENROUTER_API_KEY to "" as a placeholder; runAgent fills it. +func FillOpenRouterKey(env map[string]string) error { + apiKey := os.Getenv("OPENROUTER_API_KEY") + if apiKey == "" { + return fmt.Errorf("--openrouter requires OPENROUTER_API_KEY env var (set it or add to [op] documents)") + } + env["OPENROUTER_API_KEY"] = apiKey + return nil +} diff --git a/cmd/promptflags.go b/cmd/promptflags.go new file mode 100644 index 0000000..b9aacd9 --- /dev/null +++ b/cmd/promptflags.go @@ -0,0 +1,36 @@ +package main + +import ( + "github.com/DimmKirr/devcell/internal/cfg" + "github.com/DimmKirr/devcell/internal/config" + "github.com/DimmKirr/devcell/internal/runner" +) + +// claudePromptFlags materializes both prompt layers and returns the argv that +// points claude at them. +// +// The base flag is emitted only when a base prompt is configured. Claude +// Code's stock prompt is ~10.6 KB of tool guidance and safety instructions, +// and --system-prompt-file discards all of it — so an unconfigured cell must +// keep it. +// +// Only the file forms are emitted: claude rejects the inline and file forms +// of the same layer together, and inline capped the prompt at MAX_ARG_STRLEN +// while exposing its text to `ps aux` and `docker inspect`. +func claudePromptFlags(c config.Config, cellCfg cfg.CellConfig, opts runner.ResolveOpts) ([]string, error) { + basePath, err := runner.WriteBasePrompt(c, opts) + if err != nil { + return nil, err + } + overlayPath, err := runner.WriteOverlayPrompt(c, cellCfg, opts) + if err != nil { + return nil, err + } + + var flags []string + if basePath != "" { + flags = append(flags, "--system-prompt-file", basePath) + } + // The overlay always exists: container context is never empty. + return append(flags, "--append-system-prompt-file", overlayPath), nil +} diff --git a/cmd/promptflags_test.go b/cmd/promptflags_test.go new file mode 100644 index 0000000..11062fc --- /dev/null +++ b/cmd/promptflags_test.go @@ -0,0 +1,174 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DimmKirr/devcell/internal/cfg" + "github.com/DimmKirr/devcell/internal/config" + "github.com/DimmKirr/devcell/internal/runner" +) + +func promptFlagsConfig(t *testing.T) config.Config { + t.Helper() + return config.Config{ + AppName: "devcell-85", + BaseDir: t.TempDir(), + CellName: "main", + HostUser: "dmitry", + HostHome: "/Users/dmitry", + } +} + +// The prompt reaches claude as a file path, never as an inline argv element: +// inline capped it at MAX_ARG_STRLEN and published it to `ps aux`. +func TestClaudePromptFlags_EmitsAppendSystemPromptFile(t *testing.T) { + c := promptFlagsConfig(t) + + flags, err := claudePromptFlags(c, cfg.CellConfig{}, runner.ResolveOpts{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + want := []string{"--append-system-prompt-file", "/devcell-85/.devcell/prompts/main/additional-systemprompt.md"} + if len(flags) != len(want) { + t.Fatalf("flags = %v, want %v", flags, want) + } + for i := range want { + if flags[i] != want[i] { + t.Errorf("flags[%d] = %q, want %q", i, flags[i], want[i]) + } + } +} + +func TestClaudePromptFlags_NeverEmitsInlineForm(t *testing.T) { + c := promptFlagsConfig(t) + + flags, err := claudePromptFlags(c, cfg.CellConfig{}, runner.ResolveOpts{AppendEnvInline: "be terse"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // claude rejects the inline and file forms together, so only the file + // form may ever be emitted. + for _, f := range flags { + if f == "--append-system-prompt" { + t.Fatalf("inline --append-system-prompt must not be emitted, got %v", flags) + } + } + // The prompt text itself must not appear in argv. + for _, f := range flags { + if strings.Contains(f, "be terse") { + t.Errorf("prompt text leaked into argv: %v", flags) + } + } +} + +func TestClaudePromptFlags_WritesResolvedPromptToFile(t *testing.T) { + c := promptFlagsConfig(t) + + if _, err := claudePromptFlags(c, cfg.CellConfig{}, runner.ResolveOpts{AppendEnvInline: "be terse"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + body, err := os.ReadFile(filepath.Join(c.BaseDir, ".devcell", "prompts", "main", "additional-systemprompt.md")) + if err != nil { + t.Fatalf("read generated file: %v", err) + } + got := string(body) + if !strings.Contains(got, "Docker container") { + t.Error("generated overlay missing container context") + } + if !strings.Contains(got, "be terse") { + t.Error("generated overlay missing resolved prompt") + } +} + +func TestClaudePromptFlags_PropagatesResolverError(t *testing.T) { + c := promptFlagsConfig(t) + + _, err := claudePromptFlags(c, cfg.CellConfig{}, runner.ResolveOpts{ + EnvInline: "a", + EnvFile: "/nonexistent/b.md", + }) + if err == nil { + t.Fatal("expected ambiguous-source error to propagate, got nil") + } +} + +// A configured base replaces Claude Code's built-in prompt, so it travels on +// --system-prompt-file alongside the overlay. +func TestClaudePromptFlags_EmitsBaseFlagWhenConfigured(t *testing.T) { + c := promptFlagsConfig(t) + + flags, err := claudePromptFlags(c, cfg.CellConfig{ + LLM: cfg.LLMSection{SystemPrompt: "you are a release bot"}, + }, runner.ResolveOpts{ + CellCfg: cfg.CellConfig{LLM: cfg.LLMSection{SystemPrompt: "you are a release bot"}}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + joined := strings.Join(flags, " ") + if !strings.Contains(joined, "--system-prompt-file /devcell-85/.devcell/prompts/main/system-prompt.md") { + t.Errorf("expected base flag, got %v", flags) + } + if !strings.Contains(joined, "--append-system-prompt-file /devcell-85/.devcell/prompts/main/additional-systemprompt.md") { + t.Errorf("expected overlay flag, got %v", flags) + } +} + +// Unconfigured base must leave the stock prompt in effect — no flag at all. +func TestClaudePromptFlags_NoBaseFlagWhenUnconfigured(t *testing.T) { + c := promptFlagsConfig(t) + + flags, err := claudePromptFlags(c, cfg.CellConfig{}, runner.ResolveOpts{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + for _, f := range flags { + if f == "--system-prompt-file" { + t.Fatalf("base flag must not be emitted when unconfigured, got %v", flags) + } + } +} + +// Base and overlay land in separate files: container context belongs only to +// the overlay, and the base must not be polluted by it. +func TestClaudePromptFlags_BaseAndOverlayAreSeparateFiles(t *testing.T) { + c := promptFlagsConfig(t) + llm := cfg.LLMSection{SystemPrompt: "BASE-ONLY", AppendSystemPrompt: "OVERLAY-ONLY"} + + if _, err := claudePromptFlags(c, cfg.CellConfig{LLM: llm}, runner.ResolveOpts{ + CellCfg: cfg.CellConfig{LLM: llm}, + }); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + read := func(name string) string { + b, err := os.ReadFile(filepath.Join(c.BaseDir, ".devcell", "prompts", "main", name)) + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + return string(b) + } + + base := read("system-prompt.md") + overlay := read("additional-systemprompt.md") + + if base != "BASE-ONLY" { + t.Errorf("base file = %q, want verbatim base prompt", base) + } + if strings.Contains(base, "Docker container") { + t.Error("container context leaked into the base file") + } + if !strings.Contains(overlay, "Docker container") || !strings.Contains(overlay, "OVERLAY-ONLY") { + t.Errorf("overlay file = %q, want container context + append text", overlay) + } + if strings.Contains(overlay, "BASE-ONLY") { + t.Error("base prompt leaked into the overlay file") + } +} diff --git a/cmd/qemu_runner.go b/cmd/qemu_runner.go new file mode 100644 index 0000000..c88e696 --- /dev/null +++ b/cmd/qemu_runner.go @@ -0,0 +1,379 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/exec" + "os/signal" + "path/filepath" + "runtime" + "strings" + "syscall" + "time" + + "github.com/DimmKirr/devcell/internal/cfg" + "github.com/DimmKirr/devcell/internal/config" + "github.com/DimmKirr/devcell/internal/ux" + "github.com/DimmKirr/devcell/internal/vm/qemu" +) + +// runQemuAgent is the qemu-engine equivalent of runTartAgent. +// +// Lifecycle (managed Windows VM via QEMU): +// 1. Acquire VM (clone template or auto-build if missing) +// 2. Boot VM, wait for SSH +// 3. Exec into VM via SSH (PowerShell) +// +// On non-darwin with --debug: mock/simulate every step with [MOCK] prefix. +// On darwin with --debug: real execution with ux.Debugf logging. +func runQemuAgent( + binary string, + defaultFlags, userArgs []string, + cellCfg cfg.CellConfig, + baseDir, hostHome, cellName string, + dryRun, background, debug bool, +) error { + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + if runtime.GOOS != "darwin" && !debug && !dryRun { + return fmt.Errorf("qemu engine requires macOS (use --debug to simulate on %s)", runtime.GOOS) + } + mock := runtime.GOOS != "darwin" && !dryRun + + logf := func(format string, args ...any) { + if mock { + fmt.Printf("[MOCK %s]: %s\n", runtime.GOOS, fmt.Sprintf(format, args...)) + } else { + ux.Debugf("qemu: "+format, args...) + } + } + + if mock { + logf("runtime.GOOS=%s (not darwin) — entering mock mode", runtime.GOOS) + } + logf("binary=%q defaultFlags=%v userArgs=%v", binary, defaultFlags, userArgs) + logf("cellName=%q baseDir=%q hostHome=%q", cellName, baseDir, hostHome) + logf("background=%v dryRun=%v debug=%v", background, dryRun, debug) + + // --- env var assembly --- + logf("assembling env vars to forward into VM") + envVars := buildQemuEnvVars(cellCfg, cellName) + for _, kv := range envVars { + logf(" env: %s", kv) + } + + // --- consume --force and --stack from userArgs (qemu-specific) --- + force := false + stackOverride := "" + var filteredUserArgs []string + for _, a := range userArgs { + if a == "--force" { + force = true + continue + } + if strings.HasPrefix(a, "--stack=") { + stackOverride = strings.TrimPrefix(a, "--stack=") + continue + } + filteredUserArgs = append(filteredUserArgs, a) + } + userArgs = filteredUserArgs + + stack := cellCfg.Cell.ResolvedStack() + if stackOverride != "" { + logf("--stack=%s overrides resolved stack %q", stackOverride, stack) + stack = stackOverride + } + + // --- resolve paths --- + instanceDir := qemu.InstanceDir(hostHome, cellName) + templateDir := qemu.TemplateDir(hostHome, stack, cellCfg.Cell.Modules) + templateDisk := filepath.Join(templateDir, qemu.ImageName(stack, cellCfg.Cell.Modules)) + instanceDisk := filepath.Join(instanceDir, "disk.qcow2") + varsPath := filepath.Join(instanceDir, "vars.fd") + sshKeyPath := filepath.Join(qemuKeyDir(hostHome, cellName), "id_ed25519") + + // Port allocation — same bunk-based scheme as Docker runner (CELL-352) + c := config.Load(baseDir, os.Getenv) + taken := config.DockerAllocatedPorts() + ports := qemu.AllocatePorts(c.PortPrefix, taken) + + sshPort := ports.SSHPortUint16() + if cellCfg.Cell.QemuSSHPort > 0 || os.Getenv("DEVCELL_QEMU_SSH_PORT") != "" { + sshPort = uint16(cellCfg.Cell.ResolvedQemuSSHPort()) + } + + spec := qemu.Spec{ + VMName: qemu.InstanceVMName(cellName), + CPUs: uint(cellCfg.Cell.ResolvedQemuCPUs()), + MemoryGB: uint64(cellCfg.Cell.ResolvedQemuMemoryGB()), + DiskPath: instanceDisk, + FirmwarePath: qemu.FirmwarePath(), + VarsPath: varsPath, + SSHPort: sshPort, + VNCPort: ports.VNCPortUint16(), + RDPPort: ports.RDPPortUint16(), + SSHHost: cellCfg.Cell.ResolvedQemuSSHHost(), + SSHUser: "devcell", + SSHKeyPath: sshKeyPath, + MACAddr: qemu.DeterministicMAC(cellName), + Binary: binary, + DefaultFlags: defaultFlags, + UserArgs: userArgs, + EnvVars: envVars, + ProjectDir: baseDir, + DisplayType: cellCfg.Cell.ResolvedQemuDisplay(), + QMPSocketDir: instanceDir, + KVM: cellCfg.Cell.ResolvedKVM(), + } + spec.ApplyDefaults() + + logf("templateDir=%s instanceDir=%s", templateDir, instanceDir) + logf("templateDisk=%s instanceDisk=%s", templateDisk, instanceDisk) + logf("spec: cpus=%d mem=%dGB ssh=%s:%d vnc=%d rdp=%d display=%s", spec.CPUs, spec.MemoryGB, spec.SSHHost, spec.SSHPort, spec.VNCPort, spec.RDPPort, spec.DisplayType) + logf("accel: %s — %s", spec.Accel, spec.AccelReason) + + // --- lifecycle: acquire VM (real or mock) --- + if !dryRun && !mock { + if force { + logf("--force: removing existing instance disk and vars") + os.Remove(instanceDisk) + os.Remove(varsPath) + } + + // Detect managed VM: PID file + QMP state query + qemu.CleanStalePIDFile(instanceDir) + vmRunning := false + if pid, err := qemu.ReadPIDFile(instanceDir); err == nil { + qmpSock := qemu.QMPSocketPath(spec) + if state, err := qemu.QueryVMState(qmpSock); err == nil && state == qemu.StateRunning { + logf("detected running VM (PID %d, QMP=%s)", pid, state) + vmRunning = true + } + } + + diskInfo, diskErr := os.Stat(instanceDisk) + tplInfo, tplErr := os.Stat(templateDisk) + var diskSize, tplSize int64 + if diskErr == nil { + diskSize = diskInfo.Size() + } + if tplErr == nil { + tplSize = tplInfo.Size() + } + marker := qemu.ProvisionedMarker(hostHome, stack, cellCfg.Cell.Modules) + _, markerErr := os.Stat(marker) + actions := qemu.DecideLaunchActions(qemu.LaunchInputs{ + ExplicitBuild: force, + DiskExists: diskErr == nil, + DiskSizeBytes: diskSize, + TemplateExists: tplErr == nil, + TemplateSizeBytes: tplSize, + VMRunning: vmRunning, + Provisioned: markerErr == nil, + }) + logf("launch actions: %v", actions) + + attachMode := false + for _, action := range actions { + switch action { + case qemu.ActionAttach: + logf("attaching to running VM (skipping boot)") + attachMode = true + + case qemu.ActionBuild: + logf("auto-build: template missing or corrupt — running build with stack=%q", stack) + if !force { + fmt.Printf("VM template is missing or corrupt — a full rebuild is required (stack %q).\n", stack) + ok, err := ux.GetConfirmation("Rebuild now?") + if err != nil { + return fmt.Errorf("prompt: %w", err) + } + if !ok { + return fmt.Errorf("rebuild declined — run `cell build --engine=qemu` manually") + } + } + os.Remove(templateDisk) + os.Remove(instanceDisk) + if err := runBuildQemu(cellName, hostHome, baseDir, stack, false, false, false, cellCfg.Cell); err != nil { + return fmt.Errorf("auto-build failed: %w", err) + } + if err := os.MkdirAll(instanceDir, 0755); err != nil { + return fmt.Errorf("creating instance dir: %w", err) + } + if err := qemu.CloneDisk(templateDisk, instanceDisk); err != nil { + return fmt.Errorf("cloning template disk: %w", err) + } + if err := qemu.PrepareVarsFile(spec.FirmwarePath, varsPath); err != nil { + return fmt.Errorf("preparing UEFI vars: %w", err) + } + logf("instance disk cloned from template") + + case qemu.ActionClone: + logf("cloning template to instance") + if err := os.MkdirAll(instanceDir, 0755); err != nil { + return fmt.Errorf("creating instance dir: %w", err) + } + if err := qemu.CloneDisk(templateDisk, instanceDisk); err != nil { + return fmt.Errorf("cloning template disk: %w", err) + } + if err := qemu.PrepareVarsFile(spec.FirmwarePath, varsPath); err != nil { + return fmt.Errorf("preparing UEFI vars: %w", err) + } + logf("instance disk cloned from template") + + case qemu.ActionUseLocal: + logf("using existing instance disk: %s", instanceDisk) + if _, err := os.Stat(varsPath); err != nil { + logf("vars file missing — preparing from firmware") + if err := qemu.PrepareVarsFile(spec.FirmwarePath, varsPath); err != nil { + return fmt.Errorf("preparing UEFI vars: %w", err) + } + } + } + } + + if !attachMode { + logf("provisioned marker verified: %s", marker) + + // Boot VM + vm := qemu.NewVM(spec, qemu.NopObserver{}, instanceDir) + logf("starting QEMU VM: %s", spec.VMName) + if err := vm.Start(ctx); err != nil { + return fmt.Errorf("starting QEMU: %w", err) + } + defer func() { + logf("shutting down QEMU VM") + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := vm.Shutdown(ctx); err != nil { + logf("graceful shutdown failed: %v — force stopping", err) + vm.ForceStop() + } + }() + } + + // Write port metadata for discovery by cell vnc/rdp (CELL-352) + if err := qemu.WritePortMeta(instanceDir, qemu.PortMeta{ + SSHPort: spec.SSHPort, + VNCPort: spec.VNCPort, + RDPPort: spec.RDPPort, + }); err != nil { + logf("warning: failed to write port metadata: %v", err) + } + + // Wait for SSH (both attach and fresh boot need this) + logf("waiting for SSH on %s:%d", spec.SSHHost, spec.SSHPort) + if err := qemu.WaitForSSH(spec.SSHHost, spec.SSHPort, 5*time.Minute, 3*time.Second, qemu.NopObserver{}); err != nil { + return fmt.Errorf("waiting for SSH: %w", err) + } + logf("SSH ready") + } + + // --- mock mode --- + if mock { + logf("[qemu] preflight: GOOS=%s GOARCH=%s", runtime.GOOS, runtime.GOARCH) + logf("[qemu] would boot QEMU VM and connect via SSH") + logf("[qemu] guest SSH ready (simulated)") + } + + // --- build SSH command --- + sshArgv := qemu.BuildSSHArgv(spec) + logf("ssh command: %s", strings.Join(sshArgv, " ")) + + if dryRun { + fmt.Printf("%s\n", strings.Join(sshArgv, " ")) + return nil + } + + if mock { + logf("would exec: %s", strings.Join(sshArgv, " ")) + logf("skipping exec (mock mode)") + return nil + } + + // --- project sync (CELL-383) --- + syncMode := cellCfg.Cell.ResolvedQemuProjectSync() + if syncMode != "off" { + if err := runProjectSync(qemu.BuildProjectPushArgv(spec), "pushing project into guest"); err != nil { + return err + } + } + + // --- exec SSH into VM --- + logf("connecting via SSH...") + cmd := exec.Command(sshArgv[0], sshArgv[1:]...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + runErr := cmd.Run() + + if syncMode == "two-way" { + if err := runProjectSync(qemu.BuildProjectPullArgv(spec), "pulling project back from guest"); err != nil { + logf("%v", err) + } + } + + if runErr != nil { + if exitErr, ok := runErr.(*exec.ExitError); ok { + os.Exit(exitErr.ExitCode()) + } + return runErr + } + return nil +} + +// buildQemuEnvVars collects env vars to forward into the Windows VM via SSH. +func buildQemuEnvVars(cellCfg cfg.CellConfig, cellName string) []string { + var envs []string + e := func(k, v string) { + if v != "" { + envs = append(envs, k+"="+v) + } + } + + e("TERM", os.Getenv("TERM")) + e("DEVCELL_CELL_NAME", cellName) + + gitCfg := cellCfg.Git + hostGitEnv := os.Getenv("GIT_AUTHOR_NAME") != "" || + os.Getenv("GIT_AUTHOR_EMAIL") != "" || + os.Getenv("GIT_COMMITTER_NAME") != "" || + os.Getenv("GIT_COMMITTER_EMAIL") != "" + if hostGitEnv { + e("GIT_AUTHOR_NAME", os.Getenv("GIT_AUTHOR_NAME")) + e("GIT_AUTHOR_EMAIL", os.Getenv("GIT_AUTHOR_EMAIL")) + e("GIT_COMMITTER_NAME", os.Getenv("GIT_COMMITTER_NAME")) + e("GIT_COMMITTER_EMAIL", os.Getenv("GIT_COMMITTER_EMAIL")) + } else if gitCfg.HasIdentity() { + e("GIT_AUTHOR_NAME", gitCfg.AuthorName) + e("GIT_AUTHOR_EMAIL", gitCfg.AuthorEmail) + e("GIT_COMMITTER_NAME", gitCfg.ResolvedCommitterName()) + e("GIT_COMMITTER_EMAIL", gitCfg.ResolvedCommitterEmail()) + } else { + if out, err := exec.Command("git", "config", "user.name").Output(); err == nil { + e("GIT_AUTHOR_NAME", trimNL(string(out))) + e("GIT_COMMITTER_NAME", trimNL(string(out))) + } + if out, err := exec.Command("git", "config", "user.email").Output(); err == nil { + e("GIT_AUTHOR_EMAIL", trimNL(string(out))) + e("GIT_COMMITTER_EMAIL", trimNL(string(out))) + } + } + + tz := cellCfg.Cell.Timezone + if tz == "" { + tz = os.Getenv("TZ") + } + e("TZ", tz) + + locale := cellCfg.Cell.Locale + if locale == "" { + locale = os.Getenv("LANG") + } + e("LANG", locale) + + return envs +} diff --git a/cmd/qemu_test.go b/cmd/qemu_test.go new file mode 100644 index 0000000..b04c723 --- /dev/null +++ b/cmd/qemu_test.go @@ -0,0 +1,517 @@ +package main_test + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// qemuTestHome sets up a temp HOME with config dir and .devcell.toml for qemu tests. +func qemuTestHome(t *testing.T) string { + t.Helper() + return qemuTestHomeWithTOML(t, "[cell]\n") +} + +// qemuTestHomeWithTOML sets up a temp HOME with custom project TOML content. +func qemuTestHomeWithTOML(t *testing.T, projectTOML string) string { + t.Helper() + home := t.TempDir() + cfgDir := filepath.Join(home, ".config", "devcell") + if err := os.MkdirAll(cfgDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cfgDir, "devcell.toml"), []byte("[cell]\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(home, ".devcell.toml"), []byte(projectTOML), 0644); err != nil { + t.Fatal(err) + } + return home +} + +// --- Cross-platform smoke tests (dry-run, mock, help) --- + +func TestEngineQemu_DryRunPrintsSSH(t *testing.T) { + home := qemuTestHome(t) + cmd := exec.Command(binaryPath, "--engine=qemu", "--local", "shell", "--dry-run") + cmd.Dir = home + cmd.Env = append(os.Environ(), "DEVCELL_BUNK=1", "HOME="+home) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("expected exit 0, got: %v\noutput: %s", err, out) + } + s := string(out) + if !strings.Contains(s, "ssh") { + t.Errorf("expected 'ssh' in dry-run output, got:\n%s", s) + } + if !strings.Contains(s, "powershell") { + t.Errorf("expected 'powershell' in dry-run output, got:\n%s", s) + } + if strings.Contains(s, "docker run") { + t.Errorf("qemu engine should not print docker run argv, got:\n%s", s) + } +} + +func TestEngineQemu_DryRunContainsBinary(t *testing.T) { + home := qemuTestHome(t) + cmd := exec.Command(binaryPath, "--engine=qemu", "--local", "claude", "--dry-run") + cmd.Dir = home + cmd.Env = append(os.Environ(), "DEVCELL_BUNK=1", "HOME="+home) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("expected exit 0, got: %v\noutput: %s", err, out) + } + if !strings.Contains(string(out), "claude") { + t.Errorf("expected 'claude' in dry-run output, got:\n%s", out) + } +} + +func TestEngineQemu_DryRunNoDocker(t *testing.T) { + home := qemuTestHome(t) + cmd := exec.Command(binaryPath, "--engine=qemu", "--local", "claude", "--dry-run") + cmd.Dir = home + cmd.Env = append(os.Environ(), "DEVCELL_BUNK=1", "HOME="+home) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("expected exit 0, got: %v\noutput: %s", err, out) + } + if strings.Contains(string(out), "docker") { + t.Errorf("qemu engine should not involve docker, got:\n%s", out) + } +} + +func TestEngineQemu_DryRunContainsEnvVars(t *testing.T) { + home := qemuTestHome(t) + cmd := exec.Command(binaryPath, "--engine=qemu", "--local", "shell", "--dry-run") + cmd.Dir = home + cmd.Env = append(os.Environ(), "DEVCELL_BUNK=1", "HOME="+home, "TERM=xterm-256color") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("expected exit 0, got: %v\noutput: %s", err, out) + } + if !strings.Contains(string(out), "TERM=") { + t.Errorf("expected TERM= in dry-run output, got:\n%s", out) + } +} + +func TestEngineQemu_DryRunSSHPort(t *testing.T) { + home := qemuTestHome(t) + cmd := exec.Command(binaryPath, "--engine=qemu", "--local", "shell", "--dry-run") + cmd.Dir = home + // DEVCELL_BUNK=1, no SESSION_PORT_PREFIX → portPrefix="1" → SSH=ClampPort("122")=122 → hoisted to 10122 + cmd.Env = append(os.Environ(), "DEVCELL_BUNK=1", "HOME="+home) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("expected exit 0, got: %v\noutput: %s", err, out) + } + if !strings.Contains(string(out), "-p 10122") { + t.Errorf("expected '-p 10122' (bunk-based SSH port) in dry-run output, got:\n%s", out) + } +} + +func TestEngineQemu_DryRunCustomSSHPort(t *testing.T) { + home := qemuTestHomeWithTOML(t, "[cell]\nqemu_ssh_port = 3333\n") + cmd := exec.Command(binaryPath, "--engine=qemu", "--local", "shell", "--dry-run") + cmd.Dir = home + cmd.Env = append(os.Environ(), "DEVCELL_BUNK=1", "HOME="+home) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("expected exit 0, got: %v\noutput: %s", err, out) + } + if !strings.Contains(string(out), "-p 3333") { + t.Errorf("expected '-p 3333' (custom SSH port) in dry-run output, got:\n%s", out) + } +} + +func TestEngineQemu_EngineHelpIncludesQemu(t *testing.T) { + out, err := exec.Command(binaryPath, "--help").CombinedOutput() + if err != nil { + t.Fatalf("--help exited non-zero: %v\noutput: %s", err, out) + } + if !strings.Contains(string(out), "qemu") { + t.Errorf("expected 'qemu' in --help output for --engine flag, got:\n%s", out) + } +} + +func TestBackgroundFlag_StrippedFromArgsQemu(t *testing.T) { + home := qemuTestHome(t) + cmd := exec.Command(binaryPath, "--engine=qemu", "--local", "--background", "shell", "--dry-run") + cmd.Dir = home + cmd.Env = append(os.Environ(), "DEVCELL_BUNK=1", "HOME="+home) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("expected exit 0, got: %v\noutput: %s", err, out) + } + s := string(out) + if strings.Contains(s, "--background") { + t.Errorf("--background should be stripped from forwarded args, got:\n%s", s) + } +} + +// --- Non-darwin tests --- + +func TestEngineQemu_NoDebugOnLinux(t *testing.T) { + if runtime.GOOS == "darwin" { + t.Skip("this test validates the non-darwin error path") + } + home := qemuTestHome(t) + cmd := exec.Command(binaryPath, "--engine=qemu", "--local", "shell") + cmd.Dir = home + cmd.Env = append(os.Environ(), "DEVCELL_BUNK=1", "HOME="+home) + out, err := cmd.CombinedOutput() + if err == nil { + t.Fatalf("expected error on non-darwin without --debug, got exit 0:\n%s", out) + } + s := string(out) + if !strings.Contains(s, "qemu engine requires macOS") { + t.Errorf("expected 'qemu engine requires macOS' in error, got:\n%s", s) + } + if !strings.Contains(s, "--debug to simulate") { + t.Errorf("expected '--debug to simulate' hint in error, got:\n%s", s) + } +} + +func TestEngineQemu_DebugMockOutput(t *testing.T) { + home := qemuTestHome(t) + cmd := exec.Command(binaryPath, "--engine=qemu", "--local", "--debug", "shell") + cmd.Dir = home + cmd.Env = append(os.Environ(), "DEVCELL_BUNK=1", "HOME="+home) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("expected exit 0 with --debug mock, got: %v\noutput: %s", err, out) + } + s := string(out) + if runtime.GOOS == "darwin" { + t.Skip("mock output only on non-darwin") + } + for _, want := range []string{ + "[MOCK", + "mock mode", + "would exec", + } { + if !strings.Contains(s, want) { + t.Errorf("expected %q in debug mock output, got:\n%s", want, s) + } + } +} + +func TestEngineQemu_DebugMockNoDocker(t *testing.T) { + if runtime.GOOS == "darwin" { + t.Skip("mock output only on non-darwin") + } + home := qemuTestHome(t) + cmd := exec.Command(binaryPath, "--engine=qemu", "--local", "--debug", "shell") + cmd.Dir = home + cmd.Env = append(os.Environ(), "DEVCELL_BUNK=1", "HOME="+home) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("expected exit 0, got: %v\noutput: %s", err, out) + } + if strings.Contains(string(out), "docker") { + t.Errorf("mock output should not mention docker, got:\n%s", out) + } +} + +// --- macOS-only E2E lifecycle tests --- +// +// These tests exercise the full QEMU Windows VM lifecycle on macOS Apple Silicon. +// They require: +// - darwin/arm64 runtime +// - qemu-system-aarch64 installed (brew install qemu) +// - DEVCELL_QEMU_WINDOWS_ISO set to a valid Windows 11 ARM64 ISO path +// +// Run modes: +// go test ./cmd -run TestQemuE2E → skips (short mode) +// go test ./cmd -run TestQemuE2E -count=1 -timeout=0 → full lifecycle (~45 min) +// +// The subtests are ordered and share state via a temp HOME directory: +// Init → creates SSH keys + downloads VirtIO drivers (~2 min) +// Build → installs Windows + provisions (~30-45 min) +// Shell → verifies instance clone + dry-run SSH command + +func TestQemuE2E_FullLifecycle(t *testing.T) { + if runtime.GOOS != "darwin" || runtime.GOARCH != "arm64" { + t.Skip("QEMU E2E requires macOS on Apple Silicon (darwin/arm64)") + } + if testing.Short() { + t.Skip("long: QEMU E2E lifecycle takes ~45 min — run with -count=1 -timeout=0") + } + + // Check prerequisites + qemuBin, err := exec.LookPath("qemu-system-aarch64") + if err != nil { + t.Skip("qemu-system-aarch64 not found — install with: brew install qemu") + } + t.Logf("QEMU binary: %s", qemuBin) + + windowsISO := os.Getenv("DEVCELL_QEMU_WINDOWS_ISO") + if windowsISO == "" { + t.Skip("DEVCELL_QEMU_WINDOWS_ISO not set — download from https://www.microsoft.com/en-us/software-download/windows11arm64") + } + if _, err := os.Stat(windowsISO); err != nil { + t.Fatalf("Windows ISO not found at %s: %v", windowsISO, err) + } + t.Logf("Windows ISO: %s", windowsISO) + + // Set up isolated HOME + home := t.TempDir() + cfgDir := filepath.Join(home, ".config", "devcell") + if err := os.MkdirAll(cfgDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cfgDir, "devcell.toml"), []byte("[cell]\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(home, ".devcell.toml"), []byte("[cell]\nstack = \"base\"\n"), 0644); err != nil { + t.Fatal(err) + } + t.Logf("Test HOME: %s", home) + + cellName := "test-qemu-e2e" + baseEnv := append(os.Environ(), + "DEVCELL_BUNK=1", + "HOME="+home, + "DEVCELL_CELL_NAME="+cellName, + "DEVCELL_QEMU_WINDOWS_ISO="+windowsISO, + ) + + // --- Phase 1: Init --- + t.Run("Init", func(t *testing.T) { + cmd := exec.Command(binaryPath, "--engine=qemu", "--debug", "init", "--stack=base") + cmd.Dir = home + cmd.Env = baseEnv + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + t.Fatalf("cell init --engine=qemu failed: %v", err) + } + + // Verify SSH keys were created + sshDir := filepath.Join(home, ".devcell", cellName, "qemu") + for _, f := range []string{"id_ed25519", "id_ed25519.pub", "authorized_keys"} { + path := filepath.Join(sshDir, f) + if _, err := os.Stat(path); err != nil { + t.Errorf("expected SSH file %s to exist: %v", f, err) + } + } + + // Verify VirtIO drivers were downloaded + virtioPath := filepath.Join(home, ".devcell", "cache", "qemu", "virtio-win.iso") + if info, err := os.Stat(virtioPath); err != nil { + t.Errorf("VirtIO ISO not found at %s: %v", virtioPath, err) + } else { + t.Logf("VirtIO ISO: %s (%.0f MB)", virtioPath, float64(info.Size())/(1024*1024)) + } + + donePath := virtioPath + ".done" + if _, err := os.Stat(donePath); err != nil { + t.Errorf("VirtIO .done marker not found: %v", err) + } + + // Verify directories were created + templateDir := filepath.Join(home, ".devcell", "windows", "base") + if _, err := os.Stat(templateDir); err != nil { + t.Errorf("template dir not created: %v", err) + } + + instanceDir := filepath.Join(home, ".devcell", cellName, "windows") + if _, err := os.Stat(instanceDir); err != nil { + t.Errorf("instance dir not created: %v", err) + } + }) + + // --- Phase 2: Build --- + t.Run("Build", func(t *testing.T) { + cmd := exec.Command(binaryPath, "--engine=qemu", "--debug", "build", "--stack=base") + cmd.Dir = home + cmd.Env = baseEnv + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + t.Fatalf("cell build --engine=qemu failed: %v", err) + } + + // Verify template disk was created + templateDisk := filepath.Join(home, ".devcell", "windows", "base", "disk-base.qcow2") + if info, err := os.Stat(templateDisk); err != nil { + t.Errorf("template disk not found: %v", err) + } else { + t.Logf("Template disk: %s (%.1f GB)", templateDisk, float64(info.Size())/(1024*1024*1024)) + } + + // Verify UEFI vars file + varsPath := filepath.Join(home, ".devcell", "windows", "base", "vars.fd") + if _, err := os.Stat(varsPath); err != nil { + t.Errorf("UEFI vars file not found: %v", err) + } + + // Verify provisioned marker + marker := filepath.Join(home, ".devcell", "windows", "base", ".provisioned") + if _, err := os.Stat(marker); err != nil { + t.Errorf("provisioned marker not found: %v", err) + } + }) + + // --- Phase 3: Shell (dry-run — verifies clone + SSH command) --- + t.Run("ShellDryRun", func(t *testing.T) { + cmd := exec.Command(binaryPath, "--engine=qemu", "--local", "shell", "--dry-run") + cmd.Dir = home + cmd.Env = baseEnv + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("cell shell --engine=qemu --dry-run failed: %v\noutput: %s", err, out) + } + + s := string(out) + // Should print SSH command + if !strings.Contains(s, "ssh") { + t.Errorf("expected 'ssh' in shell dry-run output, got:\n%s", s) + } + if !strings.Contains(s, "powershell") { + t.Errorf("expected 'powershell' in shell dry-run output, got:\n%s", s) + } + // DEVCELL_BUNK=1 → bunk-based SSH port (10122) + if !strings.Contains(s, "-p 10122") { + t.Errorf("expected '-p 10122' (bunk-based SSH port) in shell dry-run output, got:\n%s", s) + } + t.Logf("Shell dry-run output:\n%s", s) + }) +} + +// TestQemuE2E_InitOnly exercises just the init phase — useful for quick macOS +// validation without the 45-minute build. Downloads VirtIO drivers (~500MB) +// and generates SSH keys. +// +// Run: go test ./cmd -run TestQemuE2E_InitOnly -count=1 -timeout=10m +func TestQemuE2E_InitOnly(t *testing.T) { + if runtime.GOOS != "darwin" || runtime.GOARCH != "arm64" { + t.Skip("QEMU E2E requires macOS on Apple Silicon (darwin/arm64)") + } + if testing.Short() { + t.Skip("long: downloads VirtIO drivers (~500MB)") + } + if _, err := exec.LookPath("qemu-system-aarch64"); err != nil { + t.Skip("qemu-system-aarch64 not found — install with: brew install qemu") + } + + home := t.TempDir() + cfgDir := filepath.Join(home, ".config", "devcell") + os.MkdirAll(cfgDir, 0755) + os.WriteFile(filepath.Join(cfgDir, "devcell.toml"), []byte("[cell]\n"), 0644) + os.WriteFile(filepath.Join(home, ".devcell.toml"), []byte("[cell]\n"), 0644) + + cellName := "test-qemu-init" + cmd := exec.Command(binaryPath, "--engine=qemu", "--debug", "init", "--stack=base") + cmd.Dir = home + cmd.Env = append(os.Environ(), + "DEVCELL_BUNK=1", + "HOME="+home, + "DEVCELL_CELL_NAME="+cellName, + ) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + t.Fatalf("cell init --engine=qemu failed: %v", err) + } + + // Verify SSH keypair + sshDir := filepath.Join(home, ".devcell", cellName, "qemu") + privKey := filepath.Join(sshDir, "id_ed25519") + pubKey := filepath.Join(sshDir, "id_ed25519.pub") + authKeys := filepath.Join(sshDir, "authorized_keys") + + for _, path := range []string{privKey, pubKey, authKeys} { + if _, err := os.Stat(path); err != nil { + t.Errorf("expected %s to exist: %v", filepath.Base(path), err) + } + } + + // Verify key content looks like ed25519 + pubKeyData, err := os.ReadFile(pubKey) + if err != nil { + t.Fatalf("reading pub key: %v", err) + } + if !strings.HasPrefix(string(pubKeyData), "ssh-ed25519 ") { + t.Errorf("pub key should start with 'ssh-ed25519', got: %s", string(pubKeyData)[:40]) + } + + // Verify VirtIO ISO downloaded + virtioPath := filepath.Join(home, ".devcell", "cache", "qemu", "virtio-win.iso") + info, err := os.Stat(virtioPath) + if err != nil { + t.Fatalf("VirtIO ISO not found: %v", err) + } + if info.Size() < 100*1024*1024 { + t.Errorf("VirtIO ISO suspiciously small: %d bytes", info.Size()) + } + t.Logf("VirtIO ISO downloaded: %.0f MB", float64(info.Size())/(1024*1024)) + + // Verify .done marker + if _, err := os.Stat(virtioPath + ".done"); err != nil { + t.Errorf(".done marker not found: %v", err) + } + + // Verify idempotency — second run should be fast (cache hit) + cmd2 := exec.Command(binaryPath, "--engine=qemu", "--debug", "init", "--stack=base") + cmd2.Dir = home + cmd2.Env = append(os.Environ(), + "DEVCELL_BUNK=1", + "HOME="+home, + "DEVCELL_CELL_NAME="+cellName, + ) + out, err := cmd2.CombinedOutput() + if err != nil { + t.Fatalf("second init failed: %v\noutput: %s", err, out) + } + if !strings.Contains(string(out), "cache hit") { + t.Logf("second init output (expected cache hit):\n%s", out) + } +} + +func TestEngineQemu_BuildUseBunkPorts(t *testing.T) { + if runtime.GOOS != "darwin" || runtime.GOARCH != "arm64" { + t.Skip("build --engine=qemu requires darwin/arm64") + } + home := qemuTestHome(t) + cmd := exec.Command(binaryPath, "--engine=qemu", "--debug", "build", "--stack=base") + cmd.Dir = home + cmd.Env = append(os.Environ(), "DEVCELL_BUNK=3", "HOME="+home) + out, err := cmd.CombinedOutput() + s := string(out) + _ = err + // DEVCELL_BUNK=3, no SESSION_PORT_PREFIX → prefix "3" → SSH=ClampPort("322")=322 → hoisted to 10322 + if !strings.Contains(s, "10322") { + t.Errorf("expected bunk-based SSH port 10322 in build debug output, got:\n%s", s) + } + if strings.Contains(s, "2222") { + t.Errorf("build should not use hardcoded SSH port 2222, got:\n%s", s) + } +} + +// TestQemuE2E_BuildDryRun verifies that --dry-run prints what would be built +// without actually starting QEMU. +func TestQemuE2E_BuildDryRun(t *testing.T) { + if runtime.GOOS != "darwin" || runtime.GOARCH != "arm64" { + t.Skip("QEMU build requires macOS on Apple Silicon (darwin/arm64)") + } + + home := qemuTestHome(t) + cmd := exec.Command(binaryPath, "--engine=qemu", "build", "--dry-run", "--stack=base") + cmd.Dir = home + cmd.Env = append(os.Environ(), "DEVCELL_BUNK=1", "HOME="+home) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("expected exit 0, got: %v\noutput: %s", err, out) + } + s := string(out) + if !strings.Contains(s, "Would build") { + t.Errorf("expected 'Would build' in --dry-run output, got:\n%s", s) + } + if !strings.Contains(s, "base") { + t.Errorf("expected 'base' stack in --dry-run output, got:\n%s", s) + } +} diff --git a/cmd/rdp.go b/cmd/rdp.go index de965f6..bb1ed82 100644 --- a/cmd/rdp.go +++ b/cmd/rdp.go @@ -7,12 +7,15 @@ import ( "os/exec" "path/filepath" "runtime" + "strconv" "strings" "github.com/DimmKirr/devcell/internal/config" internalrdp "github.com/DimmKirr/devcell/internal/rdp" "github.com/DimmKirr/devcell/internal/runner" + "github.com/DimmKirr/devcell/internal/telemetry" "github.com/DimmKirr/devcell/internal/ux" + "github.com/DimmKirr/devcell/internal/vm/qemu" "github.com/spf13/cobra" ) @@ -45,6 +48,8 @@ func runRDP(cmd *cobra.Command, args []string) error { rdpFullscreen, _ = cmd.Flags().GetBool("fullscreen") rdpViewer, _ = cmd.Flags().GetString("viewer") + telemetry.Track("rdp", map[string]any{"viewer": rdpViewer, "list": list, "global": rdpGlobal, "fullscreen": rdpFullscreen}) + if list { return rdpList() } @@ -212,6 +217,17 @@ func collectRDPCells(c config.Config, global bool) map[string]string { } } + // QEMU VMs (always global — one per cell, not per project) + rdpDebug("qemu: scanning for running VMs") + for _, vm := range qemu.DiscoverRunningVMs(c.HostHome) { + if vm.Ports.RDPPort > 0 { + appName := "qemu-" + vm.CellName + port := strconv.Itoa(int(vm.Ports.RDPPort)) + rdpDebug("qemu cell found: %s → %s", appName, port) + result[appName] = port + } + } + rdpDebug("collectRDPCells result: %v", result) return result } diff --git a/cmd/root.go b/cmd/root.go index 4b4c0bb..ad4eaa9 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -13,6 +13,8 @@ import ( "syscall" "time" + "github.com/mattn/go-isatty" + "github.com/DimmKirr/devcell/internal/backup" "github.com/DimmKirr/devcell/internal/cfg" "github.com/DimmKirr/devcell/internal/config" @@ -20,8 +22,10 @@ import ( "github.com/DimmKirr/devcell/internal/runner" "github.com/DimmKirr/devcell/internal/scaffold" "github.com/DimmKirr/devcell/internal/session" + "github.com/DimmKirr/devcell/internal/telemetry" "github.com/DimmKirr/devcell/internal/ux" "github.com/DimmKirr/devcell/internal/version" + "github.com/DimmKirr/devcell/internal/vm/libvirt" "github.com/spf13/cobra" ) @@ -38,11 +42,8 @@ tools inside a consistent Docker dev environment.`, fmt.Fprintf(os.Stderr, "cell %s\n", version.Full()) } // Set runner globals BEFORE any subcommand RunE so that - // runner.UserImageTag() / UserImageTagPure() / PickImageTag() reflect - // the project's stack from .devcell.toml. Without this, `cell build` - // (which fires buildCmd.RunE, NOT rootCmd.RunE) leaves Stack="" and - // tags every image as devcell-user:base-pure regardless of what - // stack the nix derivation actually built. + // runner.UserImageTag() / PickImageTag() reflect the project's + // stack from .devcell.toml. // // Best-effort: silently skips when config can't be loaded (e.g., // `cell --help` before cwd has a .devcell.toml, or stray cwd). @@ -59,12 +60,75 @@ tools inside a consistent Docker dev environment.`, if len(args) > 0 { return fmt.Errorf("unknown command %q — run 'cell --help' for usage", args[0]) } + // A valid default_command never reaches here — applyDefaultCommand + // rewrites os.Args before Execute, so cobra dispatches to the + // subcommand directly (with user args forwarded). Only an invalid + // value falls through; surface the validation error. + if c, err := config.LoadFromOS(); err == nil { + cellCfg := cfg.LoadFromOS(c.ConfigDir, c.BaseDir) + if dc := cellCfg.Cell.ResolvedDefaultCommand(); dc != "" { + if err := cfg.ValidateDefaultCommand(dc); err != nil { + return err + } + } + } return cmd.Help() }, } +// rewriteDefaultCommand injects the configured default command in front of +// the user's args (os.Args[1:]) so flags and positionals reach the inner +// binary: `cell -c` becomes `cell claude -c`. This must happen BEFORE +// rootCmd.Execute() — the root command parses flags, so an agent flag like +// -c would die there as "unknown shorthand flag" and never reach the +// default-command dispatch in RunE. An explicit subcommand, help/version, +// or completion invocation is left untouched. +func rewriteDefaultCommand(args []string, defaultCmd string, knownCmds map[string]bool) []string { + if defaultCmd == "" { + return args + } + if len(args) > 0 { + first := args[0] + if knownCmds[first] { + return args + } + switch first { + case "--help", "-h", "--version", "help", "completion", "__complete", "__completeNoDesc": + return args + } + } + return append([]string{defaultCmd}, args...) +} + +// applyDefaultCommand resolves default_command from config and rewrites +// os.Args in place. Invalid values are left for rootCmd.RunE to report. +func applyDefaultCommand() { + c, err := config.LoadFromOS() + if err != nil { + return + } + cellCfg := cfg.LoadFromOS(c.ConfigDir, c.BaseDir) + dc := cellCfg.Cell.ResolvedDefaultCommand() + if dc == "" || cfg.ValidateDefaultCommand(dc) != nil { + return + } + known := make(map[string]bool) + for _, sub := range rootCmd.Commands() { + known[sub.Name()] = true + for _, a := range sub.Aliases { + known[a] = true + } + } + rewritten := rewriteDefaultCommand(os.Args[1:], dc, known) + os.Args = append([]string{os.Args[0]}, rewritten...) + osArgs = os.Args // keep scanFlag/scanStringFlag on the rewritten argv +} + func Execute() { defer ux.CloseDebugLog() + telemetry.Init(resolveConfigDir()) + defer telemetry.Close() + applyDefaultCommand() if err := rootCmd.Execute(); err != nil { fmt.Fprintf(os.Stderr, "\n cell %s\n", version.Full()) baseVer, userVer := runner.ImageVersions(context.Background()) @@ -85,13 +149,18 @@ func init() { rootCmd.PersistentFlags().Bool("plain-text", false, "disable spinners, use plain log output (for CI/non-TTY)") rootCmd.PersistentFlags().Bool("debug", false, "plain-text mode plus stream full build log to stdout") rootCmd.PersistentFlags().String("format", "text", "output format: text, yaml, or json") - rootCmd.PersistentFlags().String("engine", "docker", "execution engine: docker, vagrant, or tart") + rootCmd.PersistentFlags().String("engine", "docker", "execution engine: docker, vagrant, tart, qemu, or libvirt") + rootCmd.PersistentFlags().Bool("local", false, "pin --engine=qemu to the in-container path (skip the libvirt auto-default)") rootCmd.PersistentFlags().Bool("background", false, "keep VM/container running after shell exit") rootCmd.PersistentFlags().Bool("macos", false, "use macOS VM via Vagrant (alias for --engine=vagrant)") rootCmd.PersistentFlags().String("vagrant-provider", "utm", "Vagrant provider (e.g. utm)") rootCmd.PersistentFlags().String("vagrant-box", "", "Vagrant box name override") rootCmd.PersistentFlags().String("tart-ssh-port", "", "SSH port for tart engine (default: 22)") rootCmd.PersistentFlags().String("tart-ssh-host", "", "SSH host for tart engine (default: localhost)") + rootCmd.PersistentFlags().String("qemu-ssh-port", "", "SSH port for QEMU engine (default: 2222)") + rootCmd.PersistentFlags().String("qemu-ssh-host", "", "SSH host for QEMU engine (default: 127.0.0.1)") + rootCmd.PersistentFlags().String("qemu-windows-iso", "", "path to Windows ARM64 ISO for QEMU engine") + rootCmd.PersistentFlags().String("qemu-display", "", "QEMU display: none, cocoa, sdl (default: none)") rootCmd.PersistentFlags().String("base-image", "", "core image for scaffold Dockerfile (default: ghcr.io/devcell-sh/devcell:core-local)") rootCmd.PersistentFlags().String("cell-name", "", "cell name for persistent home (~/.devcell/)") rootCmd.AddCommand( @@ -100,6 +169,8 @@ func init() { opencodeCmd, geminiCmd, shellCmd, + startCmd, + stopCmd, buildCmd, initCmd, vncCmd, @@ -108,6 +179,7 @@ func init() { modulesCmd, serveCmd, authCmd, + telemetryCmd, ) } @@ -148,21 +220,22 @@ func applyOutputFlagsWithLog(commandName string) { // cellBoolFlags are boolean flags consumed by devcell: strip the flag token only. var cellBoolFlags = map[string]bool{ - "--build": true, - "--background": true, - "--dry-run": true, - "--plain-text": true, - "--debug": true, - "--macos": true, - "--ollama": true, - "--impure": true, // legacy Dockerfile path (CELL-165 canonical name) - "--debian": true, // deprecated alias for --impure (kept stripping for one release) - "--pure": true, // silent no-op after flip; kept stripped from forwarded args - "--nix-daemon": true, // enable nix-daemon inside container for runtime package installs - "--thin": true, // thin image mode — nix store on Docker volume (CELL-156) - "--no-thin": true, // disable thin mode (thick image) - "--thick": true, // alias for --no-thin + "--build": true, + "--background": true, + "--dry-run": true, + "--plain-text": true, + "--debug": true, + "--macos": true, + "--ollama": true, + "--openrouter": true, + "--nix-daemon": true, // enable nix-daemon inside container for runtime package installs + "--thin": true, // thin image mode (default) + "--no-thin": true, // legacy, ignored + "--thick": true, // legacy, ignored "--no-1password": true, // skip [op] documents resolution at cell-open (CELL-42) + "--local": true, // pin --engine=qemu to the in-container path (CELL-378) + "--auto-cleanup": true, // run the CELL-334 root reaper at cell start (CELL-390) + "--skip-flake": true, // skip project-level flake.nix install (CELL-447) } // cellStringFlags are string flags consumed by devcell: strip the flag token @@ -173,6 +246,10 @@ var cellStringFlags = map[string]bool{ "--vagrant-box": true, "--tart-ssh-port": true, "--tart-ssh-host": true, + "--qemu-ssh-port": true, + "--qemu-ssh-host": true, + "--qemu-windows-iso": true, + "--qemu-display": true, "--base-image": true, "--cell-name": true, "--format": true, @@ -232,10 +309,7 @@ func runAgent(binary string, defaultFlags, userArgs []string, extraEnv map[strin os.Setenv("DEVCELL_CELL_NAME", sn) } - // First-run: scaffold .devcell.toml + .devcell/ files. Image acquisition - // is owned by the unified pure-path orchestrator below — scaffolding - // must not eagerly invoke a docker build that the next step won't even - // use (the orchestrator's first try is a registry pull of the pure tag). + // First-run: scaffold .devcell.toml + .devcell/ files. if !scaffold.IsInitialized(c.BaseDir) { globalCfg := cfg.LoadFromOS(c.ConfigDir, c.BaseDir) result, err := RunInitFlow(InitFlowOptions{ @@ -262,6 +336,7 @@ func runAgent(binary string, defaultFlags, userArgs []string, extraEnv map[strin engine = "vagrant" } if engine == "vagrant" { + telemetry.Track("command_run", map[string]any{"command": filepath.Base(binary), "engine": "vagrant"}) vagrantBox := scanStringFlag("--vagrant-box") if vagrantBox == "" { vagrantBox = cellCfgForEngine.Cell.VagrantBox @@ -288,6 +363,7 @@ func runAgent(binary string, defaultFlags, userArgs []string, extraEnv map[strin ) } if engine == "tart" { + telemetry.Track("command_run", map[string]any{"command": filepath.Base(binary), "engine": "tart"}) return runTartAgent( binary, defaultFlags, userArgs, cellCfgForEngine, @@ -297,6 +373,35 @@ func runAgent(binary string, defaultFlags, userArgs []string, extraEnv map[strin scanFlag("--debug"), ) } + // qemu→libvirt auto-default (CELL-378): in a Docker cell on a Mac, + // local qemu can only mean TCG; the host's HVF behind libvirtd is the + // only fast path. Explicit intent wins: --local pins local qemu. + if ok, reason := libvirt.ShouldDefaultToLibvirt(engine, scanFlag("--local"), libvirt.DefaultProbes()); ok { + fmt.Printf(" engine: qemu → libvirt (%s)\n", reason) + engine = "libvirt" + } + if engine == "qemu" { + telemetry.Track("command_run", map[string]any{"command": filepath.Base(binary), "engine": "qemu"}) + return runQemuAgent( + binary, defaultFlags, userArgs, + cellCfgForEngine, + c.BaseDir, c.HostHome, c.CellName, + scanFlag("--dry-run"), + scanFlag("--background"), + scanFlag("--debug"), + ) + } + if engine == "libvirt" { + telemetry.Track("command_run", map[string]any{"command": filepath.Base(binary), "engine": "libvirt"}) + return runLibvirtAgent( + binary, defaultFlags, userArgs, + cellCfgForEngine, + c.BaseDir, c.HostHome, c.CellName, + scanFlag("--dry-run"), + scanFlag("--background"), + scanFlag("--debug"), + ) + } cellCfg := cfg.LoadFromOS(c.ConfigDir, c.BaseDir) @@ -313,46 +418,27 @@ func runAgent(binary string, defaultFlags, userArgs []string, extraEnv map[strin runner.Modules = cellCfg.Cell.Modules runner.PerCellImage = cellCfg.Cell.ResolvedPerCellImage() - // After the 2026-05-15 flip (CELL-183), pure is the default for every - // agent (claude, shell, codex, gemini). `--impure` (CELL-165 canonical; - // `--debian` is a deprecated alias) opts into the legacy Dockerfile - // build path. `--pure` is kept as a silent no-op (same as default). - impure := scanFlag("--impure") || scanFlag("--debian") - thin := !scanFlag("--no-thin") && !scanFlag("--thick") && (scanFlag("--thin") || cellCfg.Cell.ResolvedThin()) - if !thin { - runner.WarnThickDeprecation() - } + thin := true + telemetry.TrackCommandRun(filepath.Base(binary), "docker", runner.Stack, runner.Modules, thin) imageTag := func() string { - if thin { - return runner.PickImageTagThin() - } - return runner.PickImageTag(impure) + return runner.PickImageTagThin() } dryRun := scanFlag("--dry-run") explicitBuild := scanFlag("--build") // Resolve available GUI ports — probe and bump if already bound - if cellCfg.Cell.ResolvedGUI() { + if cellCfg.GUI.ResolvedEnabled() { c.ResolveAvailablePorts() } // ── Image acquisition ──────────────────────────────────────────────────── - // Default (pure): runner.AcquireImage walks the fallback chain — - // local → pull-pure → pull-impure → build (pure if host nix, otherwise - // impure docker build). Each closure performs its action; on the last - // action's failure the user sees a joined chain error. - // - // --impure (legacy CLI flag): autoDetect (missing image) + staleness check. - // Staleness is not consulted for the pure path: pure images are - // content-addressed, so a local tag equals what a rebuild would produce - // from the same flake.lock. - // // Daemon preflight: surface a single actionable error if docker is down // before any pull/build attempt (CELL-44). Skip in dry-run. if !dryRun { if err := runner.DockerDaemonReachable(context.Background()); err != nil { return err } + logDockerDiagnostics(context.Background(), c) } // ── Thin image path (CELL-156) ────────────────────────────────────────── if thin { @@ -378,75 +464,6 @@ func runAgent(binary string, defaultFlags, userArgs []string, extraEnv map[strin return err } } - } else if !impure { - // HasNix means "nix is on PATH AND can build the target arch from - // this host" (the preflight catches macOS-without-linux-builder). - // When false the orchestrator skips ActionBuildPure and runs - // ActionBuildImpure instead — docker build still works without nix - // on the host because nix runs inside the build. - _, nixErr := exec.LookPath("nix") - hasNix := nixErr == nil && runner.PreflightNixBuilder(runner.Stack) == nil - - err := runner.AcquireImage(context.Background(), runner.AcquireDeps{ - Inputs: runner.LaunchInputs{ - DryRun: dryRun, - ExplicitBuild: explicitBuild, - LocalExists: runner.ImageExists(context.Background(), imageTag()), - HasNix: hasNix, - }, - PullPure: pullWithSpinner( - runner.StackImageTagPure(runner.Stack), runner.PullAndTagPure), - PullImpure: pullWithSpinner( - runner.StackImageTagImpure(runner.Stack), runner.PullAndTagImpure), - BuildPure: func(context.Context) error { - // Passing "" means runBuildPure falls back to the TOML-resolved - // stack (see CELL-93). The user overrides via `cell build - // --stack ` explicitly. - return runBuildPure(c, "") - }, - BuildImpure: func(ctx context.Context) error { - return runFallbackImpureBuild(ctx, c, cellCfg) - }, - }) - if err != nil { - return err - } - } else { - needsBuild := explicitBuild && !dryRun - autoDetect := !dryRun && !explicitBuild && - !runner.ImageExists(context.Background(), imageTag()) - var changedFiles []string - staleImage := false - if !dryRun && !explicitBuild && !autoDetect { - changedFiles, staleImage = runner.ChangedBuildFiles(c.BuildDir) - } - if needsBuild || autoDetect || staleImage { - if autoDetect { - fmt.Printf(" No %s image found — building automatically\n", imageTag()) - } else if staleImage { - fmt.Printf(" Build context changed (%s in %s) — rebuilding %s\n", - strings.Join(changedFiles, ", "), c.BuildDir, imageTag()) - if ux.Verbose { - for _, f := range changedFiles { - if diff := runner.DiffBuildFile(c.BuildDir, f); diff != "" { - fmt.Printf("\n%s\n", diff) - } - } - } - } - if err := config.EnsureBuildDir(c.BuildDir); err != nil { - return fmt.Errorf("ensure build dir: %w", err) - } - if err := syncNixhomeWithConfirmation(c, cellCfg); err != nil { - return err - } - if err := scaffold.RegenerateBuildContext(c.BuildDir, cellCfg); err != nil { - return fmt.Errorf("regenerate build context: %w", err) - } - if err := buildImageWithSpinner(c.BuildDir, needsBuild, "Building devcell image", false); err != nil { - return err - } - } } // Cell-open banner — CELL-48. Always print the compact header so users @@ -536,6 +553,23 @@ func runAgent(binary string, defaultFlags, userArgs []string, extraEnv map[strin return err } + // CELL-390: read-only nix-store health report (thin mode only). + // Non-fatal; mutation only behind the explicit --auto-cleanup opt-in. + // CELL-391: may nudge when this cell's lock is behind the volume's + // newest — the only error path is the user explicitly answering "n". + if err := nixStorePhase(ctx, pr, thin, c.BaseDir, cellCfg.Cell.StaleWarningEnabled()); err != nil { + return err + } + + // CELL-418: check that the thin image's baked-in nix closure is still + // alive on the shared volume. A dead closure means GC reaped the store + // paths — prompt for rebuild (auto-rebuild in non-TTY). + if err := closureCheckPhase(ctx, pr, thin, imageTag(), func() error { + return runBuildThin(c, "", "", false) + }); err != nil { + return err + } + _ = pr.Phase("Backup", func() error { return backup.Backup(c.CellHome, time.Now()) }) // Pin the container to the exact image ID so a concurrent `cell build` @@ -556,25 +590,64 @@ func runAgent(binary string, defaultFlags, userArgs []string, extraEnv map[strin } return short, nil }) + if ux.Verbose && !dryRun { + source := runner.DockerHostPath(c.BaseDir) + probeVolume := "" + if thin { + probeVolume = runner.ThinStoreVolume() + } + out, probeErr := runner.ProbeDockerBind( + ctx, imageID, probeVolume, source, ".devcell.toml") + if probeErr != nil { + ux.Debugf("docker bind probe: FAILED source=%q marker=.devcell.toml: %v output=%q", + source, probeErr, out) + } else { + ux.Debugf("docker bind probe: OK source=%q %s", source, out) + } + } - // Inject system prompt for Claude Code — container context (mounts, host - // paths, constraints) plus the operator/project prompt resolved from env - // vars and devcell.toml. See runner.AssembleSystemPrompt for the full - // source-precedence chain. Fatal: a bad system prompt produces a broken + // Inject prompts for Claude Code as generated files. The overlay carries + // container context (mounts, host paths, constraints) plus the append + // prompt; the base, when configured, replaces Claude Code's built-in + // prompt entirely. See runner.ResolveSystemPrompt / ResolveAppendPrompt + // for the source-precedence chains. Fatal: a bad prompt produces a broken // claude session, fail loudly here. if binary == "claude" { if err := pr.PhaseDetailed("System prompt", func() (string, error) { - prompt, spErr := runner.AssembleSystemPrompt(c, cellCfg, runner.ResolveOpts{ - EnvFile: os.Getenv("DEVCELL_SYSTEM_PROMPT_FILE"), - EnvInline: os.Getenv("DEVCELL_SYSTEM_PROMPT"), - CellCfg: cellCfg, - CfgBaseDir: c.BaseDir, + flags, spErr := claudePromptFlags(c, cellCfg, runner.ResolveOpts{ + EnvFile: os.Getenv("DEVCELL_SYSTEM_PROMPT_FILE"), + EnvInline: os.Getenv("DEVCELL_SYSTEM_PROMPT"), + AppendEnvFile: os.Getenv("DEVCELL_APPEND_SYSTEM_PROMPT_FILE"), + AppendEnvInline: os.Getenv("DEVCELL_APPEND_SYSTEM_PROMPT"), + CellCfg: cellCfg, + CfgBaseDir: c.BaseDir, }) if spErr != nil { return "", spErr } - defaultFlags = append(defaultFlags, "--append-system-prompt", prompt) - return fmt.Sprintf("%d bytes", len(prompt)), nil + defaultFlags = append(defaultFlags, flags...) + return flags[len(flags)-1], nil + }); err != nil { + return fmt.Errorf("system prompt: %w", err) + } + } + + if binary == "codex" { + if err := pr.PhaseDetailed("System prompt", func() (string, error) { + flags, spErr := codexPromptFlags(c, cellCfg, runner.ResolveOpts{ + AppendEnvFile: os.Getenv("DEVCELL_APPEND_SYSTEM_PROMPT_FILE"), + AppendEnvInline: os.Getenv("DEVCELL_APPEND_SYSTEM_PROMPT"), + CellCfg: cellCfg, + CfgBaseDir: c.BaseDir, + }) + if spErr != nil { + return "", spErr + } + defaultFlags = append(defaultFlags, flags...) + if cellCfg.LLM.SystemPrompt != "" || cellCfg.LLM.SystemPromptFile != "" { + ux.Warn("[llm].system_prompt is set but Codex has no way to replace its built-in prompt. This setting is ignored for cell codex. Only [llm].append_system_prompt is wired.") + } + return "developer_instructions", nil }); err != nil { return fmt.Errorf("system prompt: %w", err) } @@ -659,6 +732,44 @@ func runAgent(binary string, defaultFlags, userArgs []string, extraEnv map[strin ux.Debugf("1Password: skipped (--no-1password / DEVCELL_NO_1PASSWORD)") } + // Resolve deferred API keys that depend on 1Password secrets. + if extraEnv != nil { + if extraEnv["ANTHROPIC_BASE_URL"] == openRouterAnthropicBaseURL { + if err := ResolveOpenRouterKey(extraEnv); err != nil { + return err + } + } else if v, ok := extraEnv["OPENROUTER_API_KEY"]; ok && v == "" { + // codex/opencode set an empty placeholder to request the key. + if err := FillOpenRouterKey(extraEnv); err != nil { + return err + } + } + } + + // Inject a deterministic session ID so agents resume the same + // conversation when relaunched in the same tmux pane. + // Claude Code: CLAUDE_CODE_SESSION_ID env var names a new/existing session. + // OpenCode: --session requires an existing ID (no create-or-resume), so + // we skip it. OpenCode's --continue resumes the last session in the + // project directory, which the user can invoke manually. + if binary == "claude" { + sessID := sessionUUID(c.AppName) + if extraEnv == nil { + extraEnv = make(map[string]string) + } + extraEnv["CLAUDE_CODE_SESSION_ID"] = sessID + } + + // Validate and prepare WireGuard configs before docker run. + if cfg.WireguardEnabled(cellCfg) { + if err := cfg.ValidateWireguard(cellCfg); err != nil { + return fmt.Errorf("wireguard config: %w", err) + } + if err := runner.PrepareWireguard(c.CellHome, cellCfg); err != nil { + return fmt.Errorf("wireguard prepare: %w", err) + } + } + // Final ✓ row before docker exec takes the TTY. The phase checklist // stays on screen — the child TUI (claude, codex, …) draws on the row // immediately below `✓ Cell ready`, so users keep the full boot story @@ -692,6 +803,13 @@ func runAgent(binary string, defaultFlags, userArgs []string, extraEnv map[strin defer bootWatcher.Close() } + // CELL-447: detect project flake.nix and prompt for trust host-side. + skipFlake := scanFlag("--skip-flake") + trustFlake := false + if !skipFlake { + trustFlake = resolveTrustFlake(c.BaseDir, c.CellHome) + } + spec := runner.RunSpec{ Config: c, CellCfg: cellCfg, @@ -700,11 +818,15 @@ func runAgent(binary string, defaultFlags, userArgs []string, extraEnv map[strin UserArgs: userArgs, Debug: ux.Verbose, NixDaemon: scanFlag("--nix-daemon"), + SkipFlake: skipFlake, + TrustFlake: trustFlake, Image: imageID, ExtraEnv: extraEnv, InheritEnv: inheritEnv, ThinImage: thin, BootDir: bootDirEnv, + TTY: isatty.IsTerminal(os.Stdin.Fd()), + Detach: startDetach, } argv := runner.BuildArgv(spec, runner.OsFS, exec.LookPath) @@ -714,6 +836,18 @@ func runAgent(binary string, defaultFlags, userArgs []string, extraEnv map[strin } cmd := exec.Command(argv[0], argv[1:]...) + + if startDetach { + // Detached: docker run -d prints container ID and exits. + // Suppress stdout (container ID) and only show errors. + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("start container: %w", err) + } + fmt.Printf("Container %s started\n", c.ContainerName) + return nil + } + cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr @@ -722,6 +856,7 @@ func runAgent(binary string, defaultFlags, userArgs []string, extraEnv map[strin if sessErr != nil { ux.Debugf("session begin: %v", sessErr) } + startTime := time.Now() if err := cmd.Start(); err != nil { if sess != nil { @@ -749,6 +884,7 @@ func runAgent(binary string, defaultFlags, userArgs []string, extraEnv map[strin }() waitErr := cmd.Wait() + telemetry.TrackCommandFinish(filepath.Base(binary), time.Since(startTime).Milliseconds(), waitErr == nil) if sess != nil { if err := sess.Finish(c.BaseDir, waitErr); err != nil { ux.Debugf("session finish: %v", err) @@ -792,125 +928,46 @@ func scanStringFlag(flag string) string { return "" } -// buildImageWithSpinner runs docker build with a spinner. -// In verbose mode (--debug), build output streams to stdout. -// In quiet mode, output is captured and replayed to stderr only on failure. -// If silent is true, the spinner is cleared on success (no lingering output). -func buildImageWithSpinner(configDir string, noCache bool, label string, silent bool) error { - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - - var buf bytes.Buffer - var out io.Writer = &buf - if ux.Verbose { - out = os.Stdout +// resolveTrustFlake checks if the project has a flake.nix and whether the +// user has trusted it. On first encounter, prompts interactively and caches +// the answer in cellHome. Returns true if DEVCELL_FLAKE_TRUST=1 should be +// passed to the container. +func resolveTrustFlake(baseDir, cellHome string) bool { + flakePath := filepath.Join(baseDir, "flake.nix") + if _, err := os.Stat(flakePath); err != nil { + return false } - sp := ux.NewProgressSpinner(label) - if err := runner.BuildImage(ctx, configDir, noCache, ux.Verbose, out); err != nil { - sp.Fail(label + " failed") - if !ux.Verbose { - if hint := ux.ClassifyBuildOutput(buf.String()); hint != nil { - ux.PrintBuildErrorHint(hint) - } else if buf.Len() > 0 { - fmt.Fprint(os.Stderr, buf.String()) - } - } - return err - } - if silent { - sp.Stop() - } else { - sp.Success(label) - } - return nil -} -// pullWithSpinner returns an AcquireDeps closure that calls pullFn with the -// active stack, wrapping it in a spinner for non-verbose mode. Used to build -// both the pure-pull and impure-pull dependencies from a single shape. -func pullWithSpinner( - remoteTag string, - pullFn func(context.Context, string, bool) error, -) func(context.Context) error { - return func(ctx context.Context) error { - label := fmt.Sprintf("Pulling %s", remoteTag) - var sp *ux.ProgressSpinner - if !ux.Verbose { - sp = ux.NewProgressSpinner(label) - } else { - ux.Debugf("%s", label) - } - if err := pullFn(ctx, runner.Stack, ux.Verbose); err != nil { - if sp != nil { - sp.Stop() - } - ux.Debugf("pull %s failed: %v", remoteTag, err) - return err - } - if sp != nil { - sp.Success("Pulled " + remoteTag) - } - return nil + trustFile := filepath.Join(cellHome, "flake-trust") + if data, err := os.ReadFile(trustFile); err == nil { + return strings.TrimSpace(string(data)) == "1" } -} -// syncNixhomeWithConfirmation syncs the configured nixhome path into the -// build context, prompting the user before overwriting an existing sync that -// came from a different source. No-op when no nixhome path is configured. -// -// Only the impure (Dockerfile) build path needs this — runBuildPure resolves -// and consumes nixhome internally via runner.ResolvePureNixhomeRef. -func syncNixhomeWithConfirmation(c config.Config, cellCfg cfg.CellConfig) error { - nixhomePath := cellCfg.Nix.NixhomePath - if nixhomePath == "" { - return nil - } - prevSource := scaffold.NixhomeSource(c.BuildDir) - if prevSource != "" && prevSource != nixhomePath { - ux.Debugf("nixhome source changed: %s → %s", prevSource, nixhomePath) - fmt.Printf(" ⚠ nixhome source changed: %s → %s\n", prevSource, nixhomePath) - overwrite, cErr := ux.GetConfirmation("Overwrite .devcell/nixhome with new source?") - if cErr != nil || !overwrite { - ux.Debugf("Skipping nixhome sync (user declined or error)") - return nil - } + if !isatty.IsTerminal(os.Stdin.Fd()) { + ux.Debugf("project-flake: found flake.nix but stdin is not a terminal — skipping trust prompt") + return false } - ux.Debugf("Syncing nixhome: %s → %s/nixhome/", nixhomePath, c.BuildDir) - if err := scaffold.SyncNixhome(nixhomePath, c.BuildDir); err != nil { - return fmt.Errorf("sync nixhome: %w", err) - } - return nil -} -// runFallbackImpureBuild is the BuildImpure closure for the pure path's -// final fallback: docker-build the scaffolded Dockerfile and retag the -// result under the pure tag so a subsequent launch finds it locally without -// retrying the whole pull chain. Reached when both registry pulls failed -// and the host has no usable nix. -func runFallbackImpureBuild(ctx context.Context, c config.Config, cellCfg cfg.CellConfig) error { - if err := config.EnsureBuildDir(c.BuildDir); err != nil { - return fmt.Errorf("ensure build dir: %w", err) - } - if err := syncNixhomeWithConfirmation(c, cellCfg); err != nil { - return err - } - if err := scaffold.RegenerateBuildContext(c.BuildDir, cellCfg); err != nil { - return fmt.Errorf("regenerate build context: %w", err) - } - if err := buildImageWithSpinner( - c.BuildDir, false, "Building devcell image (impure fallback)", false); err != nil { - return err - } - if err := exec.CommandContext(ctx, "docker", "tag", - runner.UserImageTag(), runner.UserImageTagPure()).Run(); err != nil { - ux.Debugf("retag %s → %s failed: %v", - runner.UserImageTag(), runner.UserImageTagPure(), err) + fmt.Printf("\n Found flake.nix in %s\n", baseDir) + fmt.Printf(" Install its packages into this cell? [Y/n] ") + + var answer string + fmt.Scanln(&answer) + answer = strings.TrimSpace(answer) + + trusted := answer == "" || strings.HasPrefix(strings.ToLower(answer), "y") + + _ = os.MkdirAll(cellHome, 0o755) + if trusted { + _ = os.WriteFile(trustFile, []byte("1\n"), 0o644) + } else { + _ = os.WriteFile(trustFile, []byte("0\n"), 0o644) } - return nil + + return trusted } // updateFlakeLockWithSpinner runs nix flake lock/update with a spinner. -// Same pattern as buildImageWithSpinner. func updateFlakeLockWithSpinner(configDir string, lockOnly bool, label string) error { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() diff --git a/cmd/serve.go b/cmd/serve.go index 93bcdf8..610047f 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -16,6 +16,7 @@ import ( "github.com/DimmKirr/devcell/internal/logger" "github.com/DimmKirr/devcell/internal/runner" "github.com/DimmKirr/devcell/internal/serve" + "github.com/DimmKirr/devcell/internal/telemetry" "github.com/spf13/cobra" ) @@ -144,20 +145,31 @@ Environment: LOG_LEVEL debug|info|warn|error (default: warn) DEVCELL_LOG_PROMPTS=1 Log full prompt + response bodies at INFO level (off by default; prompts may contain secrets) - DEVCELL_SYSTEM_PROMPT Inline system prompt (overridden by --system-prompt) - DEVCELL_SYSTEM_PROMPT_FILE Path to a file used as the system prompt + DEVCELL_SYSTEM_PROMPT Inline BASE prompt (overridden by --system-prompt) + DEVCELL_SYSTEM_PROMPT_FILE Path to a file used as the BASE prompt (overridden by --system-prompt-file) - -System-prompt resolution order (first match wins): - 1. --system-prompt-file - 2. --system-prompt - 3. DEVCELL_SYSTEM_PROMPT_FILE - 4. DEVCELL_SYSTEM_PROMPT - 5. [llm].system_prompt_file in devcell.toml (path relative to project) - 6. [llm].system_prompt in devcell.toml (inline) - -A container-context preamble (bind mounts, host paths, runtime -constraints) is auto-prepended to whichever prompt resolves above. + DEVCELL_APPEND_SYSTEM_PROMPT Inline overlay text + (overridden by --append-system-prompt) + DEVCELL_APPEND_SYSTEM_PROMPT_FILE Path to a file used as overlay text + (overridden by --append-system-prompt-file) + +Two independent layers, each resolved over the same chain: + + BASE replaces Claude Code's built-in prompt entirely (~10.6 KB of + tool guidance and safety instructions). Leave it unset to keep + the built-in prompt. + 1. --system-prompt-file 4. DEVCELL_SYSTEM_PROMPT + 2. --system-prompt 5. [llm].system_prompt_file + 3. DEVCELL_SYSTEM_PROMPT_FILE 6. [llm].system_prompt + + OVERLAY layers on top of whichever base is in effect; nothing is removed. + 1. --append-system-prompt-file 4. DEVCELL_APPEND_SYSTEM_PROMPT + 2. --append-system-prompt 5. [llm].append_system_prompt_file + 3. DEVCELL_APPEND_SYSTEM_PROMPT_FILE 6. [llm].append_system_prompt + +A container-context preamble (bind mounts, host paths, runtime constraints) +is auto-prepended to the OVERLAY. Both layers are written to +.devcell/prompts// and passed to claude as file paths. Per-request 'instructions' (Responses) / 'system' role (Chat) from the API body still merge into the user prompt independently. @@ -172,26 +184,36 @@ Examples: } var ( - servePort int - serveSystemPrompt string - serveSystemPromptFile string - serveHTTPS bool - serveDebug bool - serveWorkspace bool - serveWorkspaceMock bool - serveWorkspaceHost string - serveDocker bool - serveStop bool - servePTY bool + servePort int + serveSystemPrompt string + serveSystemPromptFile string + serveAppendSystemPrompt string + serveAppendSystemPromptFile string + serveHTTPS bool + serveDebug bool + serveWorkspace bool + serveWorkspaceMock bool + serveWorkspaceHost string + serveDocker bool + serveStop bool + servePTY bool ) func init() { serveCmd.Flags().IntVar(&servePort, "port", serve.DefaultPort, "port to listen on") serveCmd.Flags().StringVar(&serveSystemPrompt, "system-prompt", "", - "system prompt passed to claude as --append-system-prompt on every request "+ - "(env: DEVCELL_SYSTEM_PROMPT). Composes with per-request `instructions`/`system` from the API body.") + "BASE prompt passed to claude as --system-prompt-file on every request — "+ + "REPLACES Claude Code's built-in prompt (env: DEVCELL_SYSTEM_PROMPT). "+ + "Composes with per-request `instructions`/`system` from the API body.") + serveCmd.Flags().StringVar(&serveAppendSystemPrompt, "append-system-prompt", "", + "text layered on top of the base prompt, passed to claude as "+ + "--append-system-prompt-file (env: DEVCELL_APPEND_SYSTEM_PROMPT). "+ + "Composes with the container context devcell always contributes.") + serveCmd.Flags().StringVar(&serveAppendSystemPromptFile, "append-system-prompt-file", "", + "path to a file whose contents are layered on top of the base prompt "+ + "(env: DEVCELL_APPEND_SYSTEM_PROMPT_FILE). Mutually exclusive with --append-system-prompt.") serveCmd.Flags().StringVar(&serveSystemPromptFile, "system-prompt-file", "", - "path to a file whose contents are used as the system prompt "+ + "path to a file whose contents REPLACE Claude Code's built-in prompt "+ "(env: DEVCELL_SYSTEM_PROMPT_FILE). Mutually exclusive with --system-prompt.") serveCmd.Flags().BoolVar(&serveHTTPS, "https", false, "serve over HTTPS with an auto-generated self-signed certificate") @@ -212,6 +234,14 @@ func init() { } func runServe(cmd *cobra.Command, args []string) error { + telemetry.Track("serve", map[string]any{ + "port": servePort, + "https": serveHTTPS, + "docker": serveDocker, + "pty": servePTY, + "stop": serveStop, + }) + if serveStop { return stopServeDaemon() } @@ -254,14 +284,23 @@ func runServe(cmd *cobra.Command, args []string) error { } cellCfg := cfg.LoadFromOS(c.ConfigDir, c.BaseDir) - systemPrompt, err := runner.AssembleSystemPrompt(c, cellCfg, runner.ResolveOpts{ - FlagFile: serveSystemPromptFile, - FlagInline: serveSystemPrompt, - EnvFile: os.Getenv("DEVCELL_SYSTEM_PROMPT_FILE"), - EnvInline: os.Getenv("DEVCELL_SYSTEM_PROMPT"), - CellCfg: cellCfg, - CfgBaseDir: c.BaseDir, - }) + promptOpts := runner.ResolveOpts{ + FlagFile: serveSystemPromptFile, + FlagInline: serveSystemPrompt, + EnvFile: os.Getenv("DEVCELL_SYSTEM_PROMPT_FILE"), + EnvInline: os.Getenv("DEVCELL_SYSTEM_PROMPT"), + AppendFlagFile: serveAppendSystemPromptFile, + AppendFlagInline: serveAppendSystemPrompt, + AppendEnvFile: os.Getenv("DEVCELL_APPEND_SYSTEM_PROMPT_FILE"), + AppendEnvInline: os.Getenv("DEVCELL_APPEND_SYSTEM_PROMPT"), + CellCfg: cellCfg, + CfgBaseDir: c.BaseDir, + } + systemPromptFile, err := runner.WriteOverlayPrompt(c, cellCfg, promptOpts) + if err != nil { + return fmt.Errorf("system prompt: %w", err) + } + basePromptFile, err := runner.WriteBasePrompt(c, promptOpts) if err != nil { return fmt.Errorf("system prompt: %w", err) } @@ -276,7 +315,8 @@ func runServe(cmd *cobra.Command, args []string) error { } srv := serve.NewServer(executor, servePort) srv.SetAPIKey(apiKey) - srv.SetSystemPrompt(systemPrompt) + srv.SetSystemPromptFile(systemPromptFile) + srv.SetBasePromptFile(basePromptFile) // Off by default. Setting DEVCELL_LOG_PROMPTS=1 makes /v1/chat/completions // and /v1/responses log full prompt + response text at INFO level. Useful // for debugging client integrations; risky for prod logs because prompts diff --git a/cmd/session_id.go b/cmd/session_id.go new file mode 100644 index 0000000..851202d --- /dev/null +++ b/cmd/session_id.go @@ -0,0 +1,14 @@ +package main + +import "github.com/google/uuid" + +// devcellNamespace is a fixed UUIDv5 namespace for deriving deterministic +// session IDs from APP_NAME. Generated once, never changes. +var devcellNamespace = uuid.NewSHA1(uuid.NameSpaceDNS, []byte("devcell.sh")) + +// sessionUUID returns a deterministic UUIDv5 derived from appName. +// Same appName (project+pane) always produces the same UUID, so agents +// resume the same conversation when relaunched in the same tmux pane. +func sessionUUID(appName string) string { + return uuid.NewSHA1(devcellNamespace, []byte(appName)).String() +} diff --git a/cmd/session_id_test.go b/cmd/session_id_test.go new file mode 100644 index 0000000..fb2b779 --- /dev/null +++ b/cmd/session_id_test.go @@ -0,0 +1,96 @@ +package main_test + +import ( + "os" + "os/exec" + "strings" + "testing" +) + +// TestClaude_SessionID_Injected verifies that CLAUDE_CODE_SESSION_ID is +// injected into the docker argv as a deterministic UUID derived from APP_NAME. +func TestClaude_SessionID_Injected(t *testing.T) { + home := scaffoldedHome(t) + + cmd := exec.Command(binaryPath, "claude", "--dry-run") + cmd.Dir = home + cmd.Env = append(os.Environ(), "DEVCELL_BUNK=1", "HOME="+home) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("claude --dry-run failed: %v\noutput: %s", err, out) + } + + argv := string(out) + if !strings.Contains(argv, "CLAUDE_CODE_SESSION_ID=") { + t.Fatalf("expected CLAUDE_CODE_SESSION_ID in argv:\n%s", argv) + } +} + +// TestClaude_SessionID_Deterministic verifies that the same bunk + project +// always produces the same session ID. +func TestClaude_SessionID_Deterministic(t *testing.T) { + home := scaffoldedHome(t) + + run := func() string { + cmd := exec.Command(binaryPath, "claude", "--dry-run") + cmd.Dir = home + cmd.Env = append(os.Environ(), "DEVCELL_BUNK=42", "HOME="+home) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("claude --dry-run failed: %v\noutput: %s", err, out) + } + return extractEnvFromArgv(string(out), "CLAUDE_CODE_SESSION_ID") + } + + id1 := run() + id2 := run() + if id1 == "" { + t.Fatal("CLAUDE_CODE_SESSION_ID is empty") + } + if id1 != id2 { + t.Errorf("session ID not deterministic: %q != %q", id1, id2) + } +} + +// TestClaude_SessionID_DiffersByBunk verifies that different bunks produce +// different session IDs for the same project. +func TestClaude_SessionID_DiffersByBunk(t *testing.T) { + home := scaffoldedHome(t) + + runWithBunk := func(bunk string) string { + cmd := exec.Command(binaryPath, "claude", "--dry-run") + cmd.Dir = home + cmd.Env = append(os.Environ(), "DEVCELL_BUNK="+bunk, "HOME="+home) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("claude --dry-run failed: %v\noutput: %s", err, out) + } + return extractEnvFromArgv(string(out), "CLAUDE_CODE_SESSION_ID") + } + + id1 := runWithBunk("1") + id2 := runWithBunk("2") + if id1 == id2 { + t.Errorf("same session ID for different bunks: %q", id1) + } +} + +// TestOpencode_NoSessionFlag verifies that opencode does NOT get --session +// injected. OpenCode's --session requires an existing session ID (no +// create-or-resume), so we don't pass it. +func TestOpencode_NoSessionFlag(t *testing.T) { + home := scaffoldedHome(t) + + cmd := exec.Command(binaryPath, "opencode", "--dry-run") + cmd.Dir = home + cmd.Env = append(os.Environ(), "DEVCELL_BUNK=1", "HOME="+home) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("opencode --dry-run failed: %v\noutput: %s", err, out) + } + + argv := string(out) + if strings.Contains(argv, "--session") { + t.Fatalf("--session should NOT be in opencode argv:\n%s", argv) + } +} diff --git a/cmd/shell.go b/cmd/shell.go index 9c90391..78746cf 100644 --- a/cmd/shell.go +++ b/cmd/shell.go @@ -1,14 +1,28 @@ package main -import "github.com/spf13/cobra" +import ( + "context" + "fmt" + "os" + "os/exec" + "os/signal" + "syscall" + + "github.com/DimmKirr/devcell/internal/config" + "github.com/DimmKirr/devcell/internal/runner" + "github.com/mattn/go-isatty" + "github.com/spf13/cobra" +) var shellCmd = &cobra.Command{ Use: "shell [-- command [args...]]", Short: "Open an interactive shell in a devcell container", Long: `Opens an interactive zsh shell inside a devcell container. -The current working directory is mounted as /workspace. Optionally pass a -command after -- to run it non-interactively instead of starting a shell. +If a container is already running (from 'cell start'), attaches to it. +Otherwise starts a new container. The current working directory is mounted +as /workspace. Optionally pass a command after -- to run it +non-interactively instead of starting a shell. Examples: @@ -16,17 +30,31 @@ Examples: cell shell -- ls /workspace`, DisableFlagParsing: true, RunE: func(cmd *cobra.Command, args []string) error { - // Find the -- separator. Everything after it is the command to run - // in the container; everything before it may be devcell flags. + applyOutputFlags() + + c, err := config.LoadFromOS() + if err == nil && runner.ContainerRunning(context.Background(), c.ContainerName) { + binary := "zsh" + var execArgs []string + for i, a := range args { + if a == "--" { + rest := args[i+1:] + if len(rest) > 0 { + binary = rest[0] + execArgs = rest[1:] + } + break + } + } + return execIntoContainer(c.ContainerName, binary, execArgs) + } + + // No running container: fall through to docker run. for i, a := range args { if a == "--" { rest := args[i+1:] cellFlags := args[:i] if len(rest) > 0 { - // Copy into a fresh slice. `cellFlags` and `rest` share the - // args backing array; appending to cellFlags in place would - // overwrite rest[0] (the binary) with rest[1] before docker - // run sees it. binary := rest[0] userArgs := make([]string, 0, len(cellFlags)+len(rest)-1) userArgs = append(userArgs, cellFlags...) @@ -39,3 +67,45 @@ Examples: return runAgent("zsh", nil, args, nil) }, } + +func execIntoContainer(containerName, binary string, args []string) error { + spec := runner.ExecSpec{ + ContainerName: containerName, + Binary: binary, + Args: args, + TTY: isatty.IsTerminal(os.Stdin.Fd()), + } + argv := runner.BuildExecArgv(spec) + + if scanFlag("--dry-run") { + fmt.Println(shellJoin(argv)) + return nil + } + + cmd := exec.Command(argv[0], argv[1:]...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Start(); err != nil { + return fmt.Errorf("exec into %s: %w", containerName, err) + } + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + for sig := range sigCh { + _ = cmd.Process.Signal(sig) + } + }() + + waitErr := cmd.Wait() + signal.Stop(sigCh) + if waitErr != nil { + if exitErr, ok := waitErr.(*exec.ExitError); ok { + os.Exit(exitErr.ExitCode()) + } + return waitErr + } + return nil +} diff --git a/cmd/shell_test.go b/cmd/shell_test.go new file mode 100644 index 0000000..57f6235 --- /dev/null +++ b/cmd/shell_test.go @@ -0,0 +1,42 @@ +package main_test + +import ( + "testing" + + "github.com/DimmKirr/devcell/internal/runner" +) + +func TestShellExecArgv_DefaultZsh(t *testing.T) { + argv := runner.BuildExecArgv(runner.ExecSpec{ + ContainerName: "cell-myproject-0-run", + Binary: "zsh", + TTY: true, + }) + want := []string{"docker", "exec", "-it", "cell-myproject-0-run", "zsh"} + if len(argv) != len(want) { + t.Fatalf("got %v, want %v", argv, want) + } + for i := range want { + if argv[i] != want[i] { + t.Errorf("argv[%d] = %q, want %q", i, argv[i], want[i]) + } + } +} + +func TestShellExecArgv_CustomCommand(t *testing.T) { + argv := runner.BuildExecArgv(runner.ExecSpec{ + ContainerName: "cell-myproject-0-run", + Binary: "bash", + Args: []string{"-c", "echo hello"}, + TTY: false, + }) + want := []string{"docker", "exec", "cell-myproject-0-run", "bash", "-c", "echo hello"} + if len(argv) != len(want) { + t.Fatalf("got %v, want %v", argv, want) + } + for i := range want { + if argv[i] != want[i] { + t.Errorf("argv[%d] = %q, want %q", i, argv[i], want[i]) + } + } +} diff --git a/cmd/start.go b/cmd/start.go new file mode 100644 index 0000000..cb09cc3 --- /dev/null +++ b/cmd/start.go @@ -0,0 +1,33 @@ +package main + +import ( + "context" + "fmt" + + "github.com/DimmKirr/devcell/internal/config" + "github.com/DimmKirr/devcell/internal/runner" + "github.com/spf13/cobra" +) + +var startDetach bool + +var startCmd = &cobra.Command{ + Use: "start", + Short: "Start a devcell container in the background", + Long: `Starts a devcell container running in the background. +Use 'cell shell' to attach, 'cell stop' to shut it down.`, + RunE: func(cmd *cobra.Command, args []string) error { + applyOutputFlags() + c, err := config.LoadFromOS() + if err != nil { + return fmt.Errorf("load config: %w", err) + } + if runner.ContainerRunning(context.Background(), c.ContainerName) { + fmt.Printf("Container %s is already running\n", c.ContainerName) + return nil + } + startDetach = true + defer func() { startDetach = false }() + return runAgent("sleep", []string{"infinity"}, nil, nil) + }, +} diff --git a/cmd/start_test.go b/cmd/start_test.go new file mode 100644 index 0000000..767d22c --- /dev/null +++ b/cmd/start_test.go @@ -0,0 +1,42 @@ +package main_test + +import ( + "os" + "os/exec" + "strings" + "testing" +) + +func TestStartHelp(t *testing.T) { + out, err := exec.Command(binaryPath, "start", "--help").CombinedOutput() + if err != nil { + t.Fatalf("start --help exited non-zero: %v\noutput: %s", err, out) + } + if !strings.Contains(string(out), "background") { + t.Errorf("expected 'background' in start --help output, got:\n%s", out) + } +} + +func TestStart_DryRun(t *testing.T) { + home := scaffoldedHome(t) + cmd := exec.Command(binaryPath, "--dry-run", "--plain-text", "start") + cmd.Dir = home + cmd.Env = append(os.Environ(), + "DEVCELL_BUNK=0", + "HOME="+home, + ) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("start --dry-run failed: %v\noutput: %s", err, out) + } + s := string(out) + if !strings.Contains(s, " -d ") { + t.Errorf("expected -d in dry-run output:\n%s", s) + } + if !strings.Contains(s, "sleep infinity") { + t.Errorf("expected 'sleep infinity' in dry-run output:\n%s", s) + } + if strings.Contains(s, " -it ") { + t.Errorf("-it should not appear in detached mode:\n%s", s) + } +} diff --git a/cmd/stop.go b/cmd/stop.go new file mode 100644 index 0000000..5dae48b --- /dev/null +++ b/cmd/stop.go @@ -0,0 +1,34 @@ +package main + +import ( + "context" + "fmt" + "os/exec" + + "github.com/DimmKirr/devcell/internal/config" + "github.com/DimmKirr/devcell/internal/runner" + "github.com/spf13/cobra" +) + +var stopCmd = &cobra.Command{ + Use: "stop", + Short: "Stop the running devcell container", + Long: `Stops the devcell container started by 'cell start'. +The container is automatically removed after stopping.`, + RunE: func(cmd *cobra.Command, args []string) error { + c, err := config.LoadFromOS() + if err != nil { + return fmt.Errorf("load config: %w", err) + } + ctx := context.Background() + if !runner.ContainerRunning(ctx, c.ContainerName) { + fmt.Printf("No running container %s found\n", c.ContainerName) + return nil + } + if err := exec.CommandContext(ctx, "docker", "stop", c.ContainerName).Run(); err != nil { + return fmt.Errorf("stop container %s: %w", c.ContainerName, err) + } + fmt.Printf("Container %s stopped\n", c.ContainerName) + return nil + }, +} diff --git a/cmd/stop_test.go b/cmd/stop_test.go new file mode 100644 index 0000000..59b0947 --- /dev/null +++ b/cmd/stop_test.go @@ -0,0 +1,17 @@ +package main_test + +import ( + "os/exec" + "strings" + "testing" +) + +func TestStopHelp(t *testing.T) { + out, err := exec.Command(binaryPath, "stop", "--help").CombinedOutput() + if err != nil { + t.Fatalf("stop --help exited non-zero: %v\noutput: %s", err, out) + } + if !strings.Contains(string(out), "Stop") { + t.Errorf("expected 'Stop' in stop --help output, got:\n%s", out) + } +} diff --git a/cmd/tart_runner.go b/cmd/tart_runner.go index dacad91..831fbbd 100644 --- a/cmd/tart_runner.go +++ b/cmd/tart_runner.go @@ -117,12 +117,8 @@ func runTartAgent( Disks: disks, SSHTimeout: 120 * time.Second, InitFunc: func() error { - nixhome := baseDir + "/nixhome" - if cellCfg.Nix.NixhomePath != "" { - nixhome = cellCfg.Nix.NixhomePath - } - logf("auto-build: VM not found — running build with stack=%q nixhome=%q", stack, nixhome) - return runBuildTart(cellName, hostHome, baseDir, stack, nil, nixhome, false, false, false, cellCfg.Cell.ResolvedTartOCIImage()) + logf("auto-build: VM not found — running build with stack=%q", stack) + return runBuildTart(cellName, hostHome, baseDir, stack, nil, false, false, false, cellCfg.Cell.ResolvedTartOCIImage()) }, } acquireIn.ApplyDefaults() diff --git a/cmd/telemetry.go b/cmd/telemetry.go new file mode 100644 index 0000000..ecb862a --- /dev/null +++ b/cmd/telemetry.go @@ -0,0 +1,85 @@ +package main + +import ( + "fmt" + "os" + + "github.com/DimmKirr/devcell/internal/config" + "github.com/DimmKirr/devcell/internal/telemetry" + "github.com/spf13/cobra" +) + +var telemetryCmd = &cobra.Command{ + Use: "telemetry", + Short: "Manage anonymous usage analytics", + Long: `Manage opt-in anonymous usage analytics. + +devcell collects anonymous feature-usage data (which commands, engines, and +stacks are popular) to guide development priorities. No personal data, file +paths, or command arguments are ever sent. + + cell telemetry show current status + cell telemetry on opt in (generates an anonymous ID) + cell telemetry off opt out (preserves ID for re-enable) + +Respects DO_NOT_TRACK=1 (consoledonottrack.com).`, + RunE: telemetryStatusCmd.RunE, +} + +var telemetryOnCmd = &cobra.Command{ + Use: "on", + Short: "Enable anonymous usage analytics", + RunE: func(cmd *cobra.Command, args []string) error { + configDir := resolveConfigDir() + cfg, err := telemetry.Enable(configDir) + if err != nil { + return fmt.Errorf("enable telemetry: %w", err) + } + fmt.Fprintf(cmd.OutOrStdout(), "Telemetry enabled.\nAnonymous ID: %s\n", cfg.AnonymousID) + return nil + }, +} + +var telemetryOffCmd = &cobra.Command{ + Use: "off", + Short: "Disable anonymous usage analytics", + RunE: func(cmd *cobra.Command, args []string) error { + configDir := resolveConfigDir() + if _, err := telemetry.Disable(configDir); err != nil { + return fmt.Errorf("disable telemetry: %w", err) + } + fmt.Fprintln(cmd.OutOrStdout(), "Telemetry disabled.") + return nil + }, +} + +var telemetryStatusCmd = &cobra.Command{ + Use: "status", + Short: "Show telemetry status", + RunE: func(cmd *cobra.Command, args []string) error { + configDir := resolveConfigDir() + cfg := telemetry.LoadConfig(configDir) + + w := cmd.OutOrStdout() + if os.Getenv("DO_NOT_TRACK") == "1" { + fmt.Fprintln(w, "Telemetry: disabled (DO_NOT_TRACK=1 is set)") + } else if cfg.Enabled { + fmt.Fprintf(w, "Telemetry: enabled\nAnonymous ID: %s\n", cfg.AnonymousID) + } else { + fmt.Fprintln(w, "Telemetry: disabled") + } + return nil + }, +} + +func resolveConfigDir() string { + if c, err := config.LoadFromOS(); err == nil { + return c.ConfigDir + } + home, _ := os.UserHomeDir() + return home + "/.config/devcell" +} + +func init() { + telemetryCmd.AddCommand(telemetryOnCmd, telemetryOffCmd, telemetryStatusCmd) +} diff --git a/cmd/telemetry_test.go b/cmd/telemetry_test.go new file mode 100644 index 0000000..25b2132 --- /dev/null +++ b/cmd/telemetry_test.go @@ -0,0 +1,128 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DimmKirr/devcell/internal/telemetry" +) + +func TestTelemetryOn_CreatesConfig(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + t.Setenv("DO_NOT_TRACK", "") + + configDir := filepath.Join(dir, "devcell") + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatal(err) + } + + out := new(strings.Builder) + cmd := telemetryOnCmd + cmd.SetOut(out) + cmd.SetErr(out) + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(filepath.Join(configDir, "telemetry.json")) + if err != nil { + t.Fatalf("telemetry.json not created: %v", err) + } + var cfg telemetry.Config + if err := json.Unmarshal(data, &cfg); err != nil { + t.Fatal(err) + } + if !cfg.Enabled { + t.Error("expected Enabled=true") + } + if cfg.AnonymousID == "" { + t.Error("expected non-empty AnonymousID") + } +} + +func TestTelemetryOff_DisablesConfig(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + t.Setenv("DO_NOT_TRACK", "") + + configDir := filepath.Join(dir, "devcell") + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatal(err) + } + + // Enable first + cfg, err := telemetry.Enable(configDir) + if err != nil { + t.Fatal(err) + } + origID := cfg.AnonymousID + + out := new(strings.Builder) + cmd := telemetryOffCmd + cmd.SetOut(out) + cmd.SetErr(out) + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatal(err) + } + + cfg = telemetry.LoadConfig(configDir) + if cfg.Enabled { + t.Error("expected Enabled=false after off") + } + if cfg.AnonymousID != origID { + t.Errorf("UUID changed: %q → %q", origID, cfg.AnonymousID) + } +} + +func TestTelemetryStatus_ShowsState(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + t.Setenv("DO_NOT_TRACK", "") + + configDir := filepath.Join(dir, "devcell") + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatal(err) + } + if _, err := telemetry.Enable(configDir); err != nil { + t.Fatal(err) + } + + out := new(strings.Builder) + cmd := telemetryStatusCmd + cmd.SetOut(out) + cmd.SetErr(out) + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatal(err) + } + + if !strings.Contains(out.String(), "enabled") { + t.Errorf("output %q does not contain 'enabled'", out.String()) + } +} + +func TestTelemetryStatus_ShowsDoNotTrack(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + t.Setenv("DO_NOT_TRACK", "1") + + configDir := filepath.Join(dir, "devcell") + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatal(err) + } + + out := new(strings.Builder) + cmd := telemetryStatusCmd + cmd.SetOut(out) + cmd.SetErr(out) + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatal(err) + } + + if !strings.Contains(out.String(), "DO_NOT_TRACK") { + t.Errorf("output %q does not mention DO_NOT_TRACK", out.String()) + } +} diff --git a/cmd/vagrant_runner.go b/cmd/vagrant_runner.go index 98886ce..fd01cd1 100644 --- a/cmd/vagrant_runner.go +++ b/cmd/vagrant_runner.go @@ -20,6 +20,7 @@ import ( // 2. Ensures the VM is up (skipped in dry-run mode) // 3. Execs: vagrant ssh -- -t [env KEY=VAL...] // with cmd.Dir=vagrantDir so vagrant locates the correct Vagrantfile +// // stackNeedsGUI reports whether the stack + modules configuration includes // desktop/GUI components. Only "ultimate" and "electronics" stacks include // the desktop module; it can also be added explicitly via extra modules. @@ -116,7 +117,7 @@ func runVagrantAgent( } // 2c. Start GUI services when the stack includes desktop and GUI is enabled. - guiNeeded := cellCfg.Cell.ResolvedGUI() && stackNeedsGUI(stack, cellCfg.Cell.Modules) + guiNeeded := cellCfg.GUI.ResolvedEnabled() && stackNeedsGUI(stack, cellCfg.Cell.Modules) if guiNeeded { guiCtx, guiCancel := context.WithTimeout(context.Background(), 30*time.Second) defer guiCancel() diff --git a/cmd/vnc.go b/cmd/vnc.go index 12eabfa..dea18f3 100644 --- a/cmd/vnc.go +++ b/cmd/vnc.go @@ -7,12 +7,15 @@ import ( "os/exec" "path/filepath" "runtime" + "strconv" "strings" "github.com/DimmKirr/devcell/internal/config" internalrdp "github.com/DimmKirr/devcell/internal/rdp" "github.com/DimmKirr/devcell/internal/runner" + "github.com/DimmKirr/devcell/internal/telemetry" "github.com/DimmKirr/devcell/internal/ux" + "github.com/DimmKirr/devcell/internal/vm/qemu" internalvnc "github.com/DimmKirr/devcell/internal/vnc" "github.com/spf13/cobra" ) @@ -44,6 +47,8 @@ func runVNC(cmd *cobra.Command, args []string) error { vncGlobal, _ = cmd.Flags().GetBool("global") vncViewer, _ = cmd.Flags().GetString("viewer") + telemetry.Track("vnc", map[string]any{"viewer": vncViewer, "list": list, "global": vncGlobal}) + if list { return vncList() } @@ -190,6 +195,17 @@ func collectVNCCells(c config.Config, global bool) map[string]string { } } + // QEMU VMs (always global — one per cell, not per project) + vncDebug("qemu: scanning for running VMs") + for _, vm := range qemu.DiscoverRunningVMs(c.HostHome) { + if vm.Ports.VNCPort > 0 { + appName := "qemu-" + vm.CellName + port := strconv.Itoa(int(vm.Ports.VNCPort)) + vncDebug("qemu cell found: %s → %s", appName, port) + result[appName] = port + } + } + vncDebug("collectVNCCells result: %v", result) return result } diff --git a/flake.nix b/flake.nix index 43b3f92..6ea4dfc 100644 --- a/flake.nix +++ b/flake.nix @@ -18,7 +18,7 @@ }); # Tight source filter — only the Go files needed to compile cell. - # Excludes test/results/, web/, nixhome/, docs/, etc. so each + # Excludes test/results/, web/, docs/, etc. so each # nix-build doesn't copy the entire repo into the store. cellSrc = nixpkgs.lib.fileset.toSource { root = ./.; @@ -31,7 +31,7 @@ }; # Version stamped into the binary via -ldflags. Consumers pinning a - # release (`nix profile install github:DimmKirr/devcell/v0.8.2#cell`) + # release (`nix profile install github:devcell-sh/devcell/v0.8.2#cell`) # override this by pointing `nix build` at a tagged ref and passing # `--override-input` or by editing this string in a release commit. # `self.shortRev` is populated when Nix evaluates a clean flake ref @@ -55,7 +55,7 @@ version = nixpkgs.lib.removePrefix "v" cellVersion; src = cellSrc; - vendorHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; + vendorHash = "sha256-Jl7DQv3SXJ6H/BY97LQH1Zm47nOgaYAKyN1AczTNpro="; subPackages = ["cmd"]; @@ -93,7 +93,7 @@ meta = with pkgs.lib; { description = "devcell CLI — container-native dev environments"; - homepage = "https://github.com/DimmKirr/devcell"; + homepage = "https://github.com/devcell-sh/devcell"; license = licenses.mit; mainProgram = "cell"; }; @@ -101,6 +101,22 @@ default = cell; }); + # Home-manager module: configure the GLOBAL devcell config declaratively. + # imports = [ devcell.homeManagerModules.default ]; + # devcell = { enable = true; prompt = "..."; op.documents = [ ... ]; }; + # Renders ~/.config/devcell/devcell.toml and installs the cell CLI from + # this flake (override with devcell.package). Option tree is generated + # from internal/cfg.CellConfig by `task hm:generate` — see + # nix/home-manager/options.nix. + homeManagerModules = rec { + devcell = { config, lib, pkgs, ... }: { + imports = [ ./nix/home-manager/module.nix ]; + config.devcell.package = + lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.cell; + }; + default = devcell; + }; + # `nix develop` for local hacking — provides Go 1.26 + tooling. # nix-update rewrites `vendorHash` in this file after go.mod/go.sum # changes; pre-commit dispatches the hook defined in @@ -116,6 +132,11 @@ pkgs.go-task pkgs.nix-update pkgs.pre-commit + pkgs.powershell + # TPM emulator for `task debug:windows:start` — the Windows debug + # VM carries its TPM state in the .utm bundle (BitLocker). + pkgs.swtpm + pkgs.cdrkit ]; shellHook = '' if [ -d .git ] && [ -f .pre-commit-config.yaml ] && \ diff --git a/go.mod b/go.mod index 3672e44..d3f9a72 100644 --- a/go.mod +++ b/go.mod @@ -12,30 +12,45 @@ require ( github.com/charmbracelet/x/vt v0.0.0-20260712004152-b16d026a9d2e github.com/charmbracelet/x/xpty v0.1.3 github.com/creack/pty v1.1.24 + github.com/digitalocean/go-libvirt v0.0.0-20260609165003-6254771e63a8 github.com/docker/docker v28.5.1+incompatible - github.com/google/go-containerregistry v0.21.5 + github.com/google/go-containerregistry v0.21.9 + github.com/google/uuid v1.6.0 + github.com/hydrz/wireguard v0.0.1 github.com/mattn/go-isatty v0.0.20 github.com/muesli/termenv v0.16.0 github.com/ollama/ollama v0.17.6 github.com/openai/openai-go v1.12.0 + github.com/posthog/posthog-go v1.22.0 github.com/spf13/cobra v1.10.2 + github.com/stretchr/testify v1.11.1 github.com/swaggo/http-swagger/v2 v2.0.2 github.com/swaggo/swag v1.16.6 github.com/testcontainers/testcontainers-go v0.40.0 - golang.org/x/crypto v0.54.0 + golang.org/x/crypto v0.55.0 golang.org/x/image v0.41.0 - golang.org/x/mod v0.37.0 + golang.org/x/mod v0.38.0 gopkg.in/yaml.v3 v3.0.1 howett.net/plist v1.0.1 k8s.io/client-go v0.36.2 + libvirt.org/go/libvirtxml v1.12005.0 sigs.k8s.io/controller-runtime v0.24.1 ) +require ( + github.com/diskfs/go-diskfs v1.9.4 // indirect + github.com/gliderlabs/ssh v0.3.8 // indirect + golang.org/x/sys v0.47.0 // indirect +) + require ( dario.cat/mergo v1.0.2 // indirect github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect github.com/KyleBanks/depth v1.2.1 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/anchore/go-lzo v0.1.0 // indirect + github.com/andybalholm/brotli v1.1.1 // indirect + github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect @@ -61,17 +76,22 @@ require ( github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect - github.com/containerd/stargz-snapshotter/estargz v0.18.2 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/devcell-sh/go-nixoci v0.1.0 + github.com/devcell-sh/go-regedit v0.1.0 + github.com/devcell-sh/go-wimlib v0.1.0 + github.com/devcell-sh/go-winkit v0.2.0 github.com/distribution/reference v0.6.0 // indirect - github.com/docker/cli v29.4.0+incompatible // indirect + github.com/djherbis/times v1.6.0 // indirect + github.com/docker/cli v29.6.2+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.3 // indirect - github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-connections v0.7.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/ebitengine/purego v0.8.4 // indirect + github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57 // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect @@ -85,19 +105,19 @@ require ( github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/spec v0.20.6 // indirect github.com/go-openapi/swag v0.23.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect github.com/google/gnostic-models v0.7.0 // indirect - github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.18.5 // indirect + github.com/klauspost/compress v1.19.1 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.23 // indirect - github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/go-archive v0.1.0 // indirect @@ -114,7 +134,9 @@ require ( github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pierrec/lz4/v4 v4.1.26 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/pkg/xattr v0.4.12 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/prometheus/client_golang v1.23.2 // indirect @@ -126,7 +148,6 @@ require ( github.com/shirou/gopsutil/v4 v4.25.6 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/spf13/pflag v1.0.10 // indirect - github.com/stretchr/testify v1.11.1 // indirect github.com/swaggo/files/v2 v2.0.0 // indirect github.com/tidwall/gjson v1.14.4 // indirect github.com/tidwall/match v1.1.1 // indirect @@ -134,7 +155,7 @@ require ( github.com/tidwall/sjson v1.2.5 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect - github.com/vbatts/tar-split v0.12.2 // indirect + github.com/ulikunitz/xz v0.5.15 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect @@ -147,14 +168,13 @@ require ( go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect - golang.org/x/net v0.56.0 // indirect + golang.org/x/net v0.57.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.22.0 // indirect - golang.org/x/sys v0.47.0 // indirect golang.org/x/term v0.45.0 // indirect - golang.org/x/text v0.40.0 // indirect + golang.org/x/text v0.41.0 // indirect golang.org/x/time v0.14.0 // indirect - golang.org/x/tools v0.47.0 // indirect + golang.org/x/tools v0.48.0 // indirect google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect diff --git a/go.sum b/go.sum index affd2ed..476d062 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,12 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1 github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/anchore/go-lzo v0.1.0 h1:NgAacnzqPeGH49Ky19QKLBZEuFRqtTG9cdaucc3Vncs= +github.com/anchore/go-lzo v0.1.0/go.mod h1:3kLx0bve2oN1iDwgM1U5zGku1Tfbdb0No5qp1eL1fIk= +github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= +github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= @@ -82,8 +88,6 @@ github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= -github.com/containerd/stargz-snapshotter/estargz v0.18.2 h1:yXkZFYIzz3eoLwlTUZKz2iQ4MrckBxJjkmD16ynUTrw= -github.com/containerd/stargz-snapshotter/estargz v0.18.2/go.mod h1:XyVU5tcJ3PRpkA9XS2T5us6Eg35yM0214Y+wvrZTBrY= github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -96,22 +100,38 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/devcell-sh/go-nixoci v0.1.0 h1:SFC8JXEyBvUn/rLKKVWCwTlEmVPTc71ExGkd/NPS5gA= +github.com/devcell-sh/go-nixoci v0.1.0/go.mod h1:YSZipF+SryYurcvQ5LvtRkWzkM/YJCDHeJYB+h/BZBg= +github.com/devcell-sh/go-regedit v0.1.0 h1:+eT+eLZQZtDpKhzjlGBP2DdyNuUhFTX+8354jTw8W5Y= +github.com/devcell-sh/go-regedit v0.1.0/go.mod h1:pWEerGIoAAVu00kuyFaXCmIzIWghcJYwiK8xGha1L5E= +github.com/devcell-sh/go-wimlib v0.1.0 h1:pg1WxyqfMm/9Anmp9amfr+nMKwzAc4D5ra4n8qkZkMg= +github.com/devcell-sh/go-wimlib v0.1.0/go.mod h1:BFGCDzvSqoVtHxQ8j5GhXHmO8c3w/kLnHDJaOogPstE= +github.com/devcell-sh/go-winkit v0.2.0 h1:qx4udUGbd33q3mE1Cg2nNzN5bpGMymD2VI49sPCC4PU= +github.com/devcell-sh/go-winkit v0.2.0/go.mod h1:knVXG2/GhrkkHZIvie9+NeZLH93F0NbISzeXtMROTUk= +github.com/digitalocean/go-libvirt v0.0.0-20260609165003-6254771e63a8 h1:R4zqGCPowg1bfJXFxsS122mzkivaMz+wPLxBsF9qsiY= +github.com/digitalocean/go-libvirt v0.0.0-20260609165003-6254771e63a8/go.mod h1:qb0Ofa71d3oXARQf633h2tNaeBxLsVxuDp+jcsVO2+4= +github.com/diskfs/go-diskfs v1.9.4 h1:0j2d7eG4IjyxL6+ChWbDPocdBCF6HQ4HBWU2WDYWVnc= +github.com/diskfs/go-diskfs v1.9.4/go.mod h1:TePJORO83Adh5pb2SqsxAwaP0fofFxKLkxctiS/9OQc= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/cli v29.4.0+incompatible h1:+IjXULMetlvWJiuSI0Nbor36lcJ5BTcVpUmB21KBoVM= -github.com/docker/cli v29.4.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= +github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= +github.com/docker/cli v29.6.2+incompatible h1:/bjePvcbbFTnRrMfWJBY7AjfICdsiLVgHn6LwTVOcqw= +github.com/docker/cli v29.6.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM= github.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= -github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= -github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= +github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57 h1:x5yxNrq8XffV/OoNUeFPM6hxHVi5OTspSTBxr/9pemg= +github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57/go.mod h1:GLo/8fDswSAniFG+BFIaiSPcK610jyzgEhWYPQwuQdw= github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= @@ -124,6 +144,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= +github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE= github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -152,13 +174,17 @@ github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+Gr github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-containerregistry v0.21.5 h1:KTJG9Pn/jC0VdZR6ctV3/jcN+q6/Iqlx0sTVz3ywZlM= -github.com/google/go-containerregistry v0.21.5/go.mod h1:ySvMuiWg+dOsRW0Hw8GYwfMwBlNRTmpYBFJPlkco5zU= +github.com/google/go-containerregistry v0.21.9 h1:F+D4uZ3iA3DLMJLfhaqMdHJbzeqm/216WGQq2dokuLs= +github.com/google/go-containerregistry v0.21.9/go.mod h1:dP5XNKcL7kMFF/TB3LfvWmVhAcv7iqkHb3oDK8aauTo= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= @@ -166,6 +192,10 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hydrz/wireguard v0.0.1 h1:cvdHILTiztublnDBvW49csPpeLRmZ/UOVpGPydLst7c= +github.com/hydrz/wireguard v0.0.1/go.mod h1:WbOpJZMqeMwVAAZ5VPuMZFHB2tafrbm6DF9Q1V/+j/Y= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= @@ -173,8 +203,8 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= -github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= +github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -200,8 +230,6 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= -github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= @@ -249,11 +277,17 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= +github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/xattr v0.4.12 h1:rRTkSyFNTRElv6pkA3zpjHpQ90p/OdHQC1GmGh1aTjM= +github.com/pkg/xattr v0.4.12/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/posthog/posthog-go v1.22.0 h1:VNy+sMJ9MMnENr9dMSxfQt/5bB4UhwRdZfasOAghMMg= +github.com/posthog/posthog-go v1.22.0/go.mod h1://M430hNH3e8CDv4i8SJesb26816Mpa6GIZaiP4pNQU= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= @@ -313,14 +347,16 @@ github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFA github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= -github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CPa4= -github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= +github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= +github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -353,16 +389,16 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= -golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0= golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= -golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= -golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= -golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= @@ -371,6 +407,8 @@ golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -378,12 +416,12 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= -golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= @@ -430,6 +468,8 @@ k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hk k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU= k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +libvirt.org/go/libvirtxml v1.12005.0 h1:KOxYULmLDHBR4GOd/c+8K65XtTYilVmiDPyr37mUGms= +libvirt.org/go/libvirtxml v1.12005.0/go.mod h1:7Oq2BLDstLr/XtoQD8Fr3mfDNrzlI3utYKySXF2xkng= sigs.k8s.io/controller-runtime v0.24.1 h1:miPEwrmirImAvgME1L9qebGHrOnGJoVmVdtOU9fRfo4= sigs.k8s.io/controller-runtime v0.24.1/go.mod h1:vFkfY5fGt5xAC/sKb8IBFKgWPNKG9OUG29dR8Y2wImw= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= diff --git a/images/Dockerfile b/images/Dockerfile deleted file mode 100644 index a9614ed..0000000 --- a/images/Dockerfile +++ /dev/null @@ -1,362 +0,0 @@ -# syntax=docker/dockerfile:1 - -# Global ARG — must be before the first FROM to be usable in FROM instructions. -# CI sets this to the previous ultimate image for nix store pre-seeding. -# Default is debian:trixie-slim (same as core base, already pulled — empty /nix/store). -ARG NIX_CACHE_IMAGE=public.ecr.aws/docker/library/debian:trixie-slim - -############################################################################### -# Stage: builder -# Compile the cell CLI binary from repo source (CI context = repo root). -############################################################################### -FROM public.ecr.aws/docker/library/golang:1.26-alpine AS builder -WORKDIR /src -COPY go.mod go.sum ./ -RUN go mod download -COPY . . -RUN go run github.com/swaggo/swag/cmd/swag@latest init -g cmd/serve.go -o docs --parseDependency --parseInternal -RUN CGO_ENABLED=0 go build -o /cell ./cmd - -############################################################################### -# Stage: core -# Apt + user creation + nix + home-manager. Published as core image. -# User Dockerfiles apply profiles via their own flake.nix. -############################################################################### -FROM public.ecr.aws/docker/library/debian:trixie-slim AS core - -# Add Docker APT repo and install all system packages in one layer. -RUN apt-get update && apt-get install -y curl gpg && \ - curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg && \ - echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian trixie stable" \ - | tee /etc/apt/sources.list.d/docker.list > /dev/null && \ - apt-get update && apt-get install -y \ - ca-certificates \ - curl \ - docker-ce-cli \ - docker-compose-plugin \ - fontconfig \ - fonts-dejavu-core \ - fonts-liberation \ - fonts-noto-core \ - git \ - gosu \ - locales \ - procps \ - sudo \ - tini \ - xz-utils \ - zsh \ - && sed -i 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen \ - && locale-gen \ - && rm -rf /var/lib/apt/lists/* - -ARG USER_NAME=devcell -ARG USER_UID=1000 -ARG USER_GID=1000 - -RUN \ - groupadd -g ${USER_GID} usergroup 2>/dev/null || true && \ - useradd -u ${USER_UID} -g ${USER_GID} --home-dir /opt/devcell -m -s /bin/zsh ${USER_NAME} && \ - chmod 755 /opt/devcell && \ - mkdir -p /opt/devcell/.local/bin && \ - chown -R ${USER_UID}:${USER_GID} /opt/devcell && \ - echo "${USER_NAME} ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers && \ - # Parity with pure-image sudoers (nixhome/packages/image.nix:288-295): - # without env_keep, Debian's default `env_reset` strips nix/SSL/locale - # vars across sudo, breaking `sudo nix profile add nixpkgs#foo` with - # "SSL peer certificate ... was not OK" against cache.nixos.org. Single- - # user cell with NOPASSWD:ALL → env_reset's privilege-escalation - # protection for these vars is moot; keeping them is a UX fix. - echo 'Defaults env_keep += "SSL_CERT_FILE NIX_SSL_CERT_FILE NIX_PATH NIX_CONFIG NIX_REMOTE NIX_USER_CONF_FILES LOCALE_ARCHIVE"' >> /etc/sudoers - -COPY --from=builder --chmod=755 /cell /opt/devcell/.local/bin/cell - -RUN mkdir -p /config /data /opt/mise \ - /opt/devcell/.config/devcell && \ - chown -R ${USER_UID}:${USER_GID} /config /data /opt/mise \ - /opt/devcell/.config - -# System-level nix.conf (read by any nix binary regardless of user). -# sandbox=false + filter-syscalls=false: Docker Desktop's Linux VM kernel rejects -# nix's seccomp BPF program with EINVAL (kernel compatibility issue). -# Docker's own isolation is sufficient for build containers. -RUN mkdir -p /etc/nix && \ - printf 'sandbox = false\nfilter-syscalls = false\nsandbox-fallback = true\nexperimental-features = nix-command flakes\nmax-substitution-jobs = 128\nhttp-connections = 128\n' \ - > /etc/nix/nix.conf - -USER ${USER_UID}:${USER_GID} - -WORKDIR /opt/devcell - -ENV PATH="/opt/devcell/.local/bin:${PATH}" -ENV HOME=/opt/devcell -# USER env var is required by nix.sh and nix profile management to locate per-user profiles. -ENV USER=${USER_NAME} - -# SSL + locale env. Parity with pure image (nixhome/packages/image.nix:790-792) -# so docker exec sessions and `sudo env_keep` see the same values regardless -# of image build path. SSL_CERT_FILE / NIX_SSL_CERT_FILE point at Debian's -# apt-installed CA bundle (`ca-certificates` package, line ~33) — stable path -# present from the core stage forward, works for nix, curl, and any TLS -# client that respects SSL_CERT_FILE. LOCALE_ARCHIVE points at the -# home-manager-installed glibc-locales archive — stable path materialized by -# the per-stack home-manager switch later in the build. -ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt -ENV NIX_SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt -ENV LOCALE_ARCHIVE=/opt/devcell/.nix-profile/lib/locale/locale-archive - -# nix-ld bridge for non-nix binaries (mise-downloaded node/go/terraform, pip -# wheels, downloaded gpg keychains). The stable paths under $HOME are created -# by home-manager via `home.file.".nix-ld-loader"` and `.nix-ld-shim` (see -# nixhome/modules/base.nix). At runtime, the entrypoint creates the -# `/lib/ld-linux-.so.` symlink → .nix-ld-shim so precompiled -# binaries find their interpreter; nix-ld then reads NIX_LD to find the -# real glibc loader. -# -# NIX_LD_LIBRARY_PATH points at a merged .nix-ld-libs/ directory (symlinks -# to every .so* from the profile closure, glibc excluded). Created by -# home.activation.generateNixLdLibs at `home-manager switch` time. -# Short enough (~30 chars) to bake into ENV without hitting ARG_MAX. -ENV NIX_LD=/opt/devcell/.nix-ld-loader -ENV NIX_LD_LIBRARY_PATH=/opt/devcell/.nix-ld-libs - -# Install nix. -# Pin to a specific version to avoid SHA-256 hash mismatches when upstream -# re-publishes a release tarball without updating the install script. -# NIX_CONFIG is exported inline via printf so it contains a real newline -# (Dockerfile ENV \n is a literal backslash-n, not a newline character). -# sandbox=false: Docker's seccomp profile blocks the BPF syscalls nix's sandbox -# needs; Docker's own isolation provides sufficient security for build containers. -ARG NIX_VERSION=2.33.3 -RUN export NIX_CONFIG="$(printf 'experimental-features = nix-command flakes\nsandbox = false\nfilter-syscalls = false\nsandbox-fallback = true')" && \ - curl -L "https://releases.nixos.org/nix/nix-${NIX_VERSION}/install" | sh -s -- --no-daemon && \ - mkdir -p "${HOME}/.config/nix" && \ - printf 'experimental-features = nix-command flakes\nsandbox = false\nfilter-syscalls = false\nsandbox-fallback = true\nmax-substitution-jobs = 128\nhttp-connections = 128\n' \ - > "${HOME}/.config/nix/nix.conf" - -# Nix 2.15+ uses XDG paths. Add nix-profile to PATH for subsequent RUN commands. -ENV PATH="${HOME}/.nix-profile/bin:${PATH}" - -# Install home-manager. -# Pin nixpkgs to the exact commit from nixhome/flake.lock to avoid GitHub API -# branch→commit resolution (anonymous rate limit is 60 req/hr per IP; shared -# GHA runners exhaust this). Update this hash when bumping nixhome/flake.lock. -ARG NIXPKGS_REV=c217913993d6c6f6805c3b1a3bda5e639adfde6d -RUN nix profile install "github:NixOS/nixpkgs/${NIXPKGS_REV}#home-manager" - -ENV MISE_DATA_DIR=/opt/mise -# Use home-manager's native profile path — automatically updated on every -# `home-manager switch`, no manual symlink management needed. -# Mise installs go to /opt/mise/installs///bin. Child stages create -# stable symlinks (/opt/mise/ → installs//) and append to PATH -# so that tools are available without shims (shims need `mise exec` resolution -# which fails in plain /bin/sh RUN steps). -ENV PATH="/opt/devcell/.local/state/nix/profiles/profile/bin:${PATH}" - -# Stamp base image version (commit SHA + build date) — needs root for /etc -USER 0 -ARG GIT_COMMIT=unknown -RUN mkdir -p /etc/devcell && \ - chown ${USER_UID}:${USER_GID} /etc/devcell && \ - echo "${GIT_COMMIT}-$(date -u +%Y%m%dT%H%M%SZ)" > /etc/devcell/base-image-version - -# Entrypoint last — changes here don't bust the nix/home-manager cache above -COPY --chmod=755 nixhome/entrypoint.sh /usr/local/bin/entrypoint.sh - -# Restore to devcell user — child stages (go, node, etc.) inherit this USER -USER ${USER_UID}:${USER_GID} - -WORKDIR / - -ENTRYPOINT ["tini", "--", "/usr/local/bin/entrypoint.sh"] -CMD ["tail", "-f", "/dev/null"] - -############################################################################### -# Stage: dev -# devcell-dev profile: Modules 2.0 default seed (scraping + infra) — ~3 GB. -# Patchright stealth browser + IaC MCPs. Smallest stack that demos value in -# session 1. Bumped to default in `cell init` post-Modules-2.0. -############################################################################### -FROM core AS dev - -ARG USER_UID=1000 -ARG USER_GID=1000 -COPY --chown=${USER_UID}:${USER_GID} nixhome/ /opt/nixhome/ -RUN ARCH=$(uname -m) && \ - [ "$ARCH" = "aarch64" ] && ARCH_SUFFIX="-aarch64" || ARCH_SUFFIX="" && \ - home-manager switch --flake "/opt/nixhome#devcell-dev${ARCH_SUFFIX}" && \ - cd "$HOME" && MISE_DATA_DIR=/opt/mise MISE_YES=1 mise install && \ - for tool_dir in /opt/mise/installs/*/; do \ - tool=$(basename "$tool_dir"); \ - version_dir=$(ls -1d "${tool_dir}"*/ 2>/dev/null | head -1); \ - if [ -n "$version_dir" ]; then ln -sfT "$version_dir" "/opt/mise/$tool"; fi; \ - done && \ - mkdir -p /opt/devcell/.local/share/mise && \ - ln -sfn /opt/mise/installs /opt/devcell/.local/share/mise/installs && \ - MISE_DATA_DIR=/opt/devcell/.local/share/mise mise reshim && \ - { nix-collect-garbage -d; nix-store --optimise; true; } - -ENV DEVCELL_PROFILE=devcell-dev - -############################################################################### -# Stage: go -# devcell-go profile: Go toolchain + language-specific tools only. -############################################################################### -FROM core AS go - -ARG USER_UID=1000 -ARG USER_GID=1000 -COPY --chown=${USER_UID}:${USER_GID} nixhome/ /opt/nixhome/ -RUN ARCH=$(uname -m) && \ - [ "$ARCH" = "aarch64" ] && ARCH_SUFFIX="-aarch64" || ARCH_SUFFIX="" && \ - home-manager switch --flake "/opt/nixhome#devcell-go${ARCH_SUFFIX}" && \ - cd "$HOME" && MISE_DATA_DIR=/opt/mise MISE_YES=1 mise install && \ - for tool_dir in /opt/mise/installs/*/; do \ - tool=$(basename "$tool_dir"); \ - version_dir=$(ls -1d "${tool_dir}"*/ 2>/dev/null | head -1); \ - if [ -n "$version_dir" ]; then ln -sfT "$version_dir" "/opt/mise/$tool"; fi; \ - done && \ - mkdir -p /opt/devcell/.local/share/mise && \ - ln -sfn /opt/mise/installs /opt/devcell/.local/share/mise/installs && \ - MISE_DATA_DIR=/opt/devcell/.local/share/mise mise reshim && \ - { nix-collect-garbage -d; nix-store --optimise; true; } - -ENV DEVCELL_PROFILE=devcell-go - -############################################################################### -# Stage: node -# devcell-node profile: Node.js + npm project tools only. -############################################################################### -FROM core AS node - -ARG USER_UID=1000 -ARG USER_GID=1000 -COPY --chown=${USER_UID}:${USER_GID} nixhome/ /opt/nixhome/ -RUN ARCH=$(uname -m) && \ - [ "$ARCH" = "aarch64" ] && ARCH_SUFFIX="-aarch64" || ARCH_SUFFIX="" && \ - home-manager switch --flake "/opt/nixhome#devcell-node${ARCH_SUFFIX}" && \ - cd "$HOME" && MISE_DATA_DIR=/opt/mise MISE_YES=1 mise install && \ - for tool_dir in /opt/mise/installs/*/; do \ - tool=$(basename "$tool_dir"); \ - version_dir=$(ls -1d "${tool_dir}"*/ 2>/dev/null | head -1); \ - if [ -n "$version_dir" ]; then ln -sfT "$version_dir" "/opt/mise/$tool"; fi; \ - done && \ - mkdir -p /opt/devcell/.local/share/mise && \ - ln -sfn /opt/mise/installs /opt/devcell/.local/share/mise/installs && \ - MISE_DATA_DIR=/opt/devcell/.local/share/mise mise reshim && \ - { nix-collect-garbage -d; nix-store --optimise; true; } - -ENV DEVCELL_PROFILE=devcell-node - -############################################################################### -# Stage: python -# devcell-python profile: Python3 + uv + Playwright chromium. -############################################################################### -FROM core AS python - -ARG USER_UID=1000 -ARG USER_GID=1000 -COPY --chown=${USER_UID}:${USER_GID} nixhome/ /opt/nixhome/ -RUN ARCH=$(uname -m) && \ - [ "$ARCH" = "aarch64" ] && ARCH_SUFFIX="-aarch64" || ARCH_SUFFIX="" && \ - home-manager switch --flake "/opt/nixhome#devcell-python${ARCH_SUFFIX}" && \ - { nix-collect-garbage -d; nix-store --optimise; true; } - -ENV DEVCELL_PROFILE=devcell-python - -############################################################################### -# Stage: electronics -# devcell-electronics profile: Build tools + KiCad, ngspice, libspnav, poppler. -############################################################################### -FROM core AS electronics - -ARG USER_UID=1000 -ARG USER_GID=1000 -COPY --chown=${USER_UID}:${USER_GID} nixhome/ /opt/nixhome/ -RUN ARCH=$(uname -m) && \ - [ "$ARCH" = "aarch64" ] && ARCH_SUFFIX="-aarch64" || ARCH_SUFFIX="" && \ - home-manager switch --flake "/opt/nixhome#devcell-electronics${ARCH_SUFFIX}" && \ - { nix-collect-garbage -d; nix-store --optimise; true; } - -ENV DEVCELL_PROFILE=devcell-electronics - -############################################################################### -# Stage: fullstack -# devcell-fullstack profile: All language tools (Go, Node, Python, web). -############################################################################### -FROM core AS fullstack - -ARG USER_UID=1000 -ARG USER_GID=1000 -COPY --chown=${USER_UID}:${USER_GID} nixhome/ /opt/nixhome/ -RUN ARCH=$(uname -m) && \ - [ "$ARCH" = "aarch64" ] && ARCH_SUFFIX="-aarch64" || ARCH_SUFFIX="" && \ - home-manager switch --flake "/opt/nixhome#devcell-fullstack${ARCH_SUFFIX}" && \ - cd "$HOME" && MISE_DATA_DIR=/opt/mise MISE_YES=1 mise install && \ - for tool_dir in /opt/mise/installs/*/; do \ - tool=$(basename "$tool_dir"); \ - version_dir=$(ls -1d "${tool_dir}"*/ 2>/dev/null | head -1); \ - if [ -n "$version_dir" ]; then ln -sfT "$version_dir" "/opt/mise/$tool"; fi; \ - done && \ - test -x /opt/mise/node/bin/npm || { echo "ERROR: npm not found at /opt/mise/node/bin/npm"; ls -la /opt/mise/ 2>/dev/null; exit 1; } && \ - mkdir -p /opt/devcell/.local/share/mise && \ - ln -sfn /opt/mise/installs /opt/devcell/.local/share/mise/installs && \ - MISE_DATA_DIR=/opt/devcell/.local/share/mise mise reshim && \ - { nix-collect-garbage -d; nix-store --optimise; true; } - -ENV DEVCELL_PROFILE=devcell-fullstack -# Add mise-installed tool bins to PATH (node/npm, go, etc.) via stable symlinks -ENV PATH="/opt/mise/node/bin:/opt/mise/go/bin:${PATH}" - - -############################################################################### -# Stage: nix-cache -# Donor stage for pre-seeding /nix/store from a previous build. -# CI sets NIX_CACHE_IMAGE to the last successful ultimate image; locally it -# defaults to debian:trixie-slim (empty /nix/store — no pre-seeding, full download). -# Run the Genesis workflow to create the seed image for first-time setup. -############################################################################### -FROM ${NIX_CACHE_IMAGE} AS nix-cache -RUN mkdir -p /nix/store /nix/var/nix - -############################################################################### -# Stage: ultimate -# devcell-ultimate: fullstack + desktop + KiCad, ngspice, libspnav, poppler. -# Built directly from core (not fullstack) — single home-manager switch is -# faster than two sequential switches with garbage collection in between. -############################################################################### -FROM core AS ultimate - -ARG USER_UID=1000 -ARG USER_GID=1000 -COPY --chown=${USER_UID}:${USER_GID} nixhome/ /opt/nixhome/ -# Pre-seed nix store + DB from previous build via mount (no extra layer). -# Both /nix/store (paths) and /nix/var/nix (SQLite DB) are needed — without -# the DB, nix doesn't recognize pre-seeded paths and re-downloads everything. -# On cache hit: home-manager downloads only the delta (~30s vs ~15min). -# On cache miss (busybox fallback): empty /nix/store, full download. -RUN --mount=from=nix-cache,source=/nix/store,target=/tmp/nix-cache \ - --mount=from=nix-cache,source=/nix/var/nix,target=/tmp/nix-var-cache \ - cp -a /tmp/nix-cache/. /nix/store/ 2>/dev/null || true && \ - cp -a /tmp/nix-var-cache/. /nix/var/nix/ 2>/dev/null || true && \ - ARCH=$(uname -m) && \ - [ "$ARCH" = "aarch64" ] && ARCH_SUFFIX="-aarch64" || ARCH_SUFFIX="" && \ - home-manager switch --flake "/opt/nixhome#devcell-ultimate${ARCH_SUFFIX}" && \ - cd "$HOME" && MISE_DATA_DIR=/opt/mise MISE_YES=1 mise install && \ - for tool_dir in /opt/mise/installs/*/; do \ - tool=$(basename "$tool_dir"); \ - version_dir=$(ls -1d "${tool_dir}"*/ 2>/dev/null | head -1); \ - if [ -n "$version_dir" ]; then ln -sfT "$version_dir" "/opt/mise/$tool"; fi; \ - done && \ - test -x /opt/mise/node/bin/npm || { echo "ERROR: npm not found at /opt/mise/node/bin/npm"; ls -la /opt/mise/ 2>/dev/null; exit 1; } && \ - mkdir -p /opt/devcell/.local/share/mise && \ - ln -sfn /opt/mise/installs /opt/devcell/.local/share/mise/installs && \ - MISE_DATA_DIR=/opt/devcell/.local/share/mise mise reshim && \ - { nix-collect-garbage -d; nix-store --optimise; \ - rm -rf /nix/store/*-nixpkgs/nixpkgs 2>/dev/null; \ - true; } - -ENV DEVCELL_PROFILE=devcell-ultimate -ENV DEVCELL_GUI_ENABLED=true -# Add mise-installed tool bins to PATH (node/npm, go, etc.) via stable symlinks -ENV PATH="/opt/mise/node/bin:/opt/mise/go/bin:${PATH}" diff --git a/images/Vagrantfile.macOS b/images/Vagrantfile.macOS deleted file mode 100644 index 2b91f9c..0000000 --- a/images/Vagrantfile.macOS +++ /dev/null @@ -1,55 +0,0 @@ -# -*- mode: ruby -*- -# frozen_string_literal: true -# -# images/Vagrantfile.macOS — base box builder for devcell macOS Vagrant boxes -# -# PURPOSE: Install Nix into a manually-created UTM macOS VM before packaging -# it as a reusable Vagrant box. Used by `cell init --macos` Phase 4. -# -# USAGE (via cell init --macos — do not run manually): -# vagrant provision --provision-with nix-install -# -# The VM must already be running and accessible via SSH at the configured -# hostname. It is NOT started via `vagrant up`. - -VM_SSH_HOST = ENV["MACOS_SSH_HOST"] || "vagrant-macos.local" - -Vagrant.configure("2") do |config| - # Box placeholder — VM is pre-existing and running; vagrant up is never called. - config.vm.box = "dummy" - - # SSH directly to the running macOS VM via mDNS hostname - config.ssh.host = VM_SSH_HOST - config.ssh.port = 22 - config.ssh.username = "vagrant" - config.ssh.insert_key = false - config.ssh.private_key_path = [ - File.join(ENV["HOME"], ".vagrant.d", "insecure_private_keys", "vagrant.key.ed25519"), - File.join(ENV["HOME"], ".vagrant.d", "insecure_private_keys", "vagrant.key.rsa"), - ] - - # Disable all synced folders — box builder only needs SSH access - config.vm.synced_folder ".", "/vagrant", disabled: true - - # Disable default SSH port forwarding (unsupported by Apple Virtualization) - config.vm.network "forwarded_port", id: "ssh", guest: 22, host: 2222, disabled: true - - config.vm.provider :utm do |utm| - utm.check_guest_additions = false - utm.skip_directory_share_mode = true - end - - # Install Nix via Determinate Systems installer (non-interactive, macOS-compatible) - config.vm.provision "shell", name: "nix-install", privileged: false, inline: <<~NIX_INSTALL - set -euo pipefail - if command -v nix >/dev/null 2>&1; then - echo "Nix already installed: $(nix --version)" - exit 0 - fi - echo "Installing Nix via Determinate Systems..." - curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | \ - sh -s -- install --no-confirm - . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh - echo "Nix $(nix --version) installed successfully." - NIX_INSTALL -end diff --git a/images/package-lock.json b/images/package-lock.json deleted file mode 100644 index 32fb1b1..0000000 --- a/images/package-lock.json +++ /dev/null @@ -1,9383 +0,0 @@ -{ - "name": "devcell-tools", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "devcell-tools", - "version": "1.0.0", - "dependencies": { - "@slidev/cli": "^52.11.0" - } - }, - "node_modules/@antfu/install-pkg": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", - "integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==", - "license": "MIT", - "dependencies": { - "package-manager-detector": "^1.3.0", - "tinyexec": "^1.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/@antfu/ni": { - "version": "28.3.0", - "resolved": "https://registry.npmjs.org/@antfu/ni/-/ni-28.3.0.tgz", - "integrity": "sha512-JbRijiCNAGcQcyPfV0EXOJYwV27e/srXfTvETqzbbh4jzHBV2pDYiBz8rj5SyzX27aTbCK+qXR3x6g2WKokcrA==", - "license": "MIT", - "dependencies": { - "ansis": "^4.2.0", - "fzf": "^0.5.2", - "package-manager-detector": "^1.6.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15" - }, - "bin": { - "na": "bin/na.mjs", - "nci": "bin/nci.mjs", - "nd": "bin/nd.mjs", - "ni": "bin/ni.mjs", - "nlx": "bin/nlx.mjs", - "nr": "bin/nr.mjs", - "nun": "bin/nun.mjs", - "nup": "bin/nup.mjs" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@antfu/utils": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/@antfu/utils/-/utils-9.3.0.tgz", - "integrity": "sha512-9hFT4RauhcUzqOE4f1+frMKLZrgNog5b06I7VmZQV1BkvwvqrbC8EBZf3L1eEL2AKb6rNKjER0sEvJiSP1FXEA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", - "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.27.3" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", - "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==", - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-member-expression-to-functions": "^7.28.5", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/helper-replace-supers": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/traverse": "^7.28.6", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", - "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", - "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-replace-supers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", - "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", - "license": "MIT", - "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.28.5", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", - "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", - "license": "MIT", - "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", - "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", - "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-typescript": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz", - "integrity": "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==", - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/plugin-syntax-typescript": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@braintree/sanitize-url": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", - "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", - "license": "MIT" - }, - "node_modules/@chevrotain/cst-dts-gen": { - "version": "11.1.2", - "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.1.2.tgz", - "integrity": "sha512-XTsjvDVB5nDZBQB8o0o/0ozNelQtn2KrUVteIHSlPd2VAV2utEb6JzyCJaJ8tGxACR4RiBNWy5uYUHX2eji88Q==", - "license": "Apache-2.0", - "dependencies": { - "@chevrotain/gast": "11.1.2", - "@chevrotain/types": "11.1.2", - "lodash-es": "4.17.23" - } - }, - "node_modules/@chevrotain/gast": { - "version": "11.1.2", - "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.1.2.tgz", - "integrity": "sha512-Z9zfXR5jNZb1Hlsd/p+4XWeUFugrHirq36bKzPWDSIacV+GPSVXdk+ahVWZTwjhNwofAWg/sZg58fyucKSQx5g==", - "license": "Apache-2.0", - "dependencies": { - "@chevrotain/types": "11.1.2", - "lodash-es": "4.17.23" - } - }, - "node_modules/@chevrotain/regexp-to-ast": { - "version": "11.1.2", - "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.1.2.tgz", - "integrity": "sha512-nMU3Uj8naWer7xpZTYJdxbAs6RIv/dxYzkYU8GSwgUtcAAlzjcPfX1w+RKRcYG8POlzMeayOQ/znfwxEGo5ulw==", - "license": "Apache-2.0" - }, - "node_modules/@chevrotain/types": { - "version": "11.1.2", - "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz", - "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==", - "license": "Apache-2.0" - }, - "node_modules/@chevrotain/utils": { - "version": "11.1.2", - "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.1.2.tgz", - "integrity": "sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==", - "license": "Apache-2.0" - }, - "node_modules/@comark/markdown-it": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@comark/markdown-it/-/markdown-it-0.3.2.tgz", - "integrity": "sha512-h+zwwsqr2zLBajKqdzLiLjhccO8+euTAKiBRLJcJvaMGma4yPCrYfWM0dgO0AFz3gK030cf5I5qBN0a3C3jzpQ==", - "license": "MIT", - "dependencies": { - "js-yaml": "^4.1.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - }, - "peerDependencies": { - "@types/markdown-it": "*", - "markdown-it": "^14.0.0" - } - }, - "node_modules/@drauu/core": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@drauu/core/-/core-1.0.0.tgz", - "integrity": "sha512-r1fPyuKaGuNHc8vxRFUT8LxqWjJ3nx+U+zsHcEOurmJoB7uN+zpFw5kTLInfdfvQZ+qF/ebQjw1AwbGcc1XKsQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/@emnapi/core": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", - "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.1.0", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", - "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", - "cpu": [ - "mips64el" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@floating-ui/core": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", - "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", - "license": "MIT", - "dependencies": { - "@floating-ui/utils": "^0.2.11" - } - }, - "node_modules/@floating-ui/dom": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.1.1.tgz", - "integrity": "sha512-TpIO93+DIujg3g7SykEAGZMDtbJRrmnYRCNYSjJlvIbGhBjRSNTLVbNeDQBrzy9qDgUbiWdc7KA0uZHZ2tJmiw==", - "license": "MIT", - "dependencies": { - "@floating-ui/core": "^1.1.0" - } - }, - "node_modules/@floating-ui/utils": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", - "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", - "license": "MIT" - }, - "node_modules/@iconify-json/carbon": { - "version": "1.2.19", - "resolved": "https://registry.npmjs.org/@iconify-json/carbon/-/carbon-1.2.19.tgz", - "integrity": "sha512-l89XjtEeSA5fxlxPTNSU9AA+rxaz/Dn0X/ux0/3awR+tAayY8iJqWQu3AKxhchfx3LB/fX1Nv3ZppZzrBAt7aA==", - "license": "Apache-2.0", - "dependencies": { - "@iconify/types": "*" - } - }, - "node_modules/@iconify-json/ph": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@iconify-json/ph/-/ph-1.2.2.tgz", - "integrity": "sha512-PgkEZNtqa8hBGjHXQa4pMwZa93hmfu8FUSjs/nv4oUU6yLsgv+gh9nu28Kqi8Fz9CCVu4hj1MZs9/60J57IzFw==", - "license": "MIT", - "dependencies": { - "@iconify/types": "*" - } - }, - "node_modules/@iconify-json/svg-spinners": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@iconify-json/svg-spinners/-/svg-spinners-1.2.4.tgz", - "integrity": "sha512-ayn0pogFPwJA1WFZpDnoq9/hjDxN+keeCMyThaX4d3gSJ3y0mdKUxIA/b1YXWGtY9wVtZmxwcvOIeEieG4+JNg==", - "license": "MIT", - "dependencies": { - "@iconify/types": "*" - } - }, - "node_modules/@iconify/types": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", - "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", - "license": "MIT" - }, - "node_modules/@iconify/utils": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.0.tgz", - "integrity": "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==", - "license": "MIT", - "dependencies": { - "@antfu/install-pkg": "^1.1.0", - "@iconify/types": "^2.0.0", - "mlly": "^1.8.0" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@leichtgewicht/ip-codec": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", - "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", - "license": "MIT" - }, - "node_modules/@lillallol/outline-pdf": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@lillallol/outline-pdf/-/outline-pdf-4.0.0.tgz", - "integrity": "sha512-tILGNyOdI3ukZfU19TNTDVoS0W1nSPlMxCKAm9FPV4OPL786Ur7e1CRLQZWKJP6uaMQsUqSDBCTzISs6lXWdAQ==", - "license": "MIT", - "dependencies": { - "@lillallol/outline-pdf-data-structure": "^1.0.3", - "pdf-lib": "^1.16.0" - } - }, - "node_modules/@lillallol/outline-pdf-data-structure": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@lillallol/outline-pdf-data-structure/-/outline-pdf-data-structure-1.0.3.tgz", - "integrity": "sha512-XlK9dERP2n9afkJ23JyJzpmesLgiOHmhqKuGgeytnT+IVGFdAsYl1wLr2o+byXNAN5fveNbc7CCI6RfBsd5FCw==", - "license": "MIT" - }, - "node_modules/@mdit-vue/plugin-component": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@mdit-vue/plugin-component/-/plugin-component-3.0.2.tgz", - "integrity": "sha512-Fu53MajrZMOAjOIPGMTdTXgHLgGU9KwTqKtYc6WNYtFZNKw04euSfJ/zFg8eBY/2MlciVngkF7Gyc2IL7e8Bsw==", - "license": "MIT", - "dependencies": { - "@types/markdown-it": "^14.1.2", - "markdown-it": "^14.1.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@mdit-vue/plugin-frontmatter": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@mdit-vue/plugin-frontmatter/-/plugin-frontmatter-3.0.2.tgz", - "integrity": "sha512-QKKgIva31YtqHgSAz7S7hRcL7cHXiqdog4wxTfxeQCHo+9IP4Oi5/r1Y5E93nTPccpadDWzAwr3A0F+kAEnsVQ==", - "license": "MIT", - "dependencies": { - "@mdit-vue/types": "3.0.2", - "@types/markdown-it": "^14.1.2", - "gray-matter": "^4.0.3", - "markdown-it": "^14.1.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@mdit-vue/types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@mdit-vue/types/-/types-3.0.2.tgz", - "integrity": "sha512-00aAZ0F0NLik6I6Yba2emGbHLxv+QYrPH00qQ5dFKXlAo1Ll2RHDXwY7nN2WAfrx2pP+WrvSRFTGFCNGdzBDHw==", - "license": "MIT", - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@mermaid-js/parser": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.0.1.tgz", - "integrity": "sha512-opmV19kN1JsK0T6HhhokHpcVkqKpF+x2pPDKKM2ThHtZAB5F4PROopk0amuVYK5qMrIA4erzpNm8gmPNJgMDxQ==", - "license": "MIT", - "dependencies": { - "langium": "^4.0.0" - } - }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", - "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nuxt/kit": { - "version": "3.21.1", - "resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-3.21.1.tgz", - "integrity": "sha512-QORZRjcuTKgo++XP1Pc2c2gqwRydkaExrIRfRI9vFsPA3AzuHVn5Gfmbv1ic8y34e78mr5DMBvJlelUaeOuajg==", - "license": "MIT", - "optional": true, - "dependencies": { - "c12": "^3.3.3", - "consola": "^3.4.2", - "defu": "^6.1.4", - "destr": "^2.0.5", - "errx": "^0.1.0", - "exsolve": "^1.0.8", - "ignore": "^7.0.5", - "jiti": "^2.6.1", - "klona": "^2.0.6", - "knitwork": "^1.3.0", - "mlly": "^1.8.0", - "ohash": "^2.0.11", - "pathe": "^2.0.3", - "pkg-types": "^2.3.0", - "rc9": "^3.0.0", - "scule": "^1.3.0", - "semver": "^7.7.4", - "tinyglobby": "^0.2.15", - "ufo": "^1.6.3", - "unctx": "^2.5.0", - "untyped": "^2.0.0" - }, - "engines": { - "node": ">=18.12.0" - } - }, - "node_modules/@oxc-parser/binding-android-arm-eabi": { - "version": "0.115.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.115.0.tgz", - "integrity": "sha512-VoB2rhgoqgYf64d6Qs5emONQW8ASiTc0xp+aUE4JUhxjX+0pE3gblTYDO0upcN5vt9UlBNmUhAwfSifkfre7nw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-android-arm64": { - "version": "0.115.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.115.0.tgz", - "integrity": "sha512-lWRX75u+gqfB4TF3pWCHuvhaeneAmRl2b2qNBcl4S6yJ0HtnT4VXOMEZrq747i4Zby1ZTxj6mtOe678Bg8gRLw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-darwin-arm64": { - "version": "0.115.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.115.0.tgz", - "integrity": "sha512-ii/oOZjfGY1aszXTy29Z5DRyCEnBOrAXDVCvfdfXFQsOZlbbOa7NMHD7D+06YFe5qdxfmbWAYv4yn6QJi/0d2g==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-darwin-x64": { - "version": "0.115.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.115.0.tgz", - "integrity": "sha512-R/sW/p8l77wglbjpMcF+h/3rWbp9zk1mRP3U14mxTYIC2k3m+aLBpXXgk2zksqf9qKk5mcc4GIYsuCn9l8TgDg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-freebsd-x64": { - "version": "0.115.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.115.0.tgz", - "integrity": "sha512-CSJ5ldNm9wIGGkhaIJeGmxRMZbgxThRN+X1ufYQQUNi5jZDV/U3C2QDMywpP93fczNBj961hXtcUPO/oVGq4Pw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": { - "version": "0.115.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.115.0.tgz", - "integrity": "sha512-uWFwssE5dHfQ8lH+ktrsD9JA49+Qa0gtxZHUs62z1e91NgGz6O7jefHGI6aygNyKNS45pnnBSDSP/zV977MsOQ==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-arm-musleabihf": { - "version": "0.115.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.115.0.tgz", - "integrity": "sha512-fZbqt8y/sKQ+v6bBCuv/mYYFoC0+fZI3mGDDEemmDOhT78+aUs2+4ZMdbd2btlXmnLaScl37r8IRbhnok5Ka9w==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-arm64-gnu": { - "version": "0.115.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.115.0.tgz", - "integrity": "sha512-1ej/MjuTY9tJEunU/hUPIFmgH5PqgMQoRjNOvOkibtJ3Zqlw/+Lc+HGHDNET8sjbgIkWzdhX+p4J96A5CPdbag==", - "cpu": [ - "arm64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-arm64-musl": { - "version": "0.115.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.115.0.tgz", - "integrity": "sha512-HjsZbJPH9mMd4swJRywVMsDZsJX0hyKb1iNHo5ijRl5yhtbO3lj7ImSrrL1oZ1VEg0te4iKmDGGz/6YPLd1G8w==", - "cpu": [ - "arm64" - ], - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-ppc64-gnu": { - "version": "0.115.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.115.0.tgz", - "integrity": "sha512-zhhePoBrd7kQx3oClX/W6NldsuCbuMqaN9rRsY+6/WoorAb4j490PG/FjqgAXscWp2uSW2WV9L+ksn0wHrvsrg==", - "cpu": [ - "ppc64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-riscv64-gnu": { - "version": "0.115.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.115.0.tgz", - "integrity": "sha512-t/IRojvUE9XrKu+/H1b8YINug+7Q6FLls5rsm2lxB5mnS8GN/eYAYrPgHkcg9/1SueRDSzGpDYu3lGWTObk1zw==", - "cpu": [ - "riscv64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-riscv64-musl": { - "version": "0.115.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.115.0.tgz", - "integrity": "sha512-79jBHSSh/YpQRAmvYoaCfpyToRbJ/HBrdB7hxK2ku2JMehjopTVo+xMJss/RV7/ZYqeezgjvKDQzapJbgcjVZA==", - "cpu": [ - "riscv64" - ], - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-s390x-gnu": { - "version": "0.115.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.115.0.tgz", - "integrity": "sha512-nA1TpxkhNTIOMMyiSSsa7XIVJVoOU/SsVrHIz3gHvWweB5PHCQfO7w+Lb2EP0lBWokv7HtA/KbF7aLDoXzmuMw==", - "cpu": [ - "s390x" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-x64-gnu": { - "version": "0.115.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.115.0.tgz", - "integrity": "sha512-9iVX789DoC3SaOOG+X6NcF/tVChgLp2vcHffzOC2/Z1JTPlz6bMG2ogvcW6/9s0BG2qvhNQImd+gbWYeQbOwVw==", - "cpu": [ - "x64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-x64-musl": { - "version": "0.115.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.115.0.tgz", - "integrity": "sha512-RmQmk+mjCB0nMNfEYhaCxwofLo1Z95ebHw1AGvRiWGCd4zhCNOyskgCbMogIcQzSB3SuEKWgkssyaiQYVAA4hQ==", - "cpu": [ - "x64" - ], - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-openharmony-arm64": { - "version": "0.115.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.115.0.tgz", - "integrity": "sha512-viigraWWQhhDvX5aGq+wrQq58k00Xq3MHz/0R4AFMxGlZ8ogNonpEfNc73Q5Ly87Z6sU9BvxEdG0dnYTfVnmew==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-wasm32-wasi": { - "version": "0.115.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.115.0.tgz", - "integrity": "sha512-IzGCrMwXhpb4kTXy/8lnqqqwjI7eOvy+r9AhVw+hsr8t1ecBBEHprcNy0aKatFHN6hsX7UMHHQmBAQjVvL/p1A==", - "cpu": [ - "wasm32" - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@oxc-parser/binding-win32-arm64-msvc": { - "version": "0.115.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.115.0.tgz", - "integrity": "sha512-/ym+Absk/TLFvbhh3se9XYuI1D7BrUVHw4RaG/2dmWKgBenrZHaJsgnRb7NJtaOyjEOLIPtULx1wDdVL0SX2eg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-win32-ia32-msvc": { - "version": "0.115.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.115.0.tgz", - "integrity": "sha512-AQSZjIR+b+Te7uaO/hGTMjT8/oxlYrvKrOTi4KTHF/O6osjHEatUQ3y6ZW2+8+lJxy20zIcGz6iQFmFq/qDKkg==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-win32-x64-msvc": { - "version": "0.115.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.115.0.tgz", - "integrity": "sha512-oxUl82N+fIO9jIaXPph8SPPHQXrA08BHokBBJW8ct9F/x6o6bZE6eUAhUtWajbtvFhL8UYcCWRMba+kww6MBlA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-project/types": { - "version": "0.115.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.115.0.tgz", - "integrity": "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "node_modules/@pdf-lib/standard-fonts": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", - "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", - "license": "MIT", - "dependencies": { - "pako": "^1.0.6" - } - }, - "node_modules/@pdf-lib/upng": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz", - "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==", - "license": "MIT", - "dependencies": { - "pako": "^1.0.10" - } - }, - "node_modules/@polka/url": { - "version": "1.0.0-next.29", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", - "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", - "license": "MIT" - }, - "node_modules/@quansync/fs": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@quansync/fs/-/fs-1.0.0.tgz", - "integrity": "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==", - "license": "MIT", - "dependencies": { - "quansync": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sxzz" - } - }, - "node_modules/@quansync/fs/node_modules/quansync": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/quansync/-/quansync-1.0.0.tgz", - "integrity": "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/antfu" - }, - { - "type": "individual", - "url": "https://github.com/sponsors/sxzz" - } - ], - "license": "MIT" - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.2", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", - "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", - "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", - "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", - "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", - "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", - "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", - "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", - "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", - "cpu": [ - "arm" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", - "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", - "cpu": [ - "arm" - ], - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", - "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", - "cpu": [ - "arm64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", - "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", - "cpu": [ - "arm64" - ], - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", - "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", - "cpu": [ - "loong64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", - "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", - "cpu": [ - "loong64" - ], - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", - "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", - "cpu": [ - "ppc64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", - "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", - "cpu": [ - "ppc64" - ], - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", - "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", - "cpu": [ - "riscv64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", - "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", - "cpu": [ - "riscv64" - ], - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", - "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", - "cpu": [ - "s390x" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", - "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", - "cpu": [ - "x64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", - "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", - "cpu": [ - "x64" - ], - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", - "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", - "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", - "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", - "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", - "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", - "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@shikijs/core": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.0.2.tgz", - "integrity": "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw==", - "license": "MIT", - "dependencies": { - "@shikijs/primitive": "4.0.2", - "@shikijs/types": "4.0.2", - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4", - "hast-util-to-html": "^9.0.5" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@shikijs/engine-javascript": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.0.2.tgz", - "integrity": "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag==", - "license": "MIT", - "dependencies": { - "@shikijs/types": "4.0.2", - "@shikijs/vscode-textmate": "^10.0.2", - "oniguruma-to-es": "^4.3.4" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@shikijs/markdown-it": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/markdown-it/-/markdown-it-4.0.2.tgz", - "integrity": "sha512-7DDEhknj/mXTN7ME8CjKWBv5O/4YgOiJBZLgs/NbUFMC7Ik1x/VEhaK+aBjX60bJdok0E2mxEYan/GzJ2xRx+A==", - "license": "MIT", - "dependencies": { - "markdown-it": "^14.1.1", - "shiki": "4.0.2" - }, - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "markdown-it-async": "^2.2.0" - }, - "peerDependenciesMeta": { - "markdown-it-async": { - "optional": true - } - } - }, - "node_modules/@shikijs/markdown-it/node_modules/@shikijs/engine-oniguruma": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.0.2.tgz", - "integrity": "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg==", - "license": "MIT", - "dependencies": { - "@shikijs/types": "4.0.2", - "@shikijs/vscode-textmate": "^10.0.2" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@shikijs/markdown-it/node_modules/@shikijs/langs": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.0.2.tgz", - "integrity": "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg==", - "license": "MIT", - "dependencies": { - "@shikijs/types": "4.0.2" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@shikijs/markdown-it/node_modules/@shikijs/themes": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.0.2.tgz", - "integrity": "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA==", - "license": "MIT", - "dependencies": { - "@shikijs/types": "4.0.2" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@shikijs/markdown-it/node_modules/shiki": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.0.2.tgz", - "integrity": "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==", - "license": "MIT", - "dependencies": { - "@shikijs/core": "4.0.2", - "@shikijs/engine-javascript": "4.0.2", - "@shikijs/engine-oniguruma": "4.0.2", - "@shikijs/langs": "4.0.2", - "@shikijs/themes": "4.0.2", - "@shikijs/types": "4.0.2", - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@shikijs/monaco": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/monaco/-/monaco-4.0.2.tgz", - "integrity": "sha512-yA49DPAjDyj9D8yxyr1S7qjcT1TVv6BqhZ+sXccwqcdp83RuncYOCUkJ1rjqAu3NA8YDc2wdesD+/js5pHJdqg==", - "license": "MIT", - "dependencies": { - "@shikijs/core": "4.0.2", - "@shikijs/types": "4.0.2", - "@shikijs/vscode-textmate": "^10.0.2" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@shikijs/primitive": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.0.2.tgz", - "integrity": "sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw==", - "license": "MIT", - "dependencies": { - "@shikijs/types": "4.0.2", - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@shikijs/twoslash": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/twoslash/-/twoslash-4.0.2.tgz", - "integrity": "sha512-yHRudhirlMxOwDO6Q4OFU9hJMvUqNkY8hwtUfbaSEoG7A2cYicdO4c8fdDaDtyJ50HK7I8vTokrkIHTK3DCkLQ==", - "license": "MIT", - "dependencies": { - "@shikijs/core": "4.0.2", - "@shikijs/types": "4.0.2", - "twoslash": "^0.3.6" - }, - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "typescript": ">=5.5.0" - } - }, - "node_modules/@shikijs/types": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.0.2.tgz", - "integrity": "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg==", - "license": "MIT", - "dependencies": { - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@shikijs/vitepress-twoslash": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/vitepress-twoslash/-/vitepress-twoslash-4.0.2.tgz", - "integrity": "sha512-Bk01fAYDDiTffRPLHNJdNlYwzExXIVcrHUVNciD931SMlKZArvteKib6mM3mWAUhcy78RW1llT3fczjKIgQHBA==", - "license": "MIT", - "dependencies": { - "@shikijs/twoslash": "4.0.2", - "floating-vue": "^5.2.2", - "lz-string": "^1.5.0", - "magic-string": "^0.30.21", - "markdown-it": "^14.1.1", - "mdast-util-from-markdown": "^2.0.3", - "mdast-util-gfm": "^3.1.0", - "mdast-util-to-hast": "^13.2.1", - "ohash": "^2.0.11", - "shiki": "4.0.2", - "twoslash": "^0.3.6", - "twoslash-vue": "^0.3.6", - "vue": "^3.5.29" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@shikijs/vitepress-twoslash/node_modules/@shikijs/engine-oniguruma": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.0.2.tgz", - "integrity": "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg==", - "license": "MIT", - "dependencies": { - "@shikijs/types": "4.0.2", - "@shikijs/vscode-textmate": "^10.0.2" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@shikijs/vitepress-twoslash/node_modules/@shikijs/langs": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.0.2.tgz", - "integrity": "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg==", - "license": "MIT", - "dependencies": { - "@shikijs/types": "4.0.2" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@shikijs/vitepress-twoslash/node_modules/@shikijs/themes": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.0.2.tgz", - "integrity": "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA==", - "license": "MIT", - "dependencies": { - "@shikijs/types": "4.0.2" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@shikijs/vitepress-twoslash/node_modules/shiki": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.0.2.tgz", - "integrity": "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==", - "license": "MIT", - "dependencies": { - "@shikijs/core": "4.0.2", - "@shikijs/engine-javascript": "4.0.2", - "@shikijs/engine-oniguruma": "4.0.2", - "@shikijs/langs": "4.0.2", - "@shikijs/themes": "4.0.2", - "@shikijs/types": "4.0.2", - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@shikijs/vscode-textmate": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", - "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", - "license": "MIT" - }, - "node_modules/@slidev/cli": { - "version": "52.14.1", - "resolved": "https://registry.npmjs.org/@slidev/cli/-/cli-52.14.1.tgz", - "integrity": "sha512-+MGK+9556M+XRXZ7Ut8uU4AIlHU9k+GtQWGGmpmBY/SnWvW/sE5c5ZR9uJ1gmne6gZ3rh5GLm6KhnWPq9+OmTg==", - "license": "MIT", - "dependencies": { - "@antfu/ni": "^28.2.0", - "@antfu/utils": "^9.3.0", - "@comark/markdown-it": "^0.3.0", - "@iconify-json/carbon": "^1.2.19", - "@iconify-json/ph": "^1.2.2", - "@iconify-json/svg-spinners": "^1.2.4", - "@lillallol/outline-pdf": "^4.0.0", - "@shikijs/markdown-it": "^4.0.1", - "@shikijs/twoslash": "^4.0.1", - "@shikijs/vitepress-twoslash": "^4.0.1", - "@slidev/client": "52.14.1", - "@slidev/parser": "52.14.1", - "@slidev/types": "52.14.1", - "@unocss/extractor-mdc": "^66.6.3", - "@unocss/reset": "^66.6.3", - "@vitejs/plugin-vue": "^6.0.4", - "@vitejs/plugin-vue-jsx": "^5.1.4", - "ansis": "^4.2.0", - "chokidar": "^5.0.0", - "cli-progress": "^3.12.0", - "connect": "^3.7.0", - "fast-deep-equal": "^3.1.3", - "fast-glob": "^3.3.3", - "get-port-please": "^3.2.0", - "global-directory": "^5.0.0", - "htmlparser2": "^10.1.0", - "is-installed-globally": "^1.0.0", - "jiti": "^2.6.1", - "katex": "^0.16.33", - "local-pkg": "^1.1.2", - "lz-string": "^1.5.0", - "magic-string": "^0.30.21", - "magic-string-stack": "^1.1.0", - "markdown-exit": "^1.0.0-beta.8", - "markdown-it-footnote": "^4.0.0", - "mlly": "^1.8.0", - "monaco-editor": "^0.55.1", - "obug": "^2.1.1", - "open": "^11.0.0", - "pdf-lib": "^1.17.1", - "picomatch": "^4.0.3", - "plantuml-encoder": "^1.4.0", - "postcss-nested": "^7.0.2", - "pptxgenjs": "^4.0.1", - "prompts": "^2.4.2", - "public-ip": "^8.0.0", - "resolve-from": "^5.0.0", - "resolve-global": "^2.0.0", - "semver": "^7.7.4", - "shiki": "^4.0.1", - "shiki-magic-move": "^1.2.1", - "sirv": "^3.0.2", - "source-map-js": "^1.2.1", - "typescript": "^5.9.3", - "unhead": "^2.1.10", - "unocss": "^66.6.2", - "unplugin-icons": "^23.0.1", - "unplugin-vue-components": "^31.0.0", - "unplugin-vue-markdown": "^30.0.0", - "untun": "^0.1.3", - "uqr": "^0.1.2", - "vite": "^7.3.1", - "vite-plugin-inspect": "^11.3.3", - "vite-plugin-remote-assets": "^2.1.0", - "vite-plugin-static-copy": "^3.2.0", - "vite-plugin-vue-server-ref": "^1.0.0", - "vitefu": "^1.1.2", - "vue": "^3.5.29", - "yaml": "^2.8.2", - "yargs": "^18.0.0" - }, - "bin": { - "slidev": "bin/slidev.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - }, - "peerDependencies": { - "playwright-chromium": "^1.10.0" - }, - "peerDependenciesMeta": { - "playwright-chromium": { - "optional": true - } - } - }, - "node_modules/@slidev/cli/node_modules/@shikijs/engine-oniguruma": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.0.2.tgz", - "integrity": "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg==", - "license": "MIT", - "dependencies": { - "@shikijs/types": "4.0.2", - "@shikijs/vscode-textmate": "^10.0.2" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@slidev/cli/node_modules/@shikijs/langs": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.0.2.tgz", - "integrity": "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg==", - "license": "MIT", - "dependencies": { - "@shikijs/types": "4.0.2" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@slidev/cli/node_modules/@shikijs/themes": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.0.2.tgz", - "integrity": "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA==", - "license": "MIT", - "dependencies": { - "@shikijs/types": "4.0.2" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@slidev/cli/node_modules/shiki": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.0.2.tgz", - "integrity": "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==", - "license": "MIT", - "dependencies": { - "@shikijs/core": "4.0.2", - "@shikijs/engine-javascript": "4.0.2", - "@shikijs/engine-oniguruma": "4.0.2", - "@shikijs/langs": "4.0.2", - "@shikijs/themes": "4.0.2", - "@shikijs/types": "4.0.2", - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@slidev/client": { - "version": "52.14.1", - "resolved": "https://registry.npmjs.org/@slidev/client/-/client-52.14.1.tgz", - "integrity": "sha512-aljFqR3wNhaqcOxjm3jo0DyyTnD7H8BOTwnAFFIsb4j+p3ufMh/rx39GfQF2j+ABy4K3o+L/Ue+QsA2nLNmIHg==", - "license": "MIT", - "dependencies": { - "@antfu/utils": "^9.3.0", - "@iconify-json/carbon": "^1.2.19", - "@iconify-json/ph": "^1.2.2", - "@iconify-json/svg-spinners": "^1.2.4", - "@shikijs/engine-javascript": "^4.0.1", - "@shikijs/monaco": "^4.0.1", - "@shikijs/vitepress-twoslash": "^4.0.1", - "@slidev/parser": "52.14.1", - "@slidev/rough-notation": "^0.1.0", - "@slidev/types": "52.14.1", - "@typescript/ata": "^0.9.8", - "@unhead/vue": "^2.1.10", - "@unocss/extractor-mdc": "^66.6.3", - "@unocss/preset-mini": "^66.6.3", - "@unocss/reset": "^66.6.3", - "@vueuse/core": "^14.2.1", - "@vueuse/math": "^14.2.1", - "@vueuse/motion": "^3.0.3", - "ansis": "^4.2.0", - "drauu": "^1.0.0", - "file-saver": "^2.0.5", - "floating-vue": "^5.2.2", - "fuse.js": "^7.1.0", - "katex": "^0.16.33", - "lz-string": "^1.5.0", - "mermaid": "^11.12.3", - "monaco-editor": "^0.55.1", - "nanotar": "^0.3.0", - "pptxgenjs": "^4.0.1", - "recordrtc": "^5.6.2", - "shiki": "^4.0.1", - "shiki-magic-move": "^1.2.1", - "typescript": "^5.9.3", - "unocss": "^66.6.2", - "vue": "^3.5.29", - "vue-router": "^5.0.3", - "yaml": "^2.8.2" - }, - "engines": { - "node": ">=18.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/@slidev/client/node_modules/@shikijs/engine-oniguruma": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.0.2.tgz", - "integrity": "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg==", - "license": "MIT", - "dependencies": { - "@shikijs/types": "4.0.2", - "@shikijs/vscode-textmate": "^10.0.2" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@slidev/client/node_modules/@shikijs/langs": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.0.2.tgz", - "integrity": "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg==", - "license": "MIT", - "dependencies": { - "@shikijs/types": "4.0.2" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@slidev/client/node_modules/@shikijs/themes": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.0.2.tgz", - "integrity": "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA==", - "license": "MIT", - "dependencies": { - "@shikijs/types": "4.0.2" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@slidev/client/node_modules/shiki": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.0.2.tgz", - "integrity": "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==", - "license": "MIT", - "dependencies": { - "@shikijs/core": "4.0.2", - "@shikijs/engine-javascript": "4.0.2", - "@shikijs/engine-oniguruma": "4.0.2", - "@shikijs/langs": "4.0.2", - "@shikijs/themes": "4.0.2", - "@shikijs/types": "4.0.2", - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@slidev/parser": { - "version": "52.14.1", - "resolved": "https://registry.npmjs.org/@slidev/parser/-/parser-52.14.1.tgz", - "integrity": "sha512-Y/9aYcyzj5PGAflO0IPBwb3qs7OohfDc4kcHl149hGLceHo9jfZZh20V10NyK/R8IYTRJDgALbEHN/16jSRaqg==", - "license": "MIT", - "dependencies": { - "@antfu/utils": "^9.3.0", - "@slidev/types": "52.14.1", - "yaml": "^2.8.2" - }, - "engines": { - "node": ">=18.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/@slidev/rough-notation": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@slidev/rough-notation/-/rough-notation-0.1.0.tgz", - "integrity": "sha512-a/CbVmjuoO3E4JbUr2HOTsXndbcrdLWOM+ajbSQIY3gmLFzhjeXHGksGcp1NZ08pJjLZyTCxfz1C7v/ltJqycA==", - "license": "MIT", - "dependencies": { - "roughjs": "^4.6.6" - } - }, - "node_modules/@slidev/types": { - "version": "52.14.1", - "resolved": "https://registry.npmjs.org/@slidev/types/-/types-52.14.1.tgz", - "integrity": "sha512-48wp+YRCT8mckFAdu7hGX8DICRrqSpaDNOikQT087jvzCT/D7dr15wA+QTVRJiSLHr5yJVQAlX2lVD/Izhg0FQ==", - "license": "MIT", - "dependencies": { - "@antfu/utils": "^9.3.0", - "@shikijs/markdown-it": "^4.0.1", - "@vitejs/plugin-vue": "^6.0.4", - "@vitejs/plugin-vue-jsx": "^5.1.4", - "katex": "^0.16.33", - "mermaid": "^11.12.3", - "monaco-editor": "^0.55.1", - "shiki": "^4.0.1", - "unocss": "^66.6.2", - "unplugin-icons": "^23.0.1", - "unplugin-vue-markdown": "^30.0.0", - "vite-plugin-inspect": "^11.3.3", - "vite-plugin-remote-assets": "^2.1.0", - "vite-plugin-static-copy": "^3.2.0", - "vite-plugin-vue-server-ref": "^1.0.0", - "vue": "^3.5.29", - "vue-router": "^5.0.3" - }, - "engines": { - "node": ">=18.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/@slidev/types/node_modules/@shikijs/engine-oniguruma": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.0.2.tgz", - "integrity": "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg==", - "license": "MIT", - "dependencies": { - "@shikijs/types": "4.0.2", - "@shikijs/vscode-textmate": "^10.0.2" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@slidev/types/node_modules/@shikijs/langs": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.0.2.tgz", - "integrity": "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg==", - "license": "MIT", - "dependencies": { - "@shikijs/types": "4.0.2" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@slidev/types/node_modules/@shikijs/themes": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.0.2.tgz", - "integrity": "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA==", - "license": "MIT", - "dependencies": { - "@shikijs/types": "4.0.2" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@slidev/types/node_modules/shiki": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.0.2.tgz", - "integrity": "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==", - "license": "MIT", - "dependencies": { - "@shikijs/core": "4.0.2", - "@shikijs/engine-javascript": "4.0.2", - "@shikijs/engine-oniguruma": "4.0.2", - "@shikijs/langs": "4.0.2", - "@shikijs/themes": "4.0.2", - "@shikijs/types": "4.0.2", - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@types/d3": { - "version": "7.4.3", - "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", - "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", - "license": "MIT", - "dependencies": { - "@types/d3-array": "*", - "@types/d3-axis": "*", - "@types/d3-brush": "*", - "@types/d3-chord": "*", - "@types/d3-color": "*", - "@types/d3-contour": "*", - "@types/d3-delaunay": "*", - "@types/d3-dispatch": "*", - "@types/d3-drag": "*", - "@types/d3-dsv": "*", - "@types/d3-ease": "*", - "@types/d3-fetch": "*", - "@types/d3-force": "*", - "@types/d3-format": "*", - "@types/d3-geo": "*", - "@types/d3-hierarchy": "*", - "@types/d3-interpolate": "*", - "@types/d3-path": "*", - "@types/d3-polygon": "*", - "@types/d3-quadtree": "*", - "@types/d3-random": "*", - "@types/d3-scale": "*", - "@types/d3-scale-chromatic": "*", - "@types/d3-selection": "*", - "@types/d3-shape": "*", - "@types/d3-time": "*", - "@types/d3-time-format": "*", - "@types/d3-timer": "*", - "@types/d3-transition": "*", - "@types/d3-zoom": "*" - } - }, - "node_modules/@types/d3-array": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", - "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", - "license": "MIT" - }, - "node_modules/@types/d3-axis": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", - "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", - "license": "MIT", - "dependencies": { - "@types/d3-selection": "*" - } - }, - "node_modules/@types/d3-brush": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", - "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", - "license": "MIT", - "dependencies": { - "@types/d3-selection": "*" - } - }, - "node_modules/@types/d3-chord": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", - "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", - "license": "MIT" - }, - "node_modules/@types/d3-color": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", - "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", - "license": "MIT" - }, - "node_modules/@types/d3-contour": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", - "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", - "license": "MIT", - "dependencies": { - "@types/d3-array": "*", - "@types/geojson": "*" - } - }, - "node_modules/@types/d3-delaunay": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", - "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", - "license": "MIT" - }, - "node_modules/@types/d3-dispatch": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", - "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", - "license": "MIT" - }, - "node_modules/@types/d3-drag": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", - "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", - "license": "MIT", - "dependencies": { - "@types/d3-selection": "*" - } - }, - "node_modules/@types/d3-dsv": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", - "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", - "license": "MIT" - }, - "node_modules/@types/d3-ease": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", - "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", - "license": "MIT" - }, - "node_modules/@types/d3-fetch": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", - "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", - "license": "MIT", - "dependencies": { - "@types/d3-dsv": "*" - } - }, - "node_modules/@types/d3-force": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", - "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", - "license": "MIT" - }, - "node_modules/@types/d3-format": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", - "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", - "license": "MIT" - }, - "node_modules/@types/d3-geo": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", - "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", - "license": "MIT", - "dependencies": { - "@types/geojson": "*" - } - }, - "node_modules/@types/d3-hierarchy": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", - "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", - "license": "MIT" - }, - "node_modules/@types/d3-interpolate": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", - "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", - "license": "MIT", - "dependencies": { - "@types/d3-color": "*" - } - }, - "node_modules/@types/d3-path": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", - "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", - "license": "MIT" - }, - "node_modules/@types/d3-polygon": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", - "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", - "license": "MIT" - }, - "node_modules/@types/d3-quadtree": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", - "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", - "license": "MIT" - }, - "node_modules/@types/d3-random": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", - "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", - "license": "MIT" - }, - "node_modules/@types/d3-scale": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", - "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", - "license": "MIT", - "dependencies": { - "@types/d3-time": "*" - } - }, - "node_modules/@types/d3-scale-chromatic": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", - "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", - "license": "MIT" - }, - "node_modules/@types/d3-selection": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", - "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", - "license": "MIT" - }, - "node_modules/@types/d3-shape": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", - "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", - "license": "MIT", - "dependencies": { - "@types/d3-path": "*" - } - }, - "node_modules/@types/d3-time": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", - "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", - "license": "MIT" - }, - "node_modules/@types/d3-time-format": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", - "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", - "license": "MIT" - }, - "node_modules/@types/d3-timer": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", - "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", - "license": "MIT" - }, - "node_modules/@types/d3-transition": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", - "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", - "license": "MIT", - "dependencies": { - "@types/d3-selection": "*" - } - }, - "node_modules/@types/d3-zoom": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", - "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", - "license": "MIT", - "dependencies": { - "@types/d3-interpolate": "*", - "@types/d3-selection": "*" - } - }, - "node_modules/@types/debug": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", - "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", - "license": "MIT", - "dependencies": { - "@types/ms": "*" - } - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "license": "MIT" - }, - "node_modules/@types/geojson": { - "version": "7946.0.16", - "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", - "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", - "license": "MIT" - }, - "node_modules/@types/hast": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", - "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", - "license": "MIT" - }, - "node_modules/@types/markdown-it": { - "version": "14.1.2", - "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", - "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", - "license": "MIT", - "dependencies": { - "@types/linkify-it": "^5", - "@types/mdurl": "^2" - } - }, - "node_modules/@types/mdast": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", - "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/mdurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", - "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", - "license": "MIT" - }, - "node_modules/@types/ms": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", - "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "22.19.15", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", - "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT", - "optional": true - }, - "node_modules/@types/unist": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", - "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", - "license": "MIT" - }, - "node_modules/@types/web-bluetooth": { - "version": "0.0.21", - "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", - "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", - "license": "MIT" - }, - "node_modules/@typescript/ata": { - "version": "0.9.8", - "resolved": "https://registry.npmjs.org/@typescript/ata/-/ata-0.9.8.tgz", - "integrity": "sha512-+M815CeDRJS5H5ciWfhFCKp25nNfF+LFWawWAaBhNlquFb2wS5IIMDI+2bKWN3GuU6mpj+FzySsOD29M4nG8Xg==", - "license": "MIT", - "peerDependencies": { - "typescript": ">=4.4.4" - } - }, - "node_modules/@typescript/vfs": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/@typescript/vfs/-/vfs-1.6.4.tgz", - "integrity": "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.3" - }, - "peerDependencies": { - "typescript": "*" - } - }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", - "license": "ISC" - }, - "node_modules/@unhead/vue": { - "version": "2.1.12", - "resolved": "https://registry.npmjs.org/@unhead/vue/-/vue-2.1.12.tgz", - "integrity": "sha512-zEWqg0nZM8acpuTZE40wkeUl8AhIe0tU0OkilVi1D4fmVjACrwoh5HP6aNqJ8kUnKsoy6D+R3Vi/O+fmdNGO7g==", - "license": "MIT", - "dependencies": { - "hookable": "^6.0.1", - "unhead": "2.1.12" - }, - "funding": { - "url": "https://github.com/sponsors/harlan-zw" - }, - "peerDependencies": { - "vue": ">=3.5.18" - } - }, - "node_modules/@unocss/cli": { - "version": "66.6.6", - "resolved": "https://registry.npmjs.org/@unocss/cli/-/cli-66.6.6.tgz", - "integrity": "sha512-78SY8j4hAVelK+vP/adsDGaSjEITasYLFECJLHWxUJSzK+G9UIc5wtL/u4jA+zKvwVkHcDvbkcO5K6wwwpAixg==", - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "@unocss/config": "66.6.6", - "@unocss/core": "66.6.6", - "@unocss/preset-wind3": "66.6.6", - "@unocss/preset-wind4": "66.6.6", - "@unocss/transformer-directives": "66.6.6", - "cac": "^6.7.14", - "chokidar": "^5.0.0", - "colorette": "^2.0.20", - "consola": "^3.4.2", - "magic-string": "^0.30.21", - "pathe": "^2.0.3", - "perfect-debounce": "^2.1.0", - "tinyglobby": "^0.2.15", - "unplugin-utils": "^0.3.1" - }, - "bin": { - "unocss": "bin/unocss.mjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/@unocss/config": { - "version": "66.6.6", - "resolved": "https://registry.npmjs.org/@unocss/config/-/config-66.6.6.tgz", - "integrity": "sha512-menlnkqAFX/4wR2aandY8hSqrt01JE+rOzvtQxWaBt8kf1du62b0sS72FE5Z40n6HlEsEbF91N9FCfhnzG6i6g==", - "license": "MIT", - "dependencies": { - "@unocss/core": "66.6.6", - "colorette": "^2.0.20", - "consola": "^3.4.2", - "unconfig": "^7.5.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/@unocss/core": { - "version": "66.6.6", - "resolved": "https://registry.npmjs.org/@unocss/core/-/core-66.6.6.tgz", - "integrity": "sha512-Sbbx0ZQqmV8K2lg8E+z9MJzWb1MgRtJnvqzxDIrNuBjXasKhbcFt5wEMBtEZJOr63Z4ck0xThhZK53HmYT2jmg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/@unocss/extractor-arbitrary-variants": { - "version": "66.6.6", - "resolved": "https://registry.npmjs.org/@unocss/extractor-arbitrary-variants/-/extractor-arbitrary-variants-66.6.6.tgz", - "integrity": "sha512-uMzekF2miZRUwSZGvy3yYQiBAcSAs9LiXK8e3NjldxEw8xcRDWgTErxgStRoBeAD6UyzDcg/Cvwtf2guMbtR+g==", - "license": "MIT", - "dependencies": { - "@unocss/core": "66.6.6" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/@unocss/extractor-mdc": { - "version": "66.6.6", - "resolved": "https://registry.npmjs.org/@unocss/extractor-mdc/-/extractor-mdc-66.6.6.tgz", - "integrity": "sha512-8ctylpUgZDs/TRfN5MKXQZi19eOcaRxpiumkQH+Bta1zcPWfWHpRsBOyhBffzKIWeBqScwtnZSBd5+iYrxbzcw==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/@unocss/inspector": { - "version": "66.6.6", - "resolved": "https://registry.npmjs.org/@unocss/inspector/-/inspector-66.6.6.tgz", - "integrity": "sha512-CpXIsqHwxCXJtUjUz6S29diHCIA+EJ1u5WML/6m2YPI4ObgWAVKrExy09inSg2icS52lFkWWdWQSeqc9kl5W6Q==", - "license": "MIT", - "dependencies": { - "@unocss/core": "66.6.6", - "@unocss/rule-utils": "66.6.6", - "colorette": "^2.0.20", - "gzip-size": "^6.0.0", - "sirv": "^3.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/@unocss/preset-attributify": { - "version": "66.6.6", - "resolved": "https://registry.npmjs.org/@unocss/preset-attributify/-/preset-attributify-66.6.6.tgz", - "integrity": "sha512-3H12UI1rBt60PQy+S4IEeFYWu1/WQFuc2yhJ5mu/RCvX5/qwlIGanBpuh+xzTPXU1fWBlZN68yyO9uWOQgTqZQ==", - "license": "MIT", - "dependencies": { - "@unocss/core": "66.6.6" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/@unocss/preset-icons": { - "version": "66.6.6", - "resolved": "https://registry.npmjs.org/@unocss/preset-icons/-/preset-icons-66.6.6.tgz", - "integrity": "sha512-HfIEEqf3jyKexOB2Sux556n0NkPoUftb2H4+Cf7prJvKHopMkZ/OUkXjwvUlxt1e5UpAEaIa0A2Ir7+ApxXoGA==", - "license": "MIT", - "dependencies": { - "@iconify/utils": "^3.1.0", - "@unocss/core": "66.6.6", - "ofetch": "^1.5.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/@unocss/preset-mini": { - "version": "66.6.6", - "resolved": "https://registry.npmjs.org/@unocss/preset-mini/-/preset-mini-66.6.6.tgz", - "integrity": "sha512-k+/95PKMPOK57cJcSmz34VkIFem8BlujRRx6/L0Yusw7vLJMh98k0rPhC5s+NomZ/d9ZPgbNylskLhItJlak3w==", - "license": "MIT", - "dependencies": { - "@unocss/core": "66.6.6", - "@unocss/extractor-arbitrary-variants": "66.6.6", - "@unocss/rule-utils": "66.6.6" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/@unocss/preset-tagify": { - "version": "66.6.6", - "resolved": "https://registry.npmjs.org/@unocss/preset-tagify/-/preset-tagify-66.6.6.tgz", - "integrity": "sha512-KgBXYPYS0g4TVC3NLiIB78YIqUlvDLanz1EHIDo34rOTUfMgY8Uf5VuDJAzMu4Sc0LiwwBJbk6nIG9/Zm7ufWg==", - "license": "MIT", - "dependencies": { - "@unocss/core": "66.6.6" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/@unocss/preset-typography": { - "version": "66.6.6", - "resolved": "https://registry.npmjs.org/@unocss/preset-typography/-/preset-typography-66.6.6.tgz", - "integrity": "sha512-SM1km5nqt15z4sTabfOobSC633I5Ol5nnme6JFTra4wiyCUNs+Cg31nJ6jnopWDUT4SEAXqfUH7jKSSoCnI6ZA==", - "license": "MIT", - "dependencies": { - "@unocss/core": "66.6.6", - "@unocss/rule-utils": "66.6.6" - } - }, - "node_modules/@unocss/preset-uno": { - "version": "66.6.6", - "resolved": "https://registry.npmjs.org/@unocss/preset-uno/-/preset-uno-66.6.6.tgz", - "integrity": "sha512-40PcBDtlhW7QP7e/WOxC684IhN5T1dXvj1dgx9ZzK+8lEDGjcX7bN2noW4aSenzSrHymeSsMrL/0ltL4ED/5Zw==", - "license": "MIT", - "dependencies": { - "@unocss/core": "66.6.6", - "@unocss/preset-wind3": "66.6.6" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/@unocss/preset-web-fonts": { - "version": "66.6.6", - "resolved": "https://registry.npmjs.org/@unocss/preset-web-fonts/-/preset-web-fonts-66.6.6.tgz", - "integrity": "sha512-5ikwgrJB8VPzKd0bqgGNgYUGix90KFnVtKJPjWTP5qsv3+ZtZnea1rRbAFl8i2t52hg35msNBsQo+40IC3xB6A==", - "license": "MIT", - "dependencies": { - "@unocss/core": "66.6.6", - "ofetch": "^1.5.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/@unocss/preset-wind": { - "version": "66.6.6", - "resolved": "https://registry.npmjs.org/@unocss/preset-wind/-/preset-wind-66.6.6.tgz", - "integrity": "sha512-TMy3lZ35FP/4QqDHOLWZmV+RoOGWUDqnDEOTjOKI1CQARGta0ppUmq+IZMuI1ZJLuOa4OZ9V6SfnwMXwRLgXmw==", - "license": "MIT", - "dependencies": { - "@unocss/core": "66.6.6", - "@unocss/preset-wind3": "66.6.6" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/@unocss/preset-wind3": { - "version": "66.6.6", - "resolved": "https://registry.npmjs.org/@unocss/preset-wind3/-/preset-wind3-66.6.6.tgz", - "integrity": "sha512-rk6gPPIQ7z2DVucOqp7XZ4vGpKAuzBV1vtUDvDh5WscxzO/QlqaeTfTALk5YgGpmLaF4+ns6FrTgLjV+wHgHuQ==", - "license": "MIT", - "dependencies": { - "@unocss/core": "66.6.6", - "@unocss/preset-mini": "66.6.6", - "@unocss/rule-utils": "66.6.6" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/@unocss/preset-wind4": { - "version": "66.6.6", - "resolved": "https://registry.npmjs.org/@unocss/preset-wind4/-/preset-wind4-66.6.6.tgz", - "integrity": "sha512-caTDM9rZSlp4tyPWWAnwMvQr2PXq53LsEYwd3N8zj0ou2hcsqptJvF+mFvyhvGF66x26wWJr/FwuUEhh7qycaw==", - "license": "MIT", - "dependencies": { - "@unocss/core": "66.6.6", - "@unocss/extractor-arbitrary-variants": "66.6.6", - "@unocss/rule-utils": "66.6.6" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/@unocss/reset": { - "version": "66.6.6", - "resolved": "https://registry.npmjs.org/@unocss/reset/-/reset-66.6.6.tgz", - "integrity": "sha512-rBFviUfHC6h0mSW6TYa7O1HGoEF7IV9VS0Q0EpweeQqR4N3D72DazZLWMASwNsmqKHUSDa+6h1oBqF/yqHfGAQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/@unocss/rule-utils": { - "version": "66.6.6", - "resolved": "https://registry.npmjs.org/@unocss/rule-utils/-/rule-utils-66.6.6.tgz", - "integrity": "sha512-krWtQKGshOaqQMuxeGq1NOA8NL35VdpYlmQEWOe39BY6TACT51bgQFu40MRfsAIMZZtoGS2YYTrnHojgR92omw==", - "license": "MIT", - "dependencies": { - "@unocss/core": "^66.6.6", - "magic-string": "^0.30.21" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/@unocss/transformer-attributify-jsx": { - "version": "66.6.6", - "resolved": "https://registry.npmjs.org/@unocss/transformer-attributify-jsx/-/transformer-attributify-jsx-66.6.6.tgz", - "integrity": "sha512-NnDchmN2EeFLy4lfVqDgNe9j1+w2RLL2L9zKECXs5g6rDVfeeEK6FNgxSq3XnPcKltjNCy1pF4MaDOROG7r8yA==", - "license": "MIT", - "dependencies": { - "@unocss/core": "66.6.6", - "oxc-parser": "^0.115.0", - "oxc-walker": "^0.7.0" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/@unocss/transformer-compile-class": { - "version": "66.6.6", - "resolved": "https://registry.npmjs.org/@unocss/transformer-compile-class/-/transformer-compile-class-66.6.6.tgz", - "integrity": "sha512-KKssJxU8fZ9x84yznIirbtta2sB0LN/3lm0bp+Wl1298HITaNiVeG2n26iStQ3N7r240xRN2RarxncSVCMFwWw==", - "license": "MIT", - "dependencies": { - "@unocss/core": "66.6.6" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/@unocss/transformer-directives": { - "version": "66.6.6", - "resolved": "https://registry.npmjs.org/@unocss/transformer-directives/-/transformer-directives-66.6.6.tgz", - "integrity": "sha512-CReFTcBfMtKkRvzIqxL20VptWt5C1Om27dwoKzyVFBXv0jzViWysbu0y0AQg3bsgD4cFqndFyAGyeL84j0nbKg==", - "license": "MIT", - "dependencies": { - "@unocss/core": "66.6.6", - "@unocss/rule-utils": "66.6.6", - "css-tree": "^3.1.0" - } - }, - "node_modules/@unocss/transformer-variant-group": { - "version": "66.6.6", - "resolved": "https://registry.npmjs.org/@unocss/transformer-variant-group/-/transformer-variant-group-66.6.6.tgz", - "integrity": "sha512-j4L/0Tw6AdMVB2dDnuBlDbevyL1/0CAk88a77VF/VjgEIBwB9VXsCCUsxz+2Dohcl7N2GMm7+kpaWA6qt2PSaA==", - "license": "MIT", - "dependencies": { - "@unocss/core": "66.6.6" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/@unocss/vite": { - "version": "66.6.6", - "resolved": "https://registry.npmjs.org/@unocss/vite/-/vite-66.6.6.tgz", - "integrity": "sha512-DgG7KcUUMtoDhPOlFf2l4dR+66xZ23SdZvTYpikk5nZfLCzZd62vedutD7x0bTR6VpK2YRq39B+F+Z6TktNY/w==", - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "@unocss/config": "66.6.6", - "@unocss/core": "66.6.6", - "@unocss/inspector": "66.6.6", - "chokidar": "^5.0.0", - "magic-string": "^0.30.21", - "pathe": "^2.0.3", - "tinyglobby": "^0.2.15", - "unplugin-utils": "^0.3.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - }, - "peerDependencies": { - "vite": "^2.9.0 || ^3.0.0-0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0 || ^8.0.0-0" - } - }, - "node_modules/@upsetjs/venn.js": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", - "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==", - "license": "MIT", - "optionalDependencies": { - "d3-selection": "^3.0.0", - "d3-transition": "^3.0.1" - } - }, - "node_modules/@vitejs/plugin-vue": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.4.tgz", - "integrity": "sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ==", - "license": "MIT", - "dependencies": { - "@rolldown/pluginutils": "1.0.0-rc.2" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0", - "vue": "^3.2.25" - } - }, - "node_modules/@vitejs/plugin-vue-jsx": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue-jsx/-/plugin-vue-jsx-5.1.4.tgz", - "integrity": "sha512-70LmoVk9riR7qc4W2CpjsbNMWTPnuZb9dpFKX1emru0yP57nsc9k8nhLA6U93ngQapv5VDIUq2JatNfLbBIkrA==", - "license": "MIT", - "dependencies": { - "@babel/core": "^7.29.0", - "@babel/plugin-syntax-typescript": "^7.28.6", - "@babel/plugin-transform-typescript": "^7.28.6", - "@rolldown/pluginutils": "^1.0.0-rc.2", - "@vue/babel-plugin-jsx": "^2.0.1" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0", - "vue": "^3.0.0" - } - }, - "node_modules/@volar/language-core": { - "version": "2.4.28", - "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", - "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", - "license": "MIT", - "dependencies": { - "@volar/source-map": "2.4.28" - } - }, - "node_modules/@volar/source-map": { - "version": "2.4.28", - "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", - "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", - "license": "MIT" - }, - "node_modules/@vue-macros/common": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@vue-macros/common/-/common-3.1.2.tgz", - "integrity": "sha512-h9t4ArDdniO9ekYHAD95t9AZcAbb19lEGK+26iAjUODOIJKmObDNBSe4+6ELQAA3vtYiFPPBtHh7+cQCKi3Dng==", - "license": "MIT", - "dependencies": { - "@vue/compiler-sfc": "^3.5.22", - "ast-kit": "^2.1.2", - "local-pkg": "^1.1.2", - "magic-string-ast": "^1.0.2", - "unplugin-utils": "^0.3.0" - }, - "engines": { - "node": ">=20.19.0" - }, - "funding": { - "url": "https://github.com/sponsors/vue-macros" - }, - "peerDependencies": { - "vue": "^2.7.0 || ^3.2.25" - }, - "peerDependenciesMeta": { - "vue": { - "optional": true - } - } - }, - "node_modules/@vue/babel-helper-vue-transform-on": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@vue/babel-helper-vue-transform-on/-/babel-helper-vue-transform-on-2.0.1.tgz", - "integrity": "sha512-uZ66EaFbnnZSYqYEyplWvn46GhZ1KuYSThdT68p+am7MgBNbQ3hphTL9L+xSIsWkdktwhPYLwPgVWqo96jDdRA==", - "license": "MIT" - }, - "node_modules/@vue/babel-plugin-jsx": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@vue/babel-plugin-jsx/-/babel-plugin-jsx-2.0.1.tgz", - "integrity": "sha512-a8CaLQjD/s4PVdhrLD/zT574ZNPnZBOY+IhdtKWRB4HRZ0I2tXBi5ne7d9eCfaYwp5gU5+4KIyFTV1W1YL9xZA==", - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/plugin-syntax-jsx": "^7.27.1", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.4", - "@babel/types": "^7.28.4", - "@vue/babel-helper-vue-transform-on": "2.0.1", - "@vue/babel-plugin-resolve-type": "2.0.1", - "@vue/shared": "^3.5.22" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - }, - "peerDependenciesMeta": { - "@babel/core": { - "optional": true - } - } - }, - "node_modules/@vue/babel-plugin-resolve-type": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@vue/babel-plugin-resolve-type/-/babel-plugin-resolve-type-2.0.1.tgz", - "integrity": "sha512-ybwgIuRGRRBhOU37GImDoWQoz+TlSqap65qVI6iwg/J7FfLTLmMf97TS7xQH9I7Qtr/gp161kYVdhr1ZMraSYQ==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/parser": "^7.28.4", - "@vue/compiler-sfc": "^3.5.22" - }, - "funding": { - "url": "https://github.com/sponsors/sxzz" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@vue/compiler-core": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.30.tgz", - "integrity": "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==", - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.0", - "@vue/shared": "3.5.30", - "entities": "^7.0.1", - "estree-walker": "^2.0.2", - "source-map-js": "^1.2.1" - } - }, - "node_modules/@vue/compiler-dom": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz", - "integrity": "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==", - "license": "MIT", - "dependencies": { - "@vue/compiler-core": "3.5.30", - "@vue/shared": "3.5.30" - } - }, - "node_modules/@vue/compiler-sfc": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.30.tgz", - "integrity": "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==", - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.0", - "@vue/compiler-core": "3.5.30", - "@vue/compiler-dom": "3.5.30", - "@vue/compiler-ssr": "3.5.30", - "@vue/shared": "3.5.30", - "estree-walker": "^2.0.2", - "magic-string": "^0.30.21", - "postcss": "^8.5.8", - "source-map-js": "^1.2.1" - } - }, - "node_modules/@vue/compiler-ssr": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.30.tgz", - "integrity": "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==", - "license": "MIT", - "dependencies": { - "@vue/compiler-dom": "3.5.30", - "@vue/shared": "3.5.30" - } - }, - "node_modules/@vue/devtools-api": { - "version": "8.0.7", - "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.0.7.tgz", - "integrity": "sha512-tc1TXAxclsn55JblLkFVcIRG7MeSJC4fWsPjfM7qu/IcmPUYnQ5Q8vzWwBpyDY24ZjmZTUCCwjRSNbx58IhlAA==", - "license": "MIT", - "dependencies": { - "@vue/devtools-kit": "^8.0.7" - } - }, - "node_modules/@vue/devtools-kit": { - "version": "8.0.7", - "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.0.7.tgz", - "integrity": "sha512-H6esJGHGl5q0E9iV3m2EoBQHJ+V83WMW83A0/+Fn95eZ2iIvdsq4+UCS6yT/Fdd4cGZSchx/MdWDreM3WqMsDw==", - "license": "MIT", - "dependencies": { - "@vue/devtools-shared": "^8.0.7", - "birpc": "^2.6.1", - "hookable": "^5.5.3", - "perfect-debounce": "^2.0.0" - } - }, - "node_modules/@vue/devtools-kit/node_modules/hookable": { - "version": "5.5.3", - "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", - "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", - "license": "MIT" - }, - "node_modules/@vue/devtools-shared": { - "version": "8.0.7", - "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.0.7.tgz", - "integrity": "sha512-CgAb9oJH5NUmbQRdYDj/1zMiaICYSLtm+B1kxcP72LBrifGAjUmt8bx52dDH1gWRPlQgxGPqpAMKavzVirAEhA==", - "license": "MIT" - }, - "node_modules/@vue/language-core": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.2.5.tgz", - "integrity": "sha512-d3OIxN/+KRedeM5wQ6H6NIpwS3P5gC9nmyaHgBk+rO6dIsjY+tOh4UlPpiZbAh3YtLdCGEX4M16RmsBqPmJV+g==", - "license": "MIT", - "dependencies": { - "@volar/language-core": "2.4.28", - "@vue/compiler-dom": "^3.5.0", - "@vue/shared": "^3.5.0", - "alien-signals": "^3.0.0", - "muggle-string": "^0.4.1", - "path-browserify": "^1.0.1", - "picomatch": "^4.0.2" - } - }, - "node_modules/@vue/reactivity": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.30.tgz", - "integrity": "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==", - "license": "MIT", - "dependencies": { - "@vue/shared": "3.5.30" - } - }, - "node_modules/@vue/runtime-core": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.30.tgz", - "integrity": "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==", - "license": "MIT", - "dependencies": { - "@vue/reactivity": "3.5.30", - "@vue/shared": "3.5.30" - } - }, - "node_modules/@vue/runtime-dom": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.30.tgz", - "integrity": "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==", - "license": "MIT", - "dependencies": { - "@vue/reactivity": "3.5.30", - "@vue/runtime-core": "3.5.30", - "@vue/shared": "3.5.30", - "csstype": "^3.2.3" - } - }, - "node_modules/@vue/server-renderer": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.30.tgz", - "integrity": "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==", - "license": "MIT", - "dependencies": { - "@vue/compiler-ssr": "3.5.30", - "@vue/shared": "3.5.30" - }, - "peerDependencies": { - "vue": "3.5.30" - } - }, - "node_modules/@vue/shared": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.30.tgz", - "integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==", - "license": "MIT" - }, - "node_modules/@vueuse/core": { - "version": "14.2.1", - "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.2.1.tgz", - "integrity": "sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==", - "license": "MIT", - "dependencies": { - "@types/web-bluetooth": "^0.0.21", - "@vueuse/metadata": "14.2.1", - "@vueuse/shared": "14.2.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - }, - "peerDependencies": { - "vue": "^3.5.0" - } - }, - "node_modules/@vueuse/math": { - "version": "14.2.1", - "resolved": "https://registry.npmjs.org/@vueuse/math/-/math-14.2.1.tgz", - "integrity": "sha512-WV4WTm4GBeILnIAOePQNI1UbYv/HjDx1P+0MSXxFyBy3r8I9xVYn6xqBMLkCbXfAVmkr1sA/G5ILM2K8VDtIbA==", - "license": "MIT", - "dependencies": { - "@vueuse/shared": "14.2.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - }, - "peerDependencies": { - "vue": "^3.5.0" - } - }, - "node_modules/@vueuse/metadata": { - "version": "14.2.1", - "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.2.1.tgz", - "integrity": "sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/@vueuse/motion": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vueuse/motion/-/motion-3.0.3.tgz", - "integrity": "sha512-4B+ITsxCI9cojikvrpaJcLXyq0spj3sdlzXjzesWdMRd99hhtFI6OJ/1JsqwtF73YooLe0hUn/xDR6qCtmn5GQ==", - "license": "MIT", - "dependencies": { - "@vueuse/core": "^13.0.0", - "@vueuse/shared": "^13.0.0", - "defu": "^6.1.4", - "framesync": "^6.1.2", - "popmotion": "^11.0.5", - "style-value-types": "^5.1.2" - }, - "optionalDependencies": { - "@nuxt/kit": "^3.13.0" - }, - "peerDependencies": { - "vue": ">=3.0.0" - } - }, - "node_modules/@vueuse/motion/node_modules/@vueuse/core": { - "version": "13.9.0", - "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-13.9.0.tgz", - "integrity": "sha512-ts3regBQyURfCE2BcytLqzm8+MmLlo5Ln/KLoxDVcsZ2gzIwVNnQpQOL/UKV8alUqjSZOlpFZcRNsLRqj+OzyA==", - "license": "MIT", - "dependencies": { - "@types/web-bluetooth": "^0.0.21", - "@vueuse/metadata": "13.9.0", - "@vueuse/shared": "13.9.0" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - }, - "peerDependencies": { - "vue": "^3.5.0" - } - }, - "node_modules/@vueuse/motion/node_modules/@vueuse/metadata": { - "version": "13.9.0", - "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-13.9.0.tgz", - "integrity": "sha512-1AFRvuiGphfF7yWixZa0KwjYH8ulyjDCC0aFgrGRz8+P4kvDFSdXLVfTk5xAN9wEuD1J6z4/myMoYbnHoX07zg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/@vueuse/motion/node_modules/@vueuse/shared": { - "version": "13.9.0", - "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-13.9.0.tgz", - "integrity": "sha512-e89uuTLMh0U5cZ9iDpEI2senqPGfbPRTHM/0AaQkcxnpqjkZqDYP8rpfm7edOz8s+pOCOROEy1PIveSW8+fL5g==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" - }, - "peerDependencies": { - "vue": "^3.5.0" - } - }, - "node_modules/@vueuse/shared": { - "version": "14.2.1", - "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.2.1.tgz", - "integrity": "sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" - }, - "peerDependencies": { - "vue": "^3.5.0" - } - }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/alien-signals": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.2.tgz", - "integrity": "sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==", - "license": "MIT" - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/ansis": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz", - "integrity": "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==", - "license": "ISC", - "engines": { - "node": ">=14" - } - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0" - }, - "node_modules/ast-kit": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz", - "integrity": "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==", - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.28.5", - "pathe": "^2.0.3" - }, - "engines": { - "node": ">=20.19.0" - }, - "funding": { - "url": "https://github.com/sponsors/sxzz" - } - }, - "node_modules/ast-walker-scope": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/ast-walker-scope/-/ast-walker-scope-0.8.3.tgz", - "integrity": "sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==", - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.28.4", - "ast-kit": "^2.1.3" - }, - "engines": { - "node": ">=20.19.0" - }, - "funding": { - "url": "https://github.com/sponsors/sxzz" - } - }, - "node_modules/baseline-browser-mapping": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", - "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/birpc": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", - "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/bundle-name": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", - "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", - "license": "MIT", - "dependencies": { - "run-applescript": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/c12": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.3.tgz", - "integrity": "sha512-750hTRvgBy5kcMNPdh95Qo+XUBeGo8C7nsKSmedDmaQI+E0r82DwHeM6vBewDe4rGFbnxoa4V9pw+sPh5+Iz8Q==", - "license": "MIT", - "optional": true, - "dependencies": { - "chokidar": "^5.0.0", - "confbox": "^0.2.2", - "defu": "^6.1.4", - "dotenv": "^17.2.3", - "exsolve": "^1.0.8", - "giget": "^2.0.0", - "jiti": "^2.6.1", - "ohash": "^2.0.11", - "pathe": "^2.0.3", - "perfect-debounce": "^2.0.0", - "pkg-types": "^2.3.0", - "rc9": "^2.1.2" - }, - "peerDependencies": { - "magicast": "*" - }, - "peerDependenciesMeta": { - "magicast": { - "optional": true - } - } - }, - "node_modules/c12/node_modules/rc9": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", - "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", - "license": "MIT", - "optional": true, - "dependencies": { - "defu": "^6.1.4", - "destr": "^2.0.3" - } - }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001777", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz", - "integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/ccount": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", - "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", - "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities-html4": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", - "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities-legacy": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", - "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/chevrotain": { - "version": "11.1.2", - "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.1.2.tgz", - "integrity": "sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==", - "license": "Apache-2.0", - "dependencies": { - "@chevrotain/cst-dts-gen": "11.1.2", - "@chevrotain/gast": "11.1.2", - "@chevrotain/regexp-to-ast": "11.1.2", - "@chevrotain/types": "11.1.2", - "@chevrotain/utils": "11.1.2", - "lodash-es": "4.17.23" - } - }, - "node_modules/chevrotain-allstar": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.3.1.tgz", - "integrity": "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==", - "license": "MIT", - "dependencies": { - "lodash-es": "^4.17.21" - }, - "peerDependencies": { - "chevrotain": "^11.0.0" - } - }, - "node_modules/chokidar": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", - "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "license": "MIT", - "dependencies": { - "readdirp": "^5.0.0" - }, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/citty": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", - "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", - "license": "MIT", - "dependencies": { - "consola": "^3.2.3" - } - }, - "node_modules/cli-progress": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz", - "integrity": "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==", - "license": "MIT", - "dependencies": { - "string-width": "^4.2.3" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/cliui": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", - "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", - "license": "ISC", - "dependencies": { - "string-width": "^7.2.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/cliui/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/cliui/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "license": "MIT" - }, - "node_modules/cliui/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cliui/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/clone-regexp": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/clone-regexp/-/clone-regexp-3.0.0.tgz", - "integrity": "sha512-ujdnoq2Kxb8s3ItNBtnYeXdm07FcU0u8ARAT1lQ2YdMwQC+cdiXX8KoqMVuglztILivceTtp4ivqGSmEmhBUJw==", - "license": "MIT", - "dependencies": { - "is-regexp": "^3.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/colorette": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "license": "MIT" - }, - "node_modules/comma-separated-tokens": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", - "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/commander": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", - "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/confbox": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", - "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", - "license": "MIT" - }, - "node_modules/connect": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", - "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "finalhandler": "1.1.2", - "parseurl": "~1.3.3", - "utils-merge": "1.0.1" - }, - "engines": { - "node": ">= 0.10.0" - } - }, - "node_modules/connect/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/connect/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/consola": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", - "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", - "license": "MIT", - "engines": { - "node": "^14.18.0 || >=16.10.0" - } - }, - "node_modules/convert-hrtime": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/convert-hrtime/-/convert-hrtime-5.0.0.tgz", - "integrity": "sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "license": "MIT" - }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "license": "MIT" - }, - "node_modules/cose-base": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", - "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", - "license": "MIT", - "dependencies": { - "layout-base": "^1.0.0" - } - }, - "node_modules/css-tree": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", - "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", - "license": "MIT", - "dependencies": { - "mdn-data": "2.27.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" - } - }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" - }, - "node_modules/cytoscape": { - "version": "3.33.1", - "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", - "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", - "license": "MIT", - "engines": { - "node": ">=0.10" - } - }, - "node_modules/cytoscape-cose-bilkent": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", - "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", - "license": "MIT", - "dependencies": { - "cose-base": "^1.0.0" - }, - "peerDependencies": { - "cytoscape": "^3.2.0" - } - }, - "node_modules/cytoscape-fcose": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", - "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", - "license": "MIT", - "dependencies": { - "cose-base": "^2.2.0" - }, - "peerDependencies": { - "cytoscape": "^3.2.0" - } - }, - "node_modules/cytoscape-fcose/node_modules/cose-base": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz", - "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", - "license": "MIT", - "dependencies": { - "layout-base": "^2.0.0" - } - }, - "node_modules/cytoscape-fcose/node_modules/layout-base": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz", - "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", - "license": "MIT" - }, - "node_modules/d3": { - "version": "7.9.0", - "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", - "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", - "license": "ISC", - "dependencies": { - "d3-array": "3", - "d3-axis": "3", - "d3-brush": "3", - "d3-chord": "3", - "d3-color": "3", - "d3-contour": "4", - "d3-delaunay": "6", - "d3-dispatch": "3", - "d3-drag": "3", - "d3-dsv": "3", - "d3-ease": "3", - "d3-fetch": "3", - "d3-force": "3", - "d3-format": "3", - "d3-geo": "3", - "d3-hierarchy": "3", - "d3-interpolate": "3", - "d3-path": "3", - "d3-polygon": "3", - "d3-quadtree": "3", - "d3-random": "3", - "d3-scale": "4", - "d3-scale-chromatic": "3", - "d3-selection": "3", - "d3-shape": "3", - "d3-time": "3", - "d3-time-format": "4", - "d3-timer": "3", - "d3-transition": "3", - "d3-zoom": "3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-array": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", - "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", - "license": "ISC", - "dependencies": { - "internmap": "1 - 2" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-axis": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", - "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-brush": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", - "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", - "license": "ISC", - "dependencies": { - "d3-dispatch": "1 - 3", - "d3-drag": "2 - 3", - "d3-interpolate": "1 - 3", - "d3-selection": "3", - "d3-transition": "3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-chord": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", - "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", - "license": "ISC", - "dependencies": { - "d3-path": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-color": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", - "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-contour": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", - "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", - "license": "ISC", - "dependencies": { - "d3-array": "^3.2.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-delaunay": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", - "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", - "license": "ISC", - "dependencies": { - "delaunator": "5" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-dispatch": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", - "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-drag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", - "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", - "license": "ISC", - "dependencies": { - "d3-dispatch": "1 - 3", - "d3-selection": "3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-dsv": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", - "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", - "license": "ISC", - "dependencies": { - "commander": "7", - "iconv-lite": "0.6", - "rw": "1" - }, - "bin": { - "csv2json": "bin/dsv2json.js", - "csv2tsv": "bin/dsv2dsv.js", - "dsv2dsv": "bin/dsv2dsv.js", - "dsv2json": "bin/dsv2json.js", - "json2csv": "bin/json2dsv.js", - "json2dsv": "bin/json2dsv.js", - "json2tsv": "bin/json2dsv.js", - "tsv2csv": "bin/dsv2dsv.js", - "tsv2json": "bin/dsv2json.js" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-dsv/node_modules/commander": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", - "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, - "node_modules/d3-dsv/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/d3-ease": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", - "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-fetch": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", - "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", - "license": "ISC", - "dependencies": { - "d3-dsv": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-force": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", - "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", - "license": "ISC", - "dependencies": { - "d3-dispatch": "1 - 3", - "d3-quadtree": "1 - 3", - "d3-timer": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-format": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", - "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-geo": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", - "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", - "license": "ISC", - "dependencies": { - "d3-array": "2.5.0 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-hierarchy": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", - "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-interpolate": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", - "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-path": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", - "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-polygon": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", - "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-quadtree": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", - "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-random": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", - "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-sankey": { - "version": "0.12.3", - "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", - "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-array": "1 - 2", - "d3-shape": "^1.2.0" - } - }, - "node_modules/d3-sankey/node_modules/d3-array": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", - "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", - "license": "BSD-3-Clause", - "dependencies": { - "internmap": "^1.0.0" - } - }, - "node_modules/d3-sankey/node_modules/d3-path": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", - "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-sankey/node_modules/d3-shape": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", - "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-path": "1" - } - }, - "node_modules/d3-sankey/node_modules/internmap": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", - "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", - "license": "ISC" - }, - "node_modules/d3-scale": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", - "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", - "license": "ISC", - "dependencies": { - "d3-array": "2.10.0 - 3", - "d3-format": "1 - 3", - "d3-interpolate": "1.2.0 - 3", - "d3-time": "2.1.1 - 3", - "d3-time-format": "2 - 4" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-scale-chromatic": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", - "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3", - "d3-interpolate": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-selection": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", - "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-shape": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", - "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", - "license": "ISC", - "dependencies": { - "d3-path": "^3.1.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", - "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", - "license": "ISC", - "dependencies": { - "d3-array": "2 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time-format": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", - "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", - "license": "ISC", - "dependencies": { - "d3-time": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-timer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", - "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-transition": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", - "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3", - "d3-dispatch": "1 - 3", - "d3-ease": "1 - 3", - "d3-interpolate": "1 - 3", - "d3-timer": "1 - 3" - }, - "engines": { - "node": ">=12" - }, - "peerDependencies": { - "d3-selection": "2 - 3" - } - }, - "node_modules/d3-zoom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", - "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", - "license": "ISC", - "dependencies": { - "d3-dispatch": "1 - 3", - "d3-drag": "2 - 3", - "d3-interpolate": "1 - 3", - "d3-selection": "2 - 3", - "d3-transition": "2 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/dagre-d3-es": { - "version": "7.0.14", - "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", - "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==", - "license": "MIT", - "dependencies": { - "d3": "^7.9.0", - "lodash-es": "^4.17.21" - } - }, - "node_modules/dayjs": { - "version": "1.11.19", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", - "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decode-named-character-reference": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", - "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", - "license": "MIT", - "dependencies": { - "character-entities": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/default-browser": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", - "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", - "license": "MIT", - "dependencies": { - "bundle-name": "^4.1.0", - "default-browser-id": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/default-browser-id": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", - "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/define-lazy-prop": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", - "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/defu": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", - "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", - "license": "MIT" - }, - "node_modules/delaunator": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", - "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==", - "license": "ISC", - "dependencies": { - "robust-predicates": "^3.0.2" - } - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/destr": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", - "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", - "license": "MIT" - }, - "node_modules/devlop": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", - "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", - "license": "MIT", - "dependencies": { - "dequal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/diff-match-patch-es": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/diff-match-patch-es/-/diff-match-patch-es-1.0.1.tgz", - "integrity": "sha512-KhSofrZDERg/NE6Nd+TK53knp2qz0o2Ix8rhkXd3Chfm7Wlo58Eq/juNmkyS6bS+3xS26L3Pstz3BdY/q+e9UQ==", - "license": "Apache-2.0", - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/dns-packet": { - "version": "5.6.1", - "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", - "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", - "license": "MIT", - "dependencies": { - "@leichtgewicht/ip-codec": "^2.0.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/dns-socket": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/dns-socket/-/dns-socket-4.2.2.tgz", - "integrity": "sha512-BDeBd8najI4/lS00HSKpdFia+OvUMytaVjfzR9n5Lq8MlZRSvtbI+uLtx1+XmQFls5wFU9dssccTmQQ6nfpjdg==", - "license": "MIT", - "dependencies": { - "dns-packet": "^5.2.4" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/dom-serializer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", - "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.2", - "entities": "^4.2.0" - }, - "funding": { - "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" - } - }, - "node_modules/dom-serializer/node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/domelementtype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "BSD-2-Clause" - }, - "node_modules/domhandler": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", - "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", - "license": "BSD-2-Clause", - "dependencies": { - "domelementtype": "^2.3.0" - }, - "engines": { - "node": ">= 4" - }, - "funding": { - "url": "https://github.com/fb55/domhandler?sponsor=1" - } - }, - "node_modules/dompurify": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz", - "integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==", - "license": "(MPL-2.0 OR Apache-2.0)", - "engines": { - "node": ">=20" - }, - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" - } - }, - "node_modules/domutils": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", - "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", - "license": "BSD-2-Clause", - "dependencies": { - "dom-serializer": "^2.0.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3" - }, - "funding": { - "url": "https://github.com/fb55/domutils?sponsor=1" - } - }, - "node_modules/dotenv": { - "version": "17.3.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", - "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", - "license": "BSD-2-Clause", - "optional": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/drauu": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/drauu/-/drauu-1.0.0.tgz", - "integrity": "sha512-K3a1cbP2l4i0H/bmNM4nyGsY5/hiH5a10sEHlksqKue0+TPQCHrV9DwPad+St06CJwpkdzVJ/FyOYTIAm82rgg==", - "license": "MIT", - "dependencies": { - "@drauu/core": "1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/duplexer": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", - "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", - "license": "MIT" - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/electron-to-chromium": { - "version": "1.5.307", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz", - "integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==", - "license": "ISC" - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/entities": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", - "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/error-stack-parser-es": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", - "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/errx": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/errx/-/errx-0.1.0.tgz", - "integrity": "sha512-fZmsRiDNv07K6s2KkKFTiD2aIvECa7++PKyD5NC32tpRw46qZA3sOz+aM+/V9V0GDHxVTKLziveV4JhzBHDp9Q==", - "license": "MIT", - "optional": true - }, - "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/escape-string-regexp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", - "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "license": "MIT" - }, - "node_modules/exsolve": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", - "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", - "license": "MIT" - }, - "node_modules/extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", - "license": "MIT", - "dependencies": { - "is-extendable": "^0.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" - }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/file-saver": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz", - "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==", - "license": "MIT" - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/finalhandler": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", - "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "on-finished": "~2.3.0", - "parseurl": "~1.3.3", - "statuses": "~1.5.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/finalhandler/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/finalhandler/node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/finalhandler/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/finalhandler/node_modules/on-finished": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", - "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/finalhandler/node_modules/statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/floating-vue": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/floating-vue/-/floating-vue-5.2.2.tgz", - "integrity": "sha512-afW+h2CFafo+7Y9Lvw/xsqjaQlKLdJV7h1fCHfcYQ1C4SVMlu7OAekqWgu5d4SgvkBVU0pVpLlVsrSTBURFRkg==", - "license": "MIT", - "dependencies": { - "@floating-ui/dom": "~1.1.1", - "vue-resize": "^2.0.0-alpha.1" - }, - "peerDependencies": { - "@nuxt/kit": "^3.2.0", - "vue": "^3.2.0" - }, - "peerDependenciesMeta": { - "@nuxt/kit": { - "optional": true - } - } - }, - "node_modules/framesync": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/framesync/-/framesync-6.1.2.tgz", - "integrity": "sha512-jBTqhX6KaQVDyus8muwZbBeGGP0XgujBRbQ7gM7BRdS3CadCZIHiawyzYLnafYcvZIh5j8WE7cxZKFn7dXhu9g==", - "license": "MIT", - "dependencies": { - "tslib": "2.4.0" - } - }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-timeout": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/function-timeout/-/function-timeout-0.1.1.tgz", - "integrity": "sha512-0NVVC0TaP7dSTvn1yMiy6d6Q8gifzbvQafO46RtLG/kHJUBNd+pVRGOBoK44wNBvtSPUJRfdVvkFdD3p0xvyZg==", - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/fuse.js": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.1.0.tgz", - "integrity": "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=10" - } - }, - "node_modules/fzf": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fzf/-/fzf-0.5.2.tgz", - "integrity": "sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q==", - "license": "BSD-3-Clause" - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-east-asian-width": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", - "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-port-please": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz", - "integrity": "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==", - "license": "MIT" - }, - "node_modules/giget": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", - "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", - "license": "MIT", - "optional": true, - "dependencies": { - "citty": "^0.1.6", - "consola": "^3.4.0", - "defu": "^6.1.4", - "node-fetch-native": "^1.6.6", - "nypm": "^0.6.0", - "pathe": "^2.0.3" - }, - "bin": { - "giget": "dist/cli.mjs" - } - }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/global-directory": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-5.0.0.tgz", - "integrity": "sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w==", - "license": "MIT", - "dependencies": { - "ini": "6.0.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/gray-matter": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", - "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", - "license": "MIT", - "dependencies": { - "js-yaml": "^3.13.1", - "kind-of": "^6.0.2", - "section-matter": "^1.0.0", - "strip-bom-string": "^1.0.0" - }, - "engines": { - "node": ">=6.0" - } - }, - "node_modules/gray-matter/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/gray-matter/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/gray-matter/node_modules/kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/gzip-size": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", - "integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==", - "license": "MIT", - "dependencies": { - "duplexer": "^0.1.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/hachure-fill": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", - "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", - "license": "MIT" - }, - "node_modules/hast-util-to-html": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", - "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "ccount": "^2.0.0", - "comma-separated-tokens": "^2.0.0", - "hast-util-whitespace": "^3.0.0", - "html-void-elements": "^3.0.0", - "mdast-util-to-hast": "^13.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0", - "stringify-entities": "^4.0.0", - "zwitch": "^2.0.4" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-whitespace": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", - "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hey-listen": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/hey-listen/-/hey-listen-1.0.8.tgz", - "integrity": "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==", - "license": "MIT" - }, - "node_modules/hookable": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/hookable/-/hookable-6.0.1.tgz", - "integrity": "sha512-uKGyY8BuzN/a5gvzvA+3FVWo0+wUjgtfSdnmjtrOVwQCZPHpHDH2WRO3VZSOeluYrHoDCiXFffZXs8Dj1ULWtw==", - "license": "MIT" - }, - "node_modules/html-void-elements": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", - "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/htmlparser2": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", - "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.2.2", - "entities": "^7.0.1" - } - }, - "node_modules/https": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/https/-/https-1.0.0.tgz", - "integrity": "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg==", - "license": "ISC" - }, - "node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 4" - } - }, - "node_modules/image-size": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz", - "integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==", - "license": "MIT", - "dependencies": { - "queue": "6.0.2" - }, - "bin": { - "image-size": "bin/image-size.js" - }, - "engines": { - "node": ">=16.x" - } - }, - "node_modules/immediate": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", - "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", - "license": "MIT" - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ini": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", - "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/internmap": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", - "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/ip-regex": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-5.0.0.tgz", - "integrity": "sha512-fOCG6lhoKKakwv+C6KdsOnGvgXnmgfmp0myi3bcNwj3qfwPAxRKWEuFhvEFF7ceYIz6+1jRZ+yguLFAmUNPEfw==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-docker": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", - "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-extendable": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", - "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-in-ssh": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", - "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-inside-container": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", - "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", - "license": "MIT", - "dependencies": { - "is-docker": "^3.0.0" - }, - "bin": { - "is-inside-container": "cli.js" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-installed-globally": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-1.0.0.tgz", - "integrity": "sha512-K55T22lfpQ63N4KEN57jZUAaAYqYHEe8veb/TycJRk9DdSCLLcovXz/mL6mOnhQaZsQGwPhuFopdQIlqGSEjiQ==", - "license": "MIT", - "dependencies": { - "global-directory": "^4.0.1", - "is-path-inside": "^4.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-installed-globally/node_modules/global-directory": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", - "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==", - "license": "MIT", - "dependencies": { - "ini": "4.1.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-installed-globally/node_modules/ini": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", - "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/is-ip": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/is-ip/-/is-ip-5.0.1.tgz", - "integrity": "sha512-FCsGHdlrOnZQcp0+XT5a+pYowf33itBalCl+7ovNXC/7o5BhIpG14M3OrpPPdBSIQJCm+0M5+9mO7S9VVTTCFw==", - "license": "MIT", - "dependencies": { - "ip-regex": "^5.0.0", - "super-regex": "^0.2.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-path-inside": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", - "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-regexp": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-3.1.0.tgz", - "integrity": "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-wsl": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", - "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", - "license": "MIT", - "dependencies": { - "is-inside-container": "^1.0.0" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "license": "MIT" - }, - "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/jszip": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", - "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", - "license": "(MIT OR GPL-3.0-or-later)", - "dependencies": { - "lie": "~3.3.0", - "pako": "~1.0.2", - "readable-stream": "~2.3.6", - "setimmediate": "^1.0.5" - } - }, - "node_modules/katex": { - "version": "0.16.38", - "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.38.tgz", - "integrity": "sha512-cjHooZUmIAUmDsHBN+1n8LaZdpmbj03LtYeYPyuYB7OuloiaeaV6N4LcfjcnHVzGWjVQmKrxxTrpDcmSzEZQwQ==", - "funding": [ - "https://opencollective.com/katex", - "https://github.com/sponsors/katex" - ], - "license": "MIT", - "dependencies": { - "commander": "^8.3.0" - }, - "bin": { - "katex": "cli.js" - } - }, - "node_modules/khroma": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", - "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" - }, - "node_modules/kleur": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/klona": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.6.tgz", - "integrity": "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/knitwork": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/knitwork/-/knitwork-1.3.0.tgz", - "integrity": "sha512-4LqMNoONzR43B1W0ek0fhXMsDNW/zxa1NdFAVMY+k28pgZLovR4G3PB5MrpTxCy1QaZCqNoiaKPr5w5qZHfSNw==", - "license": "MIT", - "optional": true - }, - "node_modules/langium": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/langium/-/langium-4.2.1.tgz", - "integrity": "sha512-zu9QWmjpzJcomzdJQAHgDVhLGq5bLosVak1KVa40NzQHXfqr4eAHupvnPOVXEoLkg6Ocefvf/93d//SB7du4YQ==", - "license": "MIT", - "dependencies": { - "chevrotain": "~11.1.1", - "chevrotain-allstar": "~0.3.1", - "vscode-languageserver": "~9.0.1", - "vscode-languageserver-textdocument": "~1.0.11", - "vscode-uri": "~3.1.0" - }, - "engines": { - "node": ">=20.10.0", - "npm": ">=10.2.3" - } - }, - "node_modules/layout-base": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", - "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", - "license": "MIT" - }, - "node_modules/lie": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", - "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", - "license": "MIT", - "dependencies": { - "immediate": "~3.0.5" - } - }, - "node_modules/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", - "license": "MIT", - "dependencies": { - "uc.micro": "^2.0.0" - } - }, - "node_modules/local-pkg": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz", - "integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==", - "license": "MIT", - "dependencies": { - "mlly": "^1.7.4", - "pkg-types": "^2.3.0", - "quansync": "^0.2.11" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/lodash-es": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", - "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", - "license": "MIT" - }, - "node_modules/longest-streak": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", - "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/lz-string": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "license": "MIT", - "bin": { - "lz-string": "bin/bin.js" - } - }, - "node_modules/magic-regexp": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/magic-regexp/-/magic-regexp-0.10.0.tgz", - "integrity": "sha512-Uly1Bu4lO1hwHUW0CQeSWuRtzCMNO00CmXtS8N6fyvB3B979GOEEeAkiTUDsmbYLAbvpUS/Kt5c4ibosAzVyVg==", - "license": "MIT", - "dependencies": { - "estree-walker": "^3.0.3", - "magic-string": "^0.30.12", - "mlly": "^1.7.2", - "regexp-tree": "^0.1.27", - "type-level-regexp": "~0.1.17", - "ufo": "^1.5.4", - "unplugin": "^2.0.0" - } - }, - "node_modules/magic-regexp/node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/magic-string-ast": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/magic-string-ast/-/magic-string-ast-1.0.3.tgz", - "integrity": "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==", - "license": "MIT", - "dependencies": { - "magic-string": "^0.30.19" - }, - "engines": { - "node": ">=20.19.0" - }, - "funding": { - "url": "https://github.com/sponsors/sxzz" - } - }, - "node_modules/magic-string-stack": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/magic-string-stack/-/magic-string-stack-1.1.0.tgz", - "integrity": "sha512-eAjQQ16Woyi71/6gQoLvn9Mte0JDoS5zUV/BMk0Pzs8Fou+nEuo5T0UbLWBhm3mXiK2YnFz2lFpEEVcLcohhVw==", - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "magic-string": "^0.30.17" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/markdown-exit": { - "version": "1.0.0-beta.9", - "resolved": "https://registry.npmjs.org/markdown-exit/-/markdown-exit-1.0.0-beta.9.tgz", - "integrity": "sha512-5tzrMKMF367amyBly131vm6eGuWRL2DjBqWaFmPzPbLyuxP0XOmyyyroOAIXuBAMF/3kZbbfqOxvW/SotqKqbQ==", - "license": "MIT", - "dependencies": { - "@types/linkify-it": "^5.0.0", - "@types/mdurl": "^2.0.0", - "entities": "^7.0.0", - "linkify-it": "^5.0.0", - "mdurl": "^2.0.0", - "punycode.js": "^2.3.1", - "uc.micro": "^2.1.0" - } - }, - "node_modules/markdown-it": { - "version": "14.1.1", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", - "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1", - "entities": "^4.4.0", - "linkify-it": "^5.0.0", - "mdurl": "^2.0.0", - "punycode.js": "^2.3.1", - "uc.micro": "^2.1.0" - }, - "bin": { - "markdown-it": "bin/markdown-it.mjs" - } - }, - "node_modules/markdown-it-footnote": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/markdown-it-footnote/-/markdown-it-footnote-4.0.0.tgz", - "integrity": "sha512-WYJ7urf+khJYl3DqofQpYfEYkZKbmXmwxQV8c8mO/hGIhgZ1wOe7R4HLFNwqx7TjILbnC98fuyeSsin19JdFcQ==", - "license": "MIT" - }, - "node_modules/markdown-it/node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/markdown-table": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", - "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/marked": { - "version": "16.4.2", - "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", - "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", - "license": "MIT", - "bin": { - "marked": "bin/marked.js" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/mdast-util-find-and-replace": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", - "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "escape-string-regexp": "^5.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-from-markdown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", - "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "mdast-util-to-string": "^4.0.0", - "micromark": "^4.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-decode-string": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", - "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", - "license": "MIT", - "dependencies": { - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-gfm-autolink-literal": "^2.0.0", - "mdast-util-gfm-footnote": "^2.0.0", - "mdast-util-gfm-strikethrough": "^2.0.0", - "mdast-util-gfm-table": "^2.0.0", - "mdast-util-gfm-task-list-item": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-autolink-literal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", - "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "ccount": "^2.0.0", - "devlop": "^1.0.0", - "mdast-util-find-and-replace": "^3.0.0", - "micromark-util-character": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-footnote": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", - "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.1.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-strikethrough": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", - "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-table": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", - "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "markdown-table": "^3.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-task-list-item": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", - "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-phrasing": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", - "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-hast": { - "version": "13.2.1", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", - "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "@ungap/structured-clone": "^1.0.0", - "devlop": "^1.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "trim-lines": "^3.0.0", - "unist-util-position": "^5.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-markdown": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", - "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "longest-streak": "^3.0.0", - "mdast-util-phrasing": "^4.0.0", - "mdast-util-to-string": "^4.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-decode-string": "^2.0.0", - "unist-util-visit": "^5.0.0", - "zwitch": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", - "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdn-data": { - "version": "2.27.1", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", - "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", - "license": "CC0-1.0" - }, - "node_modules/mdurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", - "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", - "license": "MIT" - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/mermaid": { - "version": "11.13.0", - "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.13.0.tgz", - "integrity": "sha512-fEnci+Immw6lKMFI8sqzjlATTyjLkRa6axrEgLV2yHTfv8r+h1wjFbV6xeRtd4rUV1cS4EpR9rwp3Rci7TRWDw==", - "license": "MIT", - "dependencies": { - "@braintree/sanitize-url": "^7.1.1", - "@iconify/utils": "^3.0.2", - "@mermaid-js/parser": "^1.0.1", - "@types/d3": "^7.4.3", - "@upsetjs/venn.js": "^2.0.0", - "cytoscape": "^3.33.1", - "cytoscape-cose-bilkent": "^4.1.0", - "cytoscape-fcose": "^2.2.0", - "d3": "^7.9.0", - "d3-sankey": "^0.12.3", - "dagre-d3-es": "7.0.14", - "dayjs": "^1.11.19", - "dompurify": "^3.3.1", - "katex": "^0.16.25", - "khroma": "^2.1.0", - "lodash-es": "^4.17.23", - "marked": "^16.3.0", - "roughjs": "^4.6.6", - "stylis": "^4.3.6", - "ts-dedent": "^2.2.0", - "uuid": "^11.1.0" - } - }, - "node_modules/micromark": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", - "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "@types/debug": "^4.0.0", - "debug": "^4.0.0", - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "micromark-core-commonmark": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-combine-extensions": "^2.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-subtokenize": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-core-commonmark": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", - "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "micromark-factory-destination": "^2.0.0", - "micromark-factory-label": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-factory-title": "^2.0.0", - "micromark-factory-whitespace": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-html-tag-name": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-subtokenize": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-destination": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", - "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-label": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", - "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-space": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", - "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-title": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", - "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-whitespace": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", - "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-chunked": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", - "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-classify-character": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", - "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-combine-extensions": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", - "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-chunked": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-decode-numeric-character-reference": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", - "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-decode-string": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", - "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-encode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", - "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-html-tag-name": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", - "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-normalize-identifier": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", - "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-resolve-all": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", - "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-sanitize-uri": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", - "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-subtokenize": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", - "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-types": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", - "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/mlly": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.1.tgz", - "integrity": "sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ==", - "license": "MIT", - "dependencies": { - "acorn": "^8.16.0", - "pathe": "^2.0.3", - "pkg-types": "^1.3.1", - "ufo": "^1.6.3" - } - }, - "node_modules/mlly/node_modules/confbox": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", - "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", - "license": "MIT" - }, - "node_modules/mlly/node_modules/pkg-types": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", - "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", - "license": "MIT", - "dependencies": { - "confbox": "^0.1.8", - "mlly": "^1.7.4", - "pathe": "^2.0.1" - } - }, - "node_modules/monaco-editor": { - "version": "0.55.1", - "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", - "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", - "license": "MIT", - "dependencies": { - "dompurify": "3.2.7", - "marked": "14.0.0" - } - }, - "node_modules/monaco-editor/node_modules/dompurify": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", - "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", - "license": "(MPL-2.0 OR Apache-2.0)", - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" - } - }, - "node_modules/monaco-editor/node_modules/marked": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", - "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", - "license": "MIT", - "bin": { - "marked": "bin/marked.js" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/mrmime": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", - "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/muggle-string": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", - "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/nanotar": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/nanotar/-/nanotar-0.3.0.tgz", - "integrity": "sha512-Kv2JYYiCzt16Kt5QwAc9BFG89xfPNBx+oQL4GQXD9nLqPkZBiNaqaCWtwnbk/q7UVsTYevvM1b0UF8zmEI4pCg==", - "license": "MIT" - }, - "node_modules/node-fetch-native": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", - "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", - "license": "MIT" - }, - "node_modules/node-releases": { - "version": "2.0.36", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", - "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", - "license": "MIT" - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/nypm": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz", - "integrity": "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "citty": "^0.2.0", - "pathe": "^2.0.3", - "tinyexec": "^1.0.2" - }, - "bin": { - "nypm": "dist/cli.mjs" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/nypm/node_modules/citty": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.1.tgz", - "integrity": "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==", - "license": "MIT", - "optional": true - }, - "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT" - }, - "node_modules/ofetch": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.5.1.tgz", - "integrity": "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==", - "license": "MIT", - "dependencies": { - "destr": "^2.0.5", - "node-fetch-native": "^1.6.7", - "ufo": "^1.6.1" - } - }, - "node_modules/ohash": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", - "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", - "license": "MIT" - }, - "node_modules/oniguruma-parser": { - "version": "0.12.1", - "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz", - "integrity": "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==", - "license": "MIT" - }, - "node_modules/oniguruma-to-es": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.4.tgz", - "integrity": "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==", - "license": "MIT", - "dependencies": { - "oniguruma-parser": "^0.12.1", - "regex": "^6.0.1", - "regex-recursion": "^6.0.2" - } - }, - "node_modules/open": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz", - "integrity": "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==", - "license": "MIT", - "dependencies": { - "default-browser": "^5.4.0", - "define-lazy-prop": "^3.0.0", - "is-in-ssh": "^1.0.0", - "is-inside-container": "^1.0.0", - "powershell-utils": "^0.1.0", - "wsl-utils": "^0.3.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/oxc-parser": { - "version": "0.115.0", - "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.115.0.tgz", - "integrity": "sha512-2w7Xn3CbS/zwzSY82S5WLemrRu3CT57uF7Lx8llrE/2bul6iMTcJE4Rbls7GDNbLn3ttATI68PfOz2Pt3KZ2cQ==", - "license": "MIT", - "dependencies": { - "@oxc-project/types": "^0.115.0" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/sponsors/Boshen" - }, - "optionalDependencies": { - "@oxc-parser/binding-android-arm-eabi": "0.115.0", - "@oxc-parser/binding-android-arm64": "0.115.0", - "@oxc-parser/binding-darwin-arm64": "0.115.0", - "@oxc-parser/binding-darwin-x64": "0.115.0", - "@oxc-parser/binding-freebsd-x64": "0.115.0", - "@oxc-parser/binding-linux-arm-gnueabihf": "0.115.0", - "@oxc-parser/binding-linux-arm-musleabihf": "0.115.0", - "@oxc-parser/binding-linux-arm64-gnu": "0.115.0", - "@oxc-parser/binding-linux-arm64-musl": "0.115.0", - "@oxc-parser/binding-linux-ppc64-gnu": "0.115.0", - "@oxc-parser/binding-linux-riscv64-gnu": "0.115.0", - "@oxc-parser/binding-linux-riscv64-musl": "0.115.0", - "@oxc-parser/binding-linux-s390x-gnu": "0.115.0", - "@oxc-parser/binding-linux-x64-gnu": "0.115.0", - "@oxc-parser/binding-linux-x64-musl": "0.115.0", - "@oxc-parser/binding-openharmony-arm64": "0.115.0", - "@oxc-parser/binding-wasm32-wasi": "0.115.0", - "@oxc-parser/binding-win32-arm64-msvc": "0.115.0", - "@oxc-parser/binding-win32-ia32-msvc": "0.115.0", - "@oxc-parser/binding-win32-x64-msvc": "0.115.0" - } - }, - "node_modules/oxc-walker": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/oxc-walker/-/oxc-walker-0.7.0.tgz", - "integrity": "sha512-54B4KUhrzbzc4sKvKwVYm7E2PgeROpGba0/2nlNZMqfDyca+yOor5IMb4WLGBatGDT0nkzYdYuzylg7n3YfB7A==", - "license": "MIT", - "dependencies": { - "magic-regexp": "^0.10.0" - }, - "peerDependencies": { - "oxc-parser": ">=0.98.0" - } - }, - "node_modules/p-map": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", - "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/package-manager-detector": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", - "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", - "license": "MIT" - }, - "node_modules/pako": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", - "license": "(MIT AND Zlib)" - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-browserify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", - "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", - "license": "MIT" - }, - "node_modules/path-data-parser": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", - "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", - "license": "MIT" - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "license": "MIT" - }, - "node_modules/pdf-lib": { - "version": "1.17.1", - "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz", - "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==", - "license": "MIT", - "dependencies": { - "@pdf-lib/standard-fonts": "^1.0.0", - "@pdf-lib/upng": "^1.0.1", - "pako": "^1.0.11", - "tslib": "^1.11.1" - } - }, - "node_modules/pdf-lib/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "license": "0BSD" - }, - "node_modules/perfect-debounce": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", - "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pkg-types": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", - "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", - "license": "MIT", - "dependencies": { - "confbox": "^0.2.2", - "exsolve": "^1.0.7", - "pathe": "^2.0.3" - } - }, - "node_modules/plantuml-encoder": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/plantuml-encoder/-/plantuml-encoder-1.4.0.tgz", - "integrity": "sha512-sxMwpDw/ySY1WB2CE3+IdMuEcWibJ72DDOsXLkSmEaSzwEUaYBT6DWgOfBiHGCux4q433X6+OEFWjlVqp7gL6g==", - "license": "MIT" - }, - "node_modules/points-on-curve": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", - "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", - "license": "MIT" - }, - "node_modules/points-on-path": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz", - "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", - "license": "MIT", - "dependencies": { - "path-data-parser": "0.1.0", - "points-on-curve": "0.2.0" - } - }, - "node_modules/popmotion": { - "version": "11.0.5", - "resolved": "https://registry.npmjs.org/popmotion/-/popmotion-11.0.5.tgz", - "integrity": "sha512-la8gPM1WYeFznb/JqF4GiTkRRPZsfaj2+kCxqQgr2MJylMmIKUwBfWW8Wa5fml/8gmtlD5yI01MP1QCZPWmppA==", - "license": "MIT", - "dependencies": { - "framesync": "6.1.2", - "hey-listen": "^1.0.8", - "style-value-types": "5.1.2", - "tslib": "2.4.0" - } - }, - "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-nested": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-7.0.2.tgz", - "integrity": "sha512-5osppouFc0VR9/VYzYxO03VaDa3e8F23Kfd6/9qcZTUI8P58GIYlArOET2Wq0ywSl2o2PjELhYOFI4W7l5QHKw==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "postcss-selector-parser": "^7.0.0" - }, - "engines": { - "node": ">=18.0" - }, - "peerDependencies": { - "postcss": "^8.2.14" - } - }, - "node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/powershell-utils": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", - "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==", - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pptxgenjs": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pptxgenjs/-/pptxgenjs-4.0.1.tgz", - "integrity": "sha512-TeJISr8wouAuXw4C1F/mC33xbZs/FuEG6nH9FG1Zj+nuPcGMP5YRHl6X+j3HSUnS1f3at6k75ZZXPMZlA5Lj9A==", - "license": "MIT", - "dependencies": { - "@types/node": "^22.8.1", - "https": "^1.0.0", - "image-size": "^1.2.1", - "jszip": "^3.10.1" - } - }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "license": "MIT" - }, - "node_modules/prompts": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", - "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", - "license": "MIT", - "dependencies": { - "kleur": "^3.0.3", - "sisteransi": "^1.0.5" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/property-information": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", - "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/public-ip": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/public-ip/-/public-ip-8.0.0.tgz", - "integrity": "sha512-XzVyz98rNQiTRciAC+I4w45fWWxM9KKedDGNtH4unPwBcWo2Y9n7kgPXqlTiWqKN0EFlIIU1i8yrWOy9mxgZ8g==", - "license": "MIT", - "dependencies": { - "dns-socket": "^4.2.2", - "is-ip": "^5.0.1" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/punycode.js": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", - "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/quansync": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", - "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/antfu" - }, - { - "type": "individual", - "url": "https://github.com/sponsors/sxzz" - } - ], - "license": "MIT" - }, - "node_modules/queue": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", - "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", - "license": "MIT", - "dependencies": { - "inherits": "~2.0.3" - } - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/rc9": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.0.tgz", - "integrity": "sha512-MGOue0VqscKWQ104udASX/3GYDcKyPI4j4F8gu/jHHzglpmy9a/anZK3PNe8ug6aZFl+9GxLtdhe3kVZuMaQbA==", - "license": "MIT", - "optional": true, - "dependencies": { - "defu": "^6.1.4", - "destr": "^2.0.5" - } - }, - "node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/readdirp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", - "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", - "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/recordrtc": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/recordrtc/-/recordrtc-5.6.2.tgz", - "integrity": "sha512-1QNKKNtl7+KcwD1lyOgP3ZlbiJ1d0HtXnypUy7yq49xEERxk31PHvE9RCciDrulPCY7WJ+oz0R9hpNxgsIurGQ==", - "license": "MIT" - }, - "node_modules/regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", - "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", - "license": "MIT", - "dependencies": { - "regex-utilities": "^2.3.0" - } - }, - "node_modules/regex-recursion": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", - "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", - "license": "MIT", - "dependencies": { - "regex-utilities": "^2.3.0" - } - }, - "node_modules/regex-utilities": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", - "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", - "license": "MIT" - }, - "node_modules/regexp-tree": { - "version": "0.1.27", - "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", - "integrity": "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==", - "license": "MIT", - "bin": { - "regexp-tree": "bin/regexp-tree" - } - }, - "node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/resolve-global": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/resolve-global/-/resolve-global-2.0.0.tgz", - "integrity": "sha512-gnAQ0Q/KkupGkuiMyX4L0GaBV8iFwlmoXsMtOz+DFTaKmHhOO/dSlP1RMKhpvHv/dh6K/IQkowGJBqUG0NfBUw==", - "license": "MIT", - "dependencies": { - "global-directory": "^4.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/resolve-global/node_modules/global-directory": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", - "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==", - "license": "MIT", - "dependencies": { - "ini": "4.1.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/resolve-global/node_modules/ini": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", - "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/robust-predicates": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", - "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", - "license": "Unlicense" - }, - "node_modules/rollup": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", - "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.59.0", - "@rollup/rollup-android-arm64": "4.59.0", - "@rollup/rollup-darwin-arm64": "4.59.0", - "@rollup/rollup-darwin-x64": "4.59.0", - "@rollup/rollup-freebsd-arm64": "4.59.0", - "@rollup/rollup-freebsd-x64": "4.59.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", - "@rollup/rollup-linux-arm-musleabihf": "4.59.0", - "@rollup/rollup-linux-arm64-gnu": "4.59.0", - "@rollup/rollup-linux-arm64-musl": "4.59.0", - "@rollup/rollup-linux-loong64-gnu": "4.59.0", - "@rollup/rollup-linux-loong64-musl": "4.59.0", - "@rollup/rollup-linux-ppc64-gnu": "4.59.0", - "@rollup/rollup-linux-ppc64-musl": "4.59.0", - "@rollup/rollup-linux-riscv64-gnu": "4.59.0", - "@rollup/rollup-linux-riscv64-musl": "4.59.0", - "@rollup/rollup-linux-s390x-gnu": "4.59.0", - "@rollup/rollup-linux-x64-gnu": "4.59.0", - "@rollup/rollup-linux-x64-musl": "4.59.0", - "@rollup/rollup-openbsd-x64": "4.59.0", - "@rollup/rollup-openharmony-arm64": "4.59.0", - "@rollup/rollup-win32-arm64-msvc": "4.59.0", - "@rollup/rollup-win32-ia32-msvc": "4.59.0", - "@rollup/rollup-win32-x64-gnu": "4.59.0", - "@rollup/rollup-win32-x64-msvc": "4.59.0", - "fsevents": "~2.3.2" - } - }, - "node_modules/roughjs": { - "version": "4.6.6", - "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", - "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==", - "license": "MIT", - "dependencies": { - "hachure-fill": "^0.5.2", - "path-data-parser": "^0.1.0", - "points-on-curve": "^0.2.0", - "points-on-path": "^0.2.1" - } - }, - "node_modules/run-applescript": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", - "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/rw": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", - "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", - "license": "BSD-3-Clause" - }, - "node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/scule": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", - "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", - "license": "MIT" - }, - "node_modules/section-matter": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", - "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", - "license": "MIT", - "dependencies": { - "extend-shallow": "^2.0.1", - "kind-of": "^6.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/section-matter/node_modules/kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/setimmediate": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", - "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", - "license": "MIT" - }, - "node_modules/shiki-magic-move": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/shiki-magic-move/-/shiki-magic-move-1.3.0.tgz", - "integrity": "sha512-QF3OmGtROCGI3HGaB5hAlB6GPnzrxblZg761wg1NhsWKqb79HCeeVVhJE6fZeU1x/6ZOh7S8o9dBWf6eJZYc6A==", - "license": "MIT", - "dependencies": { - "diff-match-patch-es": "^1.0.1", - "ohash": "^2.0.11" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - }, - "peerDependencies": { - "react": "^18.2.0 || ^19.0.0", - "shiki": "^1.0.0 || ^2.0.0 || ^3.0.0", - "solid-js": "^1.9.1", - "svelte": "^5.0.0-0", - "vue": "^3.4.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "shiki": { - "optional": true - }, - "solid-js": { - "optional": true - }, - "svelte": { - "optional": true - }, - "vue": { - "optional": true - } - } - }, - "node_modules/sirv": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", - "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", - "license": "MIT", - "dependencies": { - "@polka/url": "^1.0.0-next.24", - "mrmime": "^2.0.0", - "totalist": "^3.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/sisteransi": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "license": "MIT" - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/space-separated-tokens": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", - "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "license": "BSD-3-Clause" - }, - "node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/stringify-entities": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", - "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", - "license": "MIT", - "dependencies": { - "character-entities-html4": "^2.0.0", - "character-entities-legacy": "^3.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-bom-string": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", - "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/style-value-types": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/style-value-types/-/style-value-types-5.1.2.tgz", - "integrity": "sha512-Vs9fNreYF9j6W2VvuDTP7kepALi7sk0xtk2Tu8Yxi9UoajJdEVpNpCov0HsLTqXvNGKX+Uv09pkozVITi1jf3Q==", - "license": "MIT", - "dependencies": { - "hey-listen": "^1.0.8", - "tslib": "2.4.0" - } - }, - "node_modules/stylis": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", - "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", - "license": "MIT" - }, - "node_modules/super-regex": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/super-regex/-/super-regex-0.2.0.tgz", - "integrity": "sha512-WZzIx3rC1CvbMDloLsVw0lkZVKJWbrkJ0k1ghKFmcnPrW1+jWbgTkTEWVtD9lMdmI4jZEz40+naBxl1dCUhXXw==", - "license": "MIT", - "dependencies": { - "clone-regexp": "^3.0.0", - "function-timeout": "^0.1.0", - "time-span": "^5.1.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/time-span": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/time-span/-/time-span-5.1.0.tgz", - "integrity": "sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==", - "license": "MIT", - "dependencies": { - "convert-hrtime": "^5.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/tinyexec": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", - "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/totalist": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", - "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/trim-lines": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", - "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/ts-dedent": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", - "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", - "license": "MIT", - "engines": { - "node": ">=6.10" - } - }, - "node_modules/tslib": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", - "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", - "license": "0BSD" - }, - "node_modules/twoslash": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/twoslash/-/twoslash-0.3.6.tgz", - "integrity": "sha512-VuI5OKl+MaUO9UIW3rXKoPgHI3X40ZgB/j12VY6h98Ae1mCBihjPvhOPeJWlxCYcmSbmeZt5ZKkK0dsVtp+6pA==", - "license": "MIT", - "dependencies": { - "@typescript/vfs": "^1.6.2", - "twoslash-protocol": "0.3.6" - }, - "peerDependencies": { - "typescript": "^5.5.0" - } - }, - "node_modules/twoslash-protocol": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/twoslash-protocol/-/twoslash-protocol-0.3.6.tgz", - "integrity": "sha512-FHGsJ9Q+EsNr5bEbgG3hnbkvEBdW5STgPU824AHUjB4kw0Dn4p8tABT7Ncg1Ie6V0+mDg3Qpy41VafZXcQhWMA==", - "license": "MIT" - }, - "node_modules/twoslash-vue": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/twoslash-vue/-/twoslash-vue-0.3.6.tgz", - "integrity": "sha512-HXYxU+Y7jZiMXJN4980fQNMYflLD8uqKey1qVW5ri8bqYTm2t5ILmOoCOli7esdCHlMq4/No3iQUWBWDhZNs9w==", - "license": "MIT", - "dependencies": { - "@vue/language-core": "^3.2.0", - "twoslash": "0.3.6", - "twoslash-protocol": "0.3.6" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - }, - "peerDependencies": { - "typescript": "^5.5.0" - } - }, - "node_modules/type-level-regexp": { - "version": "0.1.17", - "resolved": "https://registry.npmjs.org/type-level-regexp/-/type-level-regexp-0.1.17.tgz", - "integrity": "sha512-wTk4DH3cxwk196uGLK/E9pE45aLfeKJacKmcEgEOA/q5dnPGNxXt0cfYdFxb57L+sEpf1oJH4Dnx/pnRcku9jg==", - "license": "MIT" - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/uc.micro": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", - "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", - "license": "MIT" - }, - "node_modules/ufo": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", - "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", - "license": "MIT" - }, - "node_modules/unconfig": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/unconfig/-/unconfig-7.5.0.tgz", - "integrity": "sha512-oi8Qy2JV4D3UQ0PsopR28CzdQ3S/5A1zwsUwp/rosSbfhJ5z7b90bIyTwi/F7hCLD4SGcZVjDzd4XoUQcEanvA==", - "license": "MIT", - "dependencies": { - "@quansync/fs": "^1.0.0", - "defu": "^6.1.4", - "jiti": "^2.6.1", - "quansync": "^1.0.0", - "unconfig-core": "7.5.0" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/unconfig-core": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/unconfig-core/-/unconfig-core-7.5.0.tgz", - "integrity": "sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==", - "license": "MIT", - "dependencies": { - "@quansync/fs": "^1.0.0", - "quansync": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/unconfig-core/node_modules/quansync": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/quansync/-/quansync-1.0.0.tgz", - "integrity": "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/antfu" - }, - { - "type": "individual", - "url": "https://github.com/sponsors/sxzz" - } - ], - "license": "MIT" - }, - "node_modules/unconfig/node_modules/quansync": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/quansync/-/quansync-1.0.0.tgz", - "integrity": "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/antfu" - }, - { - "type": "individual", - "url": "https://github.com/sponsors/sxzz" - } - ], - "license": "MIT" - }, - "node_modules/unctx": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/unctx/-/unctx-2.5.0.tgz", - "integrity": "sha512-p+Rz9x0R7X+CYDkT+Xg8/GhpcShTlU8n+cf9OtOEf7zEQsNcCZO1dPKNRDqvUTaq+P32PMMkxWHwfrxkqfqAYg==", - "license": "MIT", - "optional": true, - "dependencies": { - "acorn": "^8.15.0", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21", - "unplugin": "^2.3.11" - } - }, - "node_modules/unctx/node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "license": "MIT", - "optional": true, - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "license": "MIT" - }, - "node_modules/unhead": { - "version": "2.1.12", - "resolved": "https://registry.npmjs.org/unhead/-/unhead-2.1.12.tgz", - "integrity": "sha512-iTHdWD9ztTunOErtfUFk6Wr11BxvzumcYJ0CzaSCBUOEtg+DUZ9+gnE99i8QkLFT2q1rZD48BYYGXpOZVDLYkA==", - "license": "MIT", - "dependencies": { - "hookable": "^6.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/harlan-zw" - } - }, - "node_modules/unist-util-is": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", - "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-position": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", - "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-stringify-position": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", - "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", - "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit-parents": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", - "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unocss": { - "version": "66.6.6", - "resolved": "https://registry.npmjs.org/unocss/-/unocss-66.6.6.tgz", - "integrity": "sha512-PRKK945e2oZKHV664MA5Z9CDHbvY/V79IvTOUWKZ514jpl3UsJU3sS+skgxmKJSmwrWvXE5OVcmPthJrD/7vxg==", - "license": "MIT", - "dependencies": { - "@unocss/cli": "66.6.6", - "@unocss/core": "66.6.6", - "@unocss/preset-attributify": "66.6.6", - "@unocss/preset-icons": "66.6.6", - "@unocss/preset-mini": "66.6.6", - "@unocss/preset-tagify": "66.6.6", - "@unocss/preset-typography": "66.6.6", - "@unocss/preset-uno": "66.6.6", - "@unocss/preset-web-fonts": "66.6.6", - "@unocss/preset-wind": "66.6.6", - "@unocss/preset-wind3": "66.6.6", - "@unocss/preset-wind4": "66.6.6", - "@unocss/transformer-attributify-jsx": "66.6.6", - "@unocss/transformer-compile-class": "66.6.6", - "@unocss/transformer-directives": "66.6.6", - "@unocss/transformer-variant-group": "66.6.6", - "@unocss/vite": "66.6.6" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - }, - "peerDependencies": { - "@unocss/astro": "66.6.6", - "@unocss/postcss": "66.6.6", - "@unocss/webpack": "66.6.6" - }, - "peerDependenciesMeta": { - "@unocss/astro": { - "optional": true - }, - "@unocss/postcss": { - "optional": true - }, - "@unocss/webpack": { - "optional": true - } - } - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/unplugin": { - "version": "2.3.11", - "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", - "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==", - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "acorn": "^8.15.0", - "picomatch": "^4.0.3", - "webpack-virtual-modules": "^0.6.2" - }, - "engines": { - "node": ">=18.12.0" - } - }, - "node_modules/unplugin-icons": { - "version": "23.0.1", - "resolved": "https://registry.npmjs.org/unplugin-icons/-/unplugin-icons-23.0.1.tgz", - "integrity": "sha512-rv0XEJepajKzDLvRUWASM8K+8+/CCfZn2jtogXqg6RIp7kpatRc/aFrVJn8ANQA09e++lPEEv9yX8cC9enc+QQ==", - "license": "MIT", - "dependencies": { - "@antfu/install-pkg": "^1.1.0", - "@iconify/utils": "^3.1.0", - "local-pkg": "^1.1.2", - "obug": "^2.1.1", - "unplugin": "^2.3.11" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - }, - "peerDependencies": { - "@svgr/core": ">=7.0.0", - "@svgx/core": "^1.0.1", - "@vue/compiler-sfc": "^3.0.2", - "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0" - }, - "peerDependenciesMeta": { - "@svgr/core": { - "optional": true - }, - "@svgx/core": { - "optional": true - }, - "@vue/compiler-sfc": { - "optional": true - }, - "svelte": { - "optional": true - } - } - }, - "node_modules/unplugin-utils": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.1.tgz", - "integrity": "sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==", - "license": "MIT", - "dependencies": { - "pathe": "^2.0.3", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=20.19.0" - }, - "funding": { - "url": "https://github.com/sponsors/sxzz" - } - }, - "node_modules/unplugin-vue-components": { - "version": "31.0.0", - "resolved": "https://registry.npmjs.org/unplugin-vue-components/-/unplugin-vue-components-31.0.0.tgz", - "integrity": "sha512-4ULwfTZTLuWJ7+S9P7TrcStYLsSRkk6vy2jt/WTfgUEUb0nW9//xxmrfhyHUEVpZ2UKRRwfRb8Yy15PDbVZf+Q==", - "license": "MIT", - "dependencies": { - "chokidar": "^5.0.0", - "local-pkg": "^1.1.2", - "magic-string": "^0.30.21", - "mlly": "^1.8.0", - "obug": "^2.1.1", - "picomatch": "^4.0.3", - "tinyglobby": "^0.2.15", - "unplugin": "^2.3.11", - "unplugin-utils": "^0.3.1" - }, - "engines": { - "node": ">=20.19.0" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - }, - "peerDependencies": { - "@nuxt/kit": "^3.2.2 || ^4.0.0", - "vue": "^3.0.0" - }, - "peerDependenciesMeta": { - "@nuxt/kit": { - "optional": true - } - } - }, - "node_modules/unplugin-vue-markdown": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/unplugin-vue-markdown/-/unplugin-vue-markdown-30.0.0.tgz", - "integrity": "sha512-FVdKAb7jmZslfdkOCfm6jxHaUafltBpOXdoLvKY+0I0EeMmhxXTSzeDldwXFJeV0IH8LyIXIiU29E6gv02WJFQ==", - "license": "MIT", - "dependencies": { - "@mdit-vue/plugin-component": "^3.0.2", - "@mdit-vue/plugin-frontmatter": "^3.0.2", - "@mdit-vue/types": "^3.0.2", - "markdown-exit": "^1.0.0-beta.8", - "unplugin": "^2.3.10", - "unplugin-utils": "^0.3.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - }, - "peerDependencies": { - "vite": "^2.0.0 || ^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.0 || ^7.0.0" - } - }, - "node_modules/untun": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/untun/-/untun-0.1.3.tgz", - "integrity": "sha512-4luGP9LMYszMRZwsvyUd9MrxgEGZdZuZgpVQHEEX0lCYFESasVRvZd0EYpCkOIbJKHMuv0LskpXc/8Un+MJzEQ==", - "license": "MIT", - "dependencies": { - "citty": "^0.1.5", - "consola": "^3.2.3", - "pathe": "^1.1.1" - }, - "bin": { - "untun": "bin/untun.mjs" - } - }, - "node_modules/untun/node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "license": "MIT" - }, - "node_modules/untyped": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/untyped/-/untyped-2.0.0.tgz", - "integrity": "sha512-nwNCjxJTjNuLCgFr42fEak5OcLuB3ecca+9ksPFNvtfYSLpjf+iJqSIaSnIile6ZPbKYxI5k2AfXqeopGudK/g==", - "license": "MIT", - "optional": true, - "dependencies": { - "citty": "^0.1.6", - "defu": "^6.1.4", - "jiti": "^2.4.2", - "knitwork": "^1.2.0", - "scule": "^1.3.0" - }, - "bin": { - "untyped": "dist/cli.mjs" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/uqr": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/uqr/-/uqr-0.1.2.tgz", - "integrity": "sha512-MJu7ypHq6QasgF5YRTjqscSzQp/W11zoUk6kvmlH+fmWEs63Y0Eib13hYFwAzagRJcVY8WVnlV+eBDUGMJ5IbA==", - "license": "MIT" - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/esm/bin/uuid" - } - }, - "node_modules/vfile": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", - "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile-message": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", - "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", - "license": "MIT", - "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vite-dev-rpc": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/vite-dev-rpc/-/vite-dev-rpc-1.1.0.tgz", - "integrity": "sha512-pKXZlgoXGoE8sEKiKJSng4hI1sQ4wi5YT24FCrwrLt6opmkjlqPPVmiPWWJn8M8byMxRGzp1CrFuqQs4M/Z39A==", - "license": "MIT", - "dependencies": { - "birpc": "^2.4.0", - "vite-hot-client": "^2.1.0" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - }, - "peerDependencies": { - "vite": "^2.9.0 || ^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.1 || ^7.0.0-0" - } - }, - "node_modules/vite-hot-client": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/vite-hot-client/-/vite-hot-client-2.1.0.tgz", - "integrity": "sha512-7SpgZmU7R+dDnSmvXE1mfDtnHLHQSisdySVR7lO8ceAXvM0otZeuQQ6C8LrS5d/aYyP/QZ0hI0L+dIPrm4YlFQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" - }, - "peerDependencies": { - "vite": "^2.6.0 || ^3.0.0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0" - } - }, - "node_modules/vite-plugin-inspect": { - "version": "11.3.3", - "resolved": "https://registry.npmjs.org/vite-plugin-inspect/-/vite-plugin-inspect-11.3.3.tgz", - "integrity": "sha512-u2eV5La99oHoYPHE6UvbwgEqKKOQGz86wMg40CCosP6q8BkB6e5xPneZfYagK4ojPJSj5anHCrnvC20DpwVdRA==", - "license": "MIT", - "dependencies": { - "ansis": "^4.1.0", - "debug": "^4.4.1", - "error-stack-parser-es": "^1.0.5", - "ohash": "^2.0.11", - "open": "^10.2.0", - "perfect-debounce": "^2.0.0", - "sirv": "^3.0.1", - "unplugin-utils": "^0.3.0", - "vite-dev-rpc": "^1.1.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - }, - "peerDependencies": { - "vite": "^6.0.0 || ^7.0.0-0" - }, - "peerDependenciesMeta": { - "@nuxt/kit": { - "optional": true - } - } - }, - "node_modules/vite-plugin-inspect/node_modules/open": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", - "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", - "license": "MIT", - "dependencies": { - "default-browser": "^5.2.1", - "define-lazy-prop": "^3.0.0", - "is-inside-container": "^1.0.0", - "wsl-utils": "^0.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/vite-plugin-inspect/node_modules/wsl-utils": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", - "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", - "license": "MIT", - "dependencies": { - "is-wsl": "^3.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/vite-plugin-remote-assets": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/vite-plugin-remote-assets/-/vite-plugin-remote-assets-2.1.0.tgz", - "integrity": "sha512-8ajL5WG5BmYcC8zxeLOa3byCUG2AopKDAdNK7zStPHaRYYz1mxXBaeNFLu6vTEXj8UmXAsb5WlEmBBYwtlPEwA==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.1", - "magic-string": "^0.30.17", - "node-fetch-native": "^1.6.7", - "ohash": "^2.0.11" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - }, - "peerDependencies": { - "vite": ">=5.0.0" - } - }, - "node_modules/vite-plugin-static-copy": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-3.2.0.tgz", - "integrity": "sha512-g2k9z8B/1Bx7D4wnFjPLx9dyYGrqWMLTpwTtPHhcU+ElNZP2O4+4OsyaficiDClus0dzVhdGvoGFYMJxoXZ12Q==", - "license": "MIT", - "dependencies": { - "chokidar": "^3.6.0", - "p-map": "^7.0.4", - "picocolors": "^1.1.1", - "tinyglobby": "^0.2.15" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/sapphi-red" - }, - "peerDependencies": { - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" - } - }, - "node_modules/vite-plugin-static-copy/node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/vite-plugin-static-copy/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/vite-plugin-static-copy/node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/vite-plugin-vue-server-ref": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/vite-plugin-vue-server-ref/-/vite-plugin-vue-server-ref-1.0.0.tgz", - "integrity": "sha512-6d/JZVrnETM0xa0AVyEcI1bXFpEzQ1EPU5N/gDa7NtXo/7nfJWJhezcWq82Jih6Vf8xtGJjhi1w19AcXAtwmAg==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "klona": "^2.0.6", - "mlly": "^1.7.4", - "ufo": "^1.5.4" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - }, - "peerDependencies": { - "vite": ">=2.0.0", - "vue": "^3.0.0" - } - }, - "node_modules/vite/node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/vitefu": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.2.tgz", - "integrity": "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==", - "license": "MIT", - "workspaces": [ - "tests/deps/*", - "tests/projects/*", - "tests/projects/workspace/packages/*" - ], - "peerDependencies": { - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" - }, - "peerDependenciesMeta": { - "vite": { - "optional": true - } - } - }, - "node_modules/vscode-jsonrpc": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", - "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/vscode-languageserver": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz", - "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==", - "license": "MIT", - "dependencies": { - "vscode-languageserver-protocol": "3.17.5" - }, - "bin": { - "installServerIntoExtension": "bin/installServerIntoExtension" - } - }, - "node_modules/vscode-languageserver-protocol": { - "version": "3.17.5", - "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", - "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", - "license": "MIT", - "dependencies": { - "vscode-jsonrpc": "8.2.0", - "vscode-languageserver-types": "3.17.5" - } - }, - "node_modules/vscode-languageserver-textdocument": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", - "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", - "license": "MIT" - }, - "node_modules/vscode-languageserver-types": { - "version": "3.17.5", - "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", - "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", - "license": "MIT" - }, - "node_modules/vscode-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", - "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", - "license": "MIT" - }, - "node_modules/vue": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz", - "integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==", - "license": "MIT", - "dependencies": { - "@vue/compiler-dom": "3.5.30", - "@vue/compiler-sfc": "3.5.30", - "@vue/runtime-dom": "3.5.30", - "@vue/server-renderer": "3.5.30", - "@vue/shared": "3.5.30" - }, - "peerDependencies": { - "typescript": "*" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/vue-resize": { - "version": "2.0.0-alpha.1", - "resolved": "https://registry.npmjs.org/vue-resize/-/vue-resize-2.0.0-alpha.1.tgz", - "integrity": "sha512-7+iqOueLU7uc9NrMfrzbG8hwMqchfVfSzpVlCMeJQe4pyibqyoifDNbKTZvwxZKDvGkB+PdFeKvnGZMoEb8esg==", - "license": "MIT", - "peerDependencies": { - "vue": "^3.0.0" - } - }, - "node_modules/vue-router": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.3.tgz", - "integrity": "sha512-nG1c7aAFac7NYj8Hluo68WyWfc41xkEjaR0ViLHCa3oDvTQ/nIuLJlXJX1NUPw/DXzx/8+OKMng045HHQKQKWw==", - "license": "MIT", - "dependencies": { - "@babel/generator": "^7.28.6", - "@vue-macros/common": "^3.1.1", - "@vue/devtools-api": "^8.0.6", - "ast-walker-scope": "^0.8.3", - "chokidar": "^5.0.0", - "json5": "^2.2.3", - "local-pkg": "^1.1.2", - "magic-string": "^0.30.21", - "mlly": "^1.8.0", - "muggle-string": "^0.4.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "scule": "^1.3.0", - "tinyglobby": "^0.2.15", - "unplugin": "^3.0.0", - "unplugin-utils": "^0.3.1", - "yaml": "^2.8.2" - }, - "funding": { - "url": "https://github.com/sponsors/posva" - }, - "peerDependencies": { - "@pinia/colada": ">=0.21.2", - "@vue/compiler-sfc": "^3.5.17", - "pinia": "^3.0.4", - "vue": "^3.5.0" - }, - "peerDependenciesMeta": { - "@pinia/colada": { - "optional": true - }, - "@vue/compiler-sfc": { - "optional": true - }, - "pinia": { - "optional": true - } - } - }, - "node_modules/vue-router/node_modules/unplugin": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.0.0.tgz", - "integrity": "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==", - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "picomatch": "^4.0.3", - "webpack-virtual-modules": "^0.6.2" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/webpack-virtual-modules": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", - "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", - "license": "MIT" - }, - "node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "license": "MIT" - }, - "node_modules/wrap-ansi/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/wsl-utils": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz", - "integrity": "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==", - "license": "MIT", - "dependencies": { - "is-wsl": "^3.1.0", - "powershell-utils": "^0.1.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "license": "ISC" - }, - "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, - "node_modules/yargs": { - "version": "18.0.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", - "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", - "license": "MIT", - "dependencies": { - "cliui": "^9.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "string-width": "^7.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^22.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" - } - }, - "node_modules/yargs-parser": { - "version": "22.0.0", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", - "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", - "license": "ISC", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" - } - }, - "node_modules/yargs/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/yargs/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "license": "MIT" - }, - "node_modules/yargs/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/yargs/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/zwitch": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", - "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - } - } -} diff --git a/images/package.json b/images/package.json deleted file mode 100644 index 59cf2f8..0000000 --- a/images/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "devcell-tools", - "version": "1.0.0", - "description": "npm tools for devcell", - "private": true, - "dependencies": { - } -} diff --git a/images/pyproject.toml b/images/pyproject.toml deleted file mode 100644 index a396883..0000000 --- a/images/pyproject.toml +++ /dev/null @@ -1,13 +0,0 @@ -[project] -name = "devcell-tools" -version = "1.0.0" -description = "Global Python tools for devcell" -requires-python = ">=3.13" -dependencies = [] - -[build-system] -requires = ["setuptools>=61.0", "wheel"] -build-backend = "setuptools.build_meta" - -[tool.setuptools] -packages = [] diff --git a/internal/auth/kube/kube_test.go b/internal/auth/kube/kube_test.go index ffd11bd..e72e0f6 100644 --- a/internal/auth/kube/kube_test.go +++ b/internal/auth/kube/kube_test.go @@ -123,10 +123,10 @@ func TestBootstrap_DeclinedPrompt_NoClusterCalls_NoFile(t *testing.T) { // 3 inspectSource calls before the prompt — provide outputs. f := setupExec(t, - `echo nmd-prod`, // current-context - `echo prod-cluster`, // cluster name - `echo https://k.example:6443`, // server - `echo admin@nmd-prod`, // identity + `echo nmd-prod`, // current-context + `echo prod-cluster`, // cluster name + `echo https://k.example:6443`, // server + `echo admin@nmd-prod`, // identity ) orig := confirmFn @@ -162,15 +162,15 @@ func TestBootstrap_SkipCluster_NoCreateCalls(t *testing.T) { // Need: 4 inspect + 1 token + 2 config (set-credentials, set-context) // + 2 verify (can-i list pods, can-i create pods) f := setupExec(t, - `echo nmd-prod`, // current-context - `echo prod-cluster`, // cluster - `echo https://k.example:6443`, // server - `echo admin@nmd-prod`, // identity - `echo TOKEN_BLOB`, // token - `echo ""`, // set-credentials - `echo ""`, // set-context - `echo yes`, // can-i list pods - `echo no; exit 1`, // can-i create pods (exits 1 on no) + `echo nmd-prod`, // current-context + `echo prod-cluster`, // cluster + `echo https://k.example:6443`, // server + `echo admin@nmd-prod`, // identity + `echo TOKEN_BLOB`, // token + `echo ""`, // set-credentials + `echo ""`, // set-context + `echo yes`, // can-i list pods + `echo no; exit 1`, // can-i create pods (exits 1 on no) ) var buf bytes.Buffer @@ -198,17 +198,17 @@ func TestBootstrap_AlreadyExists_Swallowed(t *testing.T) { out := filepath.Join(t.TempDir(), "out") setupExec(t, - `echo nmd-prod`, // current-context - `echo prod-cluster`, // cluster - `echo https://k.example:6443`, // server - `echo admin@nmd-prod`, // identity - `echo 'Error from server (AlreadyExists): serviceaccounts "x" already exists' >&2; exit 1`, // create sa + `echo nmd-prod`, // current-context + `echo prod-cluster`, // cluster + `echo https://k.example:6443`, // server + `echo admin@nmd-prod`, // identity + `echo 'Error from server (AlreadyExists): serviceaccounts "x" already exists' >&2; exit 1`, // create sa `echo 'Error from server (AlreadyExists): clusterrolebindings.rbac.authorization.k8s.io "x" already exists' >&2; exit 1`, // crb - `echo TOKEN_BLOB`, // token - `echo ""`, // set-credentials - `echo ""`, // set-context - `echo yes`, // can-i list pods - `echo no; exit 1`, // can-i create pods + `echo TOKEN_BLOB`, // token + `echo ""`, // set-credentials + `echo ""`, // set-context + `echo yes`, // can-i list pods + `echo no; exit 1`, // can-i create pods ) var buf bytes.Buffer diff --git a/internal/cfg/cfg.go b/internal/cfg/cfg.go index 1b9b62f..59c942f 100644 --- a/internal/cfg/cfg.go +++ b/internal/cfg/cfg.go @@ -1,6 +1,7 @@ package cfg import ( + "encoding/base64" "fmt" "os" "runtime" @@ -9,6 +10,7 @@ import ( "strings" "github.com/BurntSushi/toml" + wg "github.com/hydrz/wireguard" ) // DefaultRegistry is the default container registry for devcell images. @@ -23,31 +25,117 @@ const DefaultNixImage = "nixos/nix:2.34.7" // DefaultTartOCIImage is the default macOS base image for tart VMs. const DefaultTartOCIImage = "ghcr.io/cirruslabs/macos-sequoia-base:latest" +// DefaultLibvirtURI targets the macOS host's session libvirtd as seen from +// inside a Docker cell (CELL-372). +const DefaultLibvirtURI = "qemu+tcp://host.docker.internal/session" + // CellSection holds [cell] config. type CellSection struct { - ImageTag string `toml:"image_tag"` - Registry string `toml:"registry"` // container registry; default: DefaultRegistry; env: DEVCELL_REGISTRY - GUI *bool `toml:"gui"` // default: true (nil = not set → true) - Timezone string `toml:"timezone"` // IANA tz (e.g. "Europe/Prague"); default: host $TZ - Locale string `toml:"locale"` // POSIX locale (e.g. "en_US.UTF-8"); default: "en_US.UTF-8" - Stack string `toml:"stack"` // nix stack name (e.g. "go", "python"); default: "base" (see ResolvedStack) - Modules []string `toml:"modules"` // extra nix modules to compose on top of stack - NixhomePath string `toml:"nixhome"` // deprecated: use [nix] nixhome instead - Engine string `toml:"engine"` // execution engine: "docker" (default) or "vagrant" - VagrantProvider string `toml:"vagrant_provider"` // vagrant provider: "utm" (default) or "libvirt" - VagrantBox string `toml:"vagrant_box"` // vagrant box name override (default: "utm/bookworm") - DockerPrivileged bool `toml:"docker_privileged"` // run container with --privileged; default: false - DockerCapAdd []string `toml:"docker_cap_add"` // extra Linux capabilities (e.g. ["SYS_ADMIN"]); default: none - PerCellImage *bool `toml:"per_cell_image"` // tag user image per cell instead of per stack; default: false - Hostname string `toml:"hostname"` // override container hostname; default: computed "cell--"; env: DEVCELL_HOSTNAME - MacAddress string `toml:"mac_address"` // MAC for the container's NIC (XX:XX:XX:XX:XX:XX); pinned across restarts for infra-side identity persistence. Honored on user-defined bridge networks (devcell uses --network devcell-network). Empty → docker auto-assigns a random MAC per launch. - Thin *bool `toml:"thin"` // thin image mode; default: true; disable with thin=false or DEVCELL_THIN=0 - Background *bool `toml:"background"` // keep VM/container running after shell exit; default: false; env: DEVCELL_BACKGROUND - TartSSHPort int `toml:"tart_ssh_port"` // SSH port for tart engine; default: 22; env: DEVCELL_TART_SSH_PORT - TartSSHHost string `toml:"tart_ssh_host"` // SSH host for tart engine; default: "localhost"; env: DEVCELL_TART_SSH_HOST - TartSSHUser string `toml:"tart_ssh_user"` // SSH user for tart engine; default: "admin"; env: DEVCELL_TART_SSH_USER - TartSSHKey string `toml:"tart_ssh_key"` // path to SSH private key for tart; env: DEVCELL_TART_SSH_KEY - TartOCIImage string `toml:"tart_oci_image"` // OCI base image for tart VMs; default: DefaultTartOCIImage; env: DEVCELL_TART_OCI_IMAGE + ImageTag string `toml:"image_tag"` + Registry string `toml:"registry"` // container registry; default: DefaultRegistry; env: DEVCELL_REGISTRY + GUI *bool `toml:"gui"` // default: true (nil = not set → true) + Timezone string `toml:"timezone"` // IANA tz (e.g. "Europe/Prague"); default: host $TZ + Locale string `toml:"locale"` // POSIX locale (e.g. "en_US.UTF-8"); default: "en_US.UTF-8" + Stack string `toml:"stack"` // nix stack name (e.g. "go", "python"); default: "base" (see ResolvedStack) + Modules []string `toml:"modules"` // extra nix modules to compose on top of stack + NixhomePath string `toml:"nixhome"` // deprecated: use [nix] nixhome instead + Engine string `toml:"engine"` // execution engine: "docker" (default) or "vagrant" + VagrantProvider string `toml:"vagrant_provider"` // vagrant provider: "utm" (default) or "libvirt" + VagrantBox string `toml:"vagrant_box"` // vagrant box name override (default: "utm/bookworm") + KVM *bool `toml:"kvm"` // pass the daemon host's /dev/kvm into the container so QEMU gets hardware accel instead of TCG; default: false; env: DEVCELL_KVM + PerCellImage *bool `toml:"per_cell_image"` // tag user image per cell instead of per stack; default: false + Hostname string `toml:"hostname"` // override container hostname; default: computed "cell--"; env: DEVCELL_HOSTNAME + MacAddress string `toml:"mac_address"` // MAC for the container's NIC (XX:XX:XX:XX:XX:XX); pinned across restarts for infra-side identity persistence. Honored on user-defined bridge networks (devcell uses --network devcell-network). Empty → docker auto-assigns a random MAC per launch. + Thin *bool `toml:"thin"` // thin image mode; default: true; disable with thin=false or DEVCELL_THIN=0 + StaleWarning *bool `toml:"stale_warning"` // CELL-391 "cell is behind — parallel reality" nudge at start; default: true; env: DEVCELL_STALE_WARN + Background *bool `toml:"background"` // keep VM/container running after shell exit; default: false; env: DEVCELL_BACKGROUND + TartSSHPort int `toml:"tart_ssh_port"` // SSH port for tart engine; default: 22; env: DEVCELL_TART_SSH_PORT + TartSSHHost string `toml:"tart_ssh_host"` // SSH host for tart engine; default: "localhost"; env: DEVCELL_TART_SSH_HOST + TartSSHUser string `toml:"tart_ssh_user"` // SSH user for tart engine; default: "admin"; env: DEVCELL_TART_SSH_USER + TartSSHKey string `toml:"tart_ssh_key"` // path to SSH private key for tart; env: DEVCELL_TART_SSH_KEY + TartOCIImage string `toml:"tart_oci_image"` // OCI base image for tart VMs; default: DefaultTartOCIImage; env: DEVCELL_TART_OCI_IMAGE + QemuSSHPort int `toml:"qemu_ssh_port"` // SSH port for QEMU engine; default: 2222; env: DEVCELL_QEMU_SSH_PORT + QemuSSHHost string `toml:"qemu_ssh_host"` // SSH host for QEMU engine; default: "127.0.0.1"; env: DEVCELL_QEMU_SSH_HOST + QemuWindowsISO string `toml:"qemu_windows_iso"` // path to Windows ARM64 ISO; env: DEVCELL_QEMU_WINDOWS_ISO + QemuCPUs int `toml:"qemu_cpus"` // QEMU vCPUs; default: 4; env: DEVCELL_QEMU_CPUS + QemuMemoryGB int `toml:"qemu_memory_gb"` // QEMU RAM in GB; default: 4; env: DEVCELL_QEMU_MEMORY_GB + QemuDiskSizeGB int `toml:"qemu_disk_size_gb"` // QEMU disk size in GB; default: 64; env: DEVCELL_QEMU_DISK_SIZE_GB + QemuDisplay string `toml:"qemu_display"` // QEMU display: "none", "cocoa", "sdl"; default: "none"; env: DEVCELL_QEMU_DISPLAY + LibvirtURI string `toml:"libvirt_uri"` // libvirtd connection URI for the libvirt engine; default: DefaultLibvirtURI; env: DEVCELL_LIBVIRT_URI + LibvirtPathMap map[string]string `toml:"libvirt_path_map"` // container prefix -> host prefix rewrites for domain XML paths (CELL-375); empty = CLI runs on the host + QemuProjectSync string `toml:"qemu_project_sync"` // project sync for qemu/libvirt engines: "push" (default), "two-way", "off"; env: DEVCELL_QEMU_PROJECT_SYNC (CELL-383) + DefaultCommand string `toml:"default_command"` // subcommand to run when `cell` is invoked with no args; env: DEVCELL_DEFAULT_COMMAND +} + +// ResolvedQemuProjectSync returns the effective project sync mode: +// env > toml > "push". Anything but off/push/two-way resolves to "push" — +// the safe default (guest gets files, nothing overwritten on the host). +// StaleWarningEnabled reports whether the CELL-391 stale-cell nudge should +// fire at cell start. Default (unset) is enabled — it's a read-only nudge +// with a proceed-by-default prompt, so opting out is the explicit act. +func (c CellSection) StaleWarningEnabled() bool { + return c.StaleWarning == nil || *c.StaleWarning +} + +func (c CellSection) ResolvedQemuProjectSync() string { + v := os.Getenv("DEVCELL_QEMU_PROJECT_SYNC") + if v == "" { + v = c.QemuProjectSync + } + switch v { + case "off", "push", "two-way": + return v + } + return "push" +} + +var knownDefaultCommands = []string{ + "claude", "codex", "opencode", "gemini", "shell", + "build", "init", "vnc", "rdp", "models", "modules", + "serve", "auth", "telemetry", +} + +// KnownDefaultCommands returns the list of valid default_command values. +func KnownDefaultCommands() []string { + out := make([]string, len(knownDefaultCommands)) + copy(out, knownDefaultCommands) + return out +} + +// ResolvedDefaultCommand returns the effective default command: env > toml > "". +func (c CellSection) ResolvedDefaultCommand() string { + if v := os.Getenv("DEVCELL_DEFAULT_COMMAND"); v != "" { + return v + } + return c.DefaultCommand +} + +// ValidateDefaultCommand checks that default_command is a known subcommand name. +// Empty is valid (no default, shows help). +func ValidateDefaultCommand(cmd string) error { + if cmd == "" { + return nil + } + for _, c := range knownDefaultCommands { + if c == cmd { + return nil + } + } + sorted := make([]string, len(knownDefaultCommands)) + copy(sorted, knownDefaultCommands) + sort.Strings(sorted) + return fmt.Errorf("unknown default_command %q; available commands: %s", cmd, strings.Join(sorted, ", ")) +} + +// ResolvedLibvirtURI returns the effective libvirtd URI: env > toml > default. +func (c CellSection) ResolvedLibvirtURI() string { + if v := os.Getenv("DEVCELL_LIBVIRT_URI"); v != "" { + return v + } + if c.LibvirtURI != "" { + return c.LibvirtURI + } + return DefaultLibvirtURI } // ResolvedBackground returns the effective background setting: default OFF, enabled by env/toml. @@ -119,6 +207,88 @@ func (c CellSection) ResolvedTartOCIImage() string { return DefaultTartOCIImage } +// ResolvedQemuSSHPort returns the effective QEMU SSH port: env > toml > default 2222. +func (c CellSection) ResolvedQemuSSHPort() int { + if v := os.Getenv("DEVCELL_QEMU_SSH_PORT"); v != "" { + if p := atoiOr(v, 0); p > 0 { + return p + } + } + if c.QemuSSHPort > 0 { + return c.QemuSSHPort + } + return 2222 +} + +// ResolvedQemuSSHHost returns the effective QEMU SSH host: env > toml > default "127.0.0.1". +func (c CellSection) ResolvedQemuSSHHost() string { + if v := os.Getenv("DEVCELL_QEMU_SSH_HOST"); v != "" { + return v + } + if c.QemuSSHHost != "" { + return c.QemuSSHHost + } + return "127.0.0.1" +} + +// ResolvedQemuWindowsISO returns the Windows ISO path: env > toml > "". +func (c CellSection) ResolvedQemuWindowsISO() string { + if v := os.Getenv("DEVCELL_QEMU_WINDOWS_ISO"); v != "" { + return v + } + return c.QemuWindowsISO +} + +// ResolvedQemuCPUs returns the effective QEMU vCPU count: env > toml > default 4. +func (c CellSection) ResolvedQemuCPUs() int { + if v := os.Getenv("DEVCELL_QEMU_CPUS"); v != "" { + if n := atoiOr(v, 0); n > 0 { + return n + } + } + if c.QemuCPUs > 0 { + return c.QemuCPUs + } + return 4 +} + +// ResolvedQemuMemoryGB returns the effective QEMU memory: env > toml > default 4. +func (c CellSection) ResolvedQemuMemoryGB() int { + if v := os.Getenv("DEVCELL_QEMU_MEMORY_GB"); v != "" { + if n := atoiOr(v, 0); n > 0 { + return n + } + } + if c.QemuMemoryGB > 0 { + return c.QemuMemoryGB + } + return 4 +} + +// ResolvedQemuDiskSizeGB returns the effective QEMU disk size: env > toml > default 64. +func (c CellSection) ResolvedQemuDiskSizeGB() int { + if v := os.Getenv("DEVCELL_QEMU_DISK_SIZE_GB"); v != "" { + if n := atoiOr(v, 0); n > 0 { + return n + } + } + if c.QemuDiskSizeGB > 0 { + return c.QemuDiskSizeGB + } + return 64 +} + +// ResolvedQemuDisplay returns the effective QEMU display: env > toml > default "none". +func (c CellSection) ResolvedQemuDisplay() string { + if v := os.Getenv("DEVCELL_QEMU_DISPLAY"); v != "" { + return v + } + if c.QemuDisplay != "" { + return c.QemuDisplay + } + return "none" +} + // ResolvedThin returns the effective thin setting: default ON, disabled by env/toml. func (c CellSection) ResolvedThin() bool { if v := os.Getenv("DEVCELL_THIN"); v == "0" { @@ -151,6 +321,22 @@ func (c CellSection) ResolvedGUI() bool { return *c.GUI } +// ResolvedKVM returns the effective KVM passthrough setting: env > toml > +// default OFF. It is opt-in because the device lives on the docker daemon +// host (e.g. the Colima VM), which the CLI cannot stat — a wrong guess either +// breaks `docker run` outright or silently drops the guest back to TCG. +func (c CellSection) ResolvedKVM() bool { + if v := os.Getenv("DEVCELL_KVM"); v == "1" { + return true + } else if v == "0" { + return false + } + if c.KVM != nil { + return *c.KVM + } + return false +} + // ResolvedPerCellImage returns true only when explicitly enabled. func (c CellSection) ResolvedPerCellImage() bool { if c.PerCellImage == nil { @@ -233,10 +419,34 @@ func (v VolumeMount) Resolved() string { return v.Mount } -// PackagesSection holds [packages] config for npm and python tools. +// ContainerPath returns the container-side mount point with trailing slashes +// stripped so path comparisons work regardless of how the user wrote the path. +// For "host:container" or "host:container:mode" it returns "container". +// For shorthand (no colon) it returns the path itself (identity mount). +func (v VolumeMount) ContainerPath() string { + if v.Mount == "" { + return "" + } + parts := strings.SplitN(v.Mount, ":", 3) + if len(parts) >= 2 { + return strings.TrimRight(parts[1], "/") + } + return strings.TrimRight(v.Mount, "/") +} + +// NixPackages holds [packages.nix] config: arbitrary nixpkgs packages +// from three channels matching the flake inputs in nixhome/flake.nix. +type NixPackages struct { + Stable []string `toml:"stable"` + Unstable []string `toml:"unstable"` + Edge []string `toml:"edge"` +} + +// PackagesSection holds [packages] config for npm, python, and nix tools. type PackagesSection struct { Npm map[string]string `toml:"npm"` Python map[string]string `toml:"python"` + Nix NixPackages `toml:"nix"` } // LLMProvider holds a single provider entry under [llm.models.providers.]. @@ -253,16 +463,28 @@ type LLMModelsSection struct { // LLMSection holds [llm] config — all AI agent settings in one place. // -// SystemPrompt and SystemPromptFile are mutually exclusive — set one or -// neither. The resolver in internal/runner.ResolveSystemPrompt validates +// Two independent layers, each with an inline and a file form: +// +// - SystemPrompt / SystemPromptFile REPLACE Claude Code's built-in prompt +// (claude --system-prompt-file). Setting this discards the stock tool +// guidance and safety instructions — you own the whole prompt. +// - AppendSystemPrompt / AppendSystemPromptFile layer on top of whichever +// base is in effect (claude --append-system-prompt-file), alongside the +// container context devcell always contributes. +// +// Within a layer the inline and file forms are mutually exclusive — set one +// or neither. The resolver in internal/runner.ResolveSystemPrompt validates // this and returns an error when both are set, so we don't fail config // load for projects where the conflict is harmless (e.g. callers that // don't read system prompts). type LLMSection struct { - SystemPrompt string `toml:"system_prompt"` - SystemPromptFile string `toml:"system_prompt_file"` - UseOllama bool `toml:"use_ollama"` - Models LLMModelsSection `toml:"models"` + SystemPrompt string `toml:"system_prompt"` + SystemPromptFile string `toml:"system_prompt_file"` + AppendSystemPrompt string `toml:"append_system_prompt"` + AppendSystemPromptFile string `toml:"append_system_prompt_file"` + UseOllama bool `toml:"use_ollama"` + UseOpenRouter bool `toml:"use_openrouter"` + Models LLMModelsSection `toml:"models"` } // GitSection holds [git] config for git identity inside the container. @@ -389,6 +611,59 @@ func (s StealthSection) ResolvedUserAgent() string { return "Mozilla/5.0 (" + platformUA + ") AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36" } +// DockerSection holds [docker] config for runtime container resource limits. +// Values follow the same env > toml > default resolution chain as other sections. +type DockerSection struct { + Privileged bool `toml:"privileged"` // run container with --privileged; default: false + CapAdd []string `toml:"cap_add"` // extra Linux capabilities (e.g. ["SYS_ADMIN"]); default: none + MemLimit string `toml:"mem_limit"` // docker --memory ceiling (e.g. "4g"); "0" = uncapped; env: DEVCELL_DOCKER_MEM_LIMIT + CPULimit string `toml:"cpu_limit"` // docker --cpus quota (e.g. "2"); "0" = no quota; env: DEVCELL_DOCKER_CPU_LIMIT + ShmSize string `toml:"shm_size"` // docker --shm-size (e.g. "1g"); env: DEVCELL_DOCKER_SHM_SIZE +} + +// ResolvedMemLimit returns the effective memory limit: env > toml > default "4g". +func (d DockerSection) ResolvedMemLimit() string { + if v := os.Getenv("DEVCELL_DOCKER_MEM_LIMIT"); v != "" { + return v + } + if d.MemLimit != "" { + return d.MemLimit + } + return "4g" +} + +// ResolvedCPULimit returns the effective CPU limit: env > toml > default "2". +func (d DockerSection) ResolvedCPULimit() string { + if v := os.Getenv("DEVCELL_DOCKER_CPU_LIMIT"); v != "" { + return v + } + if d.CPULimit != "" { + return d.CPULimit + } + return "2" +} + +// ResolvedShmSize returns the effective shm size: env > toml > default "1g". +func (d DockerSection) ResolvedShmSize() string { + if v := os.Getenv("DEVCELL_DOCKER_SHM_SIZE"); v != "" { + return v + } + if d.ShmSize != "" { + return d.ShmSize + } + return "1g" +} + +// BuildSection holds [build] config for thin-build resource ceilings. +// Values feed the same resolution chain as the env vars; an explicit env var +// always wins over TOML (env > toml > derived default). +type BuildSection struct { + Memory string `toml:"memory"` // docker --memory ceiling (e.g. "16g"); "0" = uncapped; env: DEVCELL_BUILD_MEMORY + CPUs string `toml:"cpus"` // docker --cpus quota (e.g. "8"); "0" = no quota; env: DEVCELL_BUILD_CPUS + MaxJobs int `toml:"max_jobs"` // nix max-jobs; 0 = derived from ceiling; env: DEVCELL_NIX_MAX_JOBS + Cores int `toml:"cores"` // nix cores (make -j per job); 0 = derived; env: DEVCELL_NIX_CORES +} + // NixSection holds [nix] config for nix image and nixhome settings. type NixSection struct { Image string `toml:"image"` // nix core image for thin builds; default: DefaultNixImage; env: DEVCELL_NIX_IMAGE @@ -419,20 +694,103 @@ func (a AwsSection) ResolvedReadOnly() bool { return *a.ReadOnly } +// GUISection holds [gui] config for desktop/window-manager settings. +type GUISection struct { + Enabled *bool `toml:"enabled"` // default: true (nil = not set → true) + WM string `toml:"wm"` // "icewm" (default) or "fluxbox" + Resolution string `toml:"resolution"` // logical resolution; default: "1920x1080x24" + Scale int `toml:"scale"` // display scale factor (1=96dpi, 2=192dpi HiDPI); default: 1 +} + +// ResolvedEnabled returns the effective GUI setting: true unless explicitly set to false. +func (g GUISection) ResolvedEnabled() bool { + if g.Enabled == nil { + return true + } + return *g.Enabled +} + +// ResolvedWM returns the effective window manager: "icewm" unless explicitly set. +func (g GUISection) ResolvedWM() string { + if g.WM == "" { + return "icewm" + } + return g.WM +} + +// ResolvedResolution returns the logical resolution: "1920x1080x24" unless explicitly set. +func (g GUISection) ResolvedResolution() string { + if g.Resolution == "" { + return "1920x1080x24" + } + return g.Resolution +} + +// ResolvedScale returns the display scale factor: 1 unless explicitly set. +func (g GUISection) ResolvedScale() int { + if g.Scale <= 0 { + return 1 + } + return g.Scale +} + +// ResolvedDPI returns the X server DPI: 96 * scale. +func (g GUISection) ResolvedDPI() int { + return 96 * g.ResolvedScale() +} + +// ResolvedFramebufferResolution returns the physical Xvfb framebuffer size: +// logical resolution multiplied by scale factor. +func (g GUISection) ResolvedFramebufferResolution() string { + res := g.ResolvedResolution() + scale := g.ResolvedScale() + if scale == 1 { + return res + } + parts := strings.SplitN(res, "x", 3) + if len(parts) < 2 { + return res + } + w, err := strconv.Atoi(parts[0]) + if err != nil { + return res + } + h, err := strconv.Atoi(parts[1]) + if err != nil { + return res + } + depth := "24" + if len(parts) == 3 { + depth = parts[2] + } + return fmt.Sprintf("%dx%dx%s", w*scale, h*scale, depth) +} + +// WireguardEntry holds one [[wireguard]] table-array entry. +type WireguardEntry struct { + Name string `toml:"name"` + Enabled bool `toml:"enabled"` + Config string `toml:"config"` +} + // CellConfig is the merged configuration from all TOML layers. type CellConfig struct { - Cell CellSection - Nix NixSection `toml:"nix"` - LLM LLMSection `toml:"llm"` - Git GitSection `toml:"git"` - Ports PortsSection `toml:"ports"` - Op OpSection `toml:"op"` - Aws AwsSection `toml:"aws"` - Stealth StealthSection `toml:"stealth"` - Env map[string]string - Mise map[string]string `toml:"mise"` // [mise] — keys map to MISE_ env vars - Volumes []VolumeMount - Packages PackagesSection + Cell CellSection + Docker DockerSection `toml:"docker"` + Build BuildSection `toml:"build"` + Nix NixSection `toml:"nix"` + LLM LLMSection `toml:"llm"` + Git GitSection `toml:"git"` + Ports PortsSection `toml:"ports"` + Op OpSection `toml:"op"` + Aws AwsSection `toml:"aws"` + Stealth StealthSection `toml:"stealth"` + GUI GUISection `toml:"gui"` + Env map[string]string + Mise map[string]string `toml:"mise"` // [mise] — keys map to MISE_ env vars + Volumes []VolumeMount + Packages PackagesSection + Wireguard []WireguardEntry `toml:"wireguard"` } // LoadFile parses a TOML file into CellConfig. @@ -449,9 +807,20 @@ func LoadFile(path string) (CellConfig, error) { if _, err := toml.Decode(string(data), &c); err != nil { return CellConfig{}, err } + migrateGUIField(&c) + sort.Strings(c.Cell.Modules) return c, nil } +// migrateGUIField copies legacy [cell] gui into [gui] enabled when the new +// section is not explicitly set. This preserves backward compatibility with +// configs that use [cell] gui = false instead of [gui] enabled = false. +func migrateGUIField(c *CellConfig) { + if c.Cell.GUI != nil && c.GUI.Enabled == nil { + c.GUI.Enabled = c.Cell.GUI + } +} + // unionDedupStrings returns a + b with duplicates removed, preserving the // order of `a` followed by items in `b` not already in `a`. func unionDedupStrings(a, b []string) []string { @@ -475,6 +844,17 @@ func unionDedupStrings(a, b []string) []string { return out } +// mergeNixPkgTier merges one [packages.nix] tier with the same semantics as +// [cell].modules: union-dedup, sorted; explicit empty slice clears global. +func mergeNixPkgTier(global, project []string) []string { + if project != nil && len(project) == 0 { + return []string{} + } + merged := unionDedupStrings(global, project) + sort.Strings(merged) + return merged +} + // Merge returns a new CellConfig with project overriding global for scalars; // slices accumulate (Volumes, Ports.Forward, Op documents, [cell].modules). // For [cell].modules: explicit empty list in project ([]) clears global as @@ -527,11 +907,8 @@ func Merge(global, project CellConfig) CellConfig { } else { out.Cell.Modules = unionDedupStrings(global.Cell.Modules, project.Cell.Modules) } - if project.Cell.DockerPrivileged { - out.Cell.DockerPrivileged = true - } - if len(project.Cell.DockerCapAdd) > 0 { - out.Cell.DockerCapAdd = unionDedupStrings(global.Cell.DockerCapAdd, project.Cell.DockerCapAdd) + if project.Cell.KVM != nil { + out.Cell.KVM = project.Cell.KVM } if project.Cell.PerCellImage != nil { out.Cell.PerCellImage = project.Cell.PerCellImage @@ -560,6 +937,51 @@ func Merge(global, project CellConfig) CellConfig { if project.Cell.TartOCIImage != "" { out.Cell.TartOCIImage = project.Cell.TartOCIImage } + if project.Cell.QemuSSHPort > 0 { + out.Cell.QemuSSHPort = project.Cell.QemuSSHPort + } + if project.Cell.QemuSSHHost != "" { + out.Cell.QemuSSHHost = project.Cell.QemuSSHHost + } + if project.Cell.QemuWindowsISO != "" { + out.Cell.QemuWindowsISO = project.Cell.QemuWindowsISO + } + if project.Cell.QemuCPUs > 0 { + out.Cell.QemuCPUs = project.Cell.QemuCPUs + } + if project.Cell.QemuMemoryGB > 0 { + out.Cell.QemuMemoryGB = project.Cell.QemuMemoryGB + } + if project.Cell.QemuDiskSizeGB > 0 { + out.Cell.QemuDiskSizeGB = project.Cell.QemuDiskSizeGB + } + if project.Cell.QemuDisplay != "" { + out.Cell.QemuDisplay = project.Cell.QemuDisplay + } + if project.Cell.LibvirtURI != "" { + out.Cell.LibvirtURI = project.Cell.LibvirtURI + } + if project.Cell.QemuProjectSync != "" { + out.Cell.QemuProjectSync = project.Cell.QemuProjectSync + } + if project.Cell.DefaultCommand != "" { + out.Cell.DefaultCommand = project.Cell.DefaultCommand + } + // Path map accumulates like Env: global entries plus project entries, + // project winning on the same key. + if len(global.Cell.LibvirtPathMap) > 0 || len(project.Cell.LibvirtPathMap) > 0 { + merged := make(map[string]string, len(global.Cell.LibvirtPathMap)+len(project.Cell.LibvirtPathMap)) + for k, v := range global.Cell.LibvirtPathMap { + merged[k] = v + } + for k, v := range project.Cell.LibvirtPathMap { + merged[k] = v + } + out.Cell.LibvirtPathMap = merged + } + if project.Cell.Engine != "" { + out.Cell.Engine = project.Cell.Engine + } // LLM: project wins for scalars, providers accumulate out.LLM = global.LLM @@ -569,9 +991,18 @@ func Merge(global, project CellConfig) CellConfig { if project.LLM.SystemPromptFile != "" { out.LLM.SystemPromptFile = project.LLM.SystemPromptFile } + if project.LLM.AppendSystemPrompt != "" { + out.LLM.AppendSystemPrompt = project.LLM.AppendSystemPrompt + } + if project.LLM.AppendSystemPromptFile != "" { + out.LLM.AppendSystemPromptFile = project.LLM.AppendSystemPromptFile + } if project.LLM.UseOllama { out.LLM.UseOllama = true } + if project.LLM.UseOpenRouter { + out.LLM.UseOpenRouter = true + } // Git: project wins when non-zero out.Git = global.Git @@ -603,6 +1034,39 @@ func Merge(global, project CellConfig) CellConfig { out.Stealth.Platform = project.Stealth.Platform } + // Build: project wins when non-zero + out.Build = global.Build + if project.Build.Memory != "" { + out.Build.Memory = project.Build.Memory + } + if project.Build.CPUs != "" { + out.Build.CPUs = project.Build.CPUs + } + if project.Build.MaxJobs != 0 { + out.Build.MaxJobs = project.Build.MaxJobs + } + if project.Build.Cores != 0 { + out.Build.Cores = project.Build.Cores + } + + // Docker: project wins when non-empty / true + out.Docker = global.Docker + if project.Docker.Privileged { + out.Docker.Privileged = true + } + if len(project.Docker.CapAdd) > 0 { + out.Docker.CapAdd = unionDedupStrings(global.Docker.CapAdd, project.Docker.CapAdd) + } + if project.Docker.MemLimit != "" { + out.Docker.MemLimit = project.Docker.MemLimit + } + if project.Docker.CPULimit != "" { + out.Docker.CPULimit = project.Docker.CPULimit + } + if project.Docker.ShmSize != "" { + out.Docker.ShmSize = project.Docker.ShmSize + } + // Nix: project wins when non-empty out.Nix = global.Nix if project.Nix.Image != "" { @@ -612,6 +1076,21 @@ func Merge(global, project CellConfig) CellConfig { out.Nix.NixhomePath = project.Nix.NixhomePath } + // GUI: project wins when non-zero + out.GUI = global.GUI + if project.GUI.Enabled != nil { + out.GUI.Enabled = project.GUI.Enabled + } + if project.GUI.WM != "" { + out.GUI.WM = project.GUI.WM + } + if project.GUI.Resolution != "" { + out.GUI.Resolution = project.GUI.Resolution + } + if project.GUI.Scale != 0 { + out.GUI.Scale = project.GUI.Scale + } + // Op documents: accumulate from both Documents and legacy Items, deduped. // ResolvedDocuments() merges documents+items per layer; then we dedup across layers. globalDocs := global.Op.ResolvedDocuments() @@ -645,8 +1124,26 @@ func Merge(global, project CellConfig) CellConfig { out.Ports.PublishIP = project.Ports.PublishIP } - // Slices accumulate: global first, then project - out.Volumes = append(global.Volumes, project.Volumes...) + // Volumes accumulate; project wins when both layers mount at the same + // container path. Dedup by ContainerPath prevents Docker's + // "Duplicate mount point" error. + { + seen := make(map[string]int, len(global.Volumes)+len(project.Volumes)) + for _, v := range global.Volumes { + cp := v.ContainerPath() + seen[cp] = len(out.Volumes) + out.Volumes = append(out.Volumes, v) + } + for _, v := range project.Volumes { + cp := v.ContainerPath() + if idx, ok := seen[cp]; ok { + out.Volumes[idx] = v + } else { + seen[cp] = len(out.Volumes) + out.Volumes = append(out.Volumes, v) + } + } + } // LLM models: project default wins, providers accumulate (project wins on key conflict) if project.LLM.Models.Default != "" { @@ -662,6 +1159,50 @@ func Merge(global, project CellConfig) CellConfig { } } + // Packages.Nix: union-dedup per tier, same semantics as [cell].modules. + // Explicit empty slice in project clears global (escape hatch). + out.Packages.Nix.Stable = mergeNixPkgTier(global.Packages.Nix.Stable, project.Packages.Nix.Stable) + out.Packages.Nix.Unstable = mergeNixPkgTier(global.Packages.Nix.Unstable, project.Packages.Nix.Unstable) + out.Packages.Nix.Edge = mergeNixPkgTier(global.Packages.Nix.Edge, project.Packages.Nix.Edge) + + // Packages.Npm/Python: maps accumulate (same semantics as Env — project wins on key conflict). + if len(global.Packages.Npm) > 0 || len(project.Packages.Npm) > 0 { + out.Packages.Npm = make(map[string]string, len(global.Packages.Npm)+len(project.Packages.Npm)) + for k, v := range global.Packages.Npm { + out.Packages.Npm[k] = v + } + for k, v := range project.Packages.Npm { + out.Packages.Npm[k] = v + } + } + if len(global.Packages.Python) > 0 || len(project.Packages.Python) > 0 { + out.Packages.Python = make(map[string]string, len(global.Packages.Python)+len(project.Packages.Python)) + for k, v := range global.Packages.Python { + out.Packages.Python[k] = v + } + for k, v := range project.Packages.Python { + out.Packages.Python[k] = v + } + } + + // Wireguard: accumulate, project wins on name conflict. + if len(global.Wireguard) > 0 || len(project.Wireguard) > 0 { + seen := make(map[string]int, len(global.Wireguard)) + for _, wg := range global.Wireguard { + seen[wg.Name] = len(out.Wireguard) + out.Wireguard = append(out.Wireguard, wg) + } + for _, wg := range project.Wireguard { + if idx, ok := seen[wg.Name]; ok { + out.Wireguard[idx] = wg + } else { + seen[wg.Name] = len(out.Wireguard) + out.Wireguard = append(out.Wireguard, wg) + } + } + } + + migrateGUIField(&out) return out } @@ -680,6 +1221,9 @@ func ApplyEnv(c *CellConfig, getenv func(string) string) { b := true c.Cell.PerCellImage = &b } + if v := getenv("DEVCELL_DEFAULT_COMMAND"); v != "" { + c.Cell.DefaultCommand = v + } } // LoadLayered loads global + project files, merges them, then applies env overrides. @@ -695,6 +1239,9 @@ func LoadLayered(globalPath, projectPath string, getenv func(string) string) (Ce } merged := Merge(global, project) ApplyEnv(&merged, getenv) + // CELL-331: [a,b] and [b,a] must resolve to the same image tag and + // home-manager closure regardless of which layer contributed what. + sort.Strings(merged.Cell.Modules) return merged, nil } @@ -767,6 +1314,53 @@ func ValidateStack(stack string) error { return fmt.Errorf("unknown stack %q; available stacks: %s", stack, strings.Join(sorted, ", ")) } +// ValidateWireguard checks that every enabled [[wireguard]] entry has a +// non-empty name, config, valid WireGuard syntax, at least one peer with a +// valid PublicKey, and an interface Address. +func ValidateWireguard(c CellConfig) error { + for i, entry := range c.Wireguard { + if !entry.Enabled { + continue + } + if strings.TrimSpace(entry.Name) == "" { + return fmt.Errorf("wireguard[%d]: name is required when enabled", i) + } + if strings.TrimSpace(entry.Config) == "" { + return fmt.Errorf("wireguard[%d] %q: config is required when enabled", i, entry.Name) + } + parsed, err := wg.ParseConfig(strings.NewReader(entry.Config)) + if err != nil { + return fmt.Errorf("wireguard[%d] %q: %w", i, entry.Name, err) + } + if len(parsed.Address) == 0 { + return fmt.Errorf("wireguard[%d] %q: [Interface] Address is required", i, entry.Name) + } + if len(parsed.Peers) == 0 { + return fmt.Errorf("wireguard[%d] %q: at least one [Peer] is required", i, entry.Name) + } + for j, peer := range parsed.Peers { + if peer.PublicKey == "" { + return fmt.Errorf("wireguard[%d] %q: peer[%d] PublicKey is required", i, entry.Name, j) + } + keyBytes, err := base64.StdEncoding.DecodeString(peer.PublicKey) + if err != nil || len(keyBytes) != 32 { + return fmt.Errorf("wireguard[%d] %q: peer[%d] PublicKey is not a valid 32-byte base64 key", i, entry.Name, j) + } + } + } + return nil +} + +// WireguardEnabled reports whether any [[wireguard]] entry is enabled. +func WireguardEnabled(c CellConfig) bool { + for _, wg := range c.Wireguard { + if wg.Enabled { + return true + } + } + return false +} + func atoiOr(s string, fallback int) int { n, err := strconv.Atoi(s) if err != nil { diff --git a/internal/cfg/cfg_test.go b/internal/cfg/cfg_test.go index 109ff5b..2e3f67c 100644 --- a/internal/cfg/cfg_test.go +++ b/internal/cfg/cfg_test.go @@ -118,6 +118,54 @@ func TestMerge_VolumesAccumulate(t *testing.T) { } } +func TestMerge_VolumesDedupByContainerPath(t *testing.T) { + global := cfg.CellConfig{Volumes: []cfg.VolumeMount{ + {Mount: "/host/a:/container/shared"}, + }} + project := cfg.CellConfig{Volumes: []cfg.VolumeMount{ + {Mount: "/host/b:/container/shared"}, + }} + merged := cfg.Merge(global, project) + if len(merged.Volumes) != 1 { + t.Fatalf("want 1 volume (deduped), got %d: %+v", len(merged.Volumes), merged.Volumes) + } + if merged.Volumes[0].Mount != "/host/b:/container/shared" { + t.Errorf("project should win on conflict, got %q", merged.Volumes[0].Mount) + } +} + +func TestMerge_VolumesDedupShorthand(t *testing.T) { + global := cfg.CellConfig{Volumes: []cfg.VolumeMount{ + {Mount: "/Users/dmitry/dev/skills"}, + }} + project := cfg.CellConfig{Volumes: []cfg.VolumeMount{ + {Mount: "/Users/dmitry/dev/skills"}, + }} + merged := cfg.Merge(global, project) + if len(merged.Volumes) != 1 { + t.Errorf("want 1 volume (deduped shorthand), got %d: %+v", len(merged.Volumes), merged.Volumes) + } +} + +func TestVolumeMount_ContainerPath(t *testing.T) { + cases := []struct { + in, want string + }{ + {"/host:/container", "/container"}, + {"/host:/container:ro", "/container"}, + {"/Users/dmitry/dev/skills", "/Users/dmitry/dev/skills"}, + {"/Users/dmitry/dev/skills/", "/Users/dmitry/dev/skills"}, + {"/host/:/container/", "/container"}, + {"", ""}, + } + for _, tc := range cases { + got := cfg.VolumeMount{Mount: tc.in}.ContainerPath() + if got != tc.want { + t.Errorf("ContainerPath(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + func TestApplyEnv_ImageTagOverride(t *testing.T) { c := cfg.CellConfig{Cell: cfg.CellSection{ImageTag: "v0.0.0-ultimate"}} cfg.ApplyEnv(&c, func(k string) string { @@ -335,6 +383,249 @@ func TestMerge_GUIBothUnsetDefaultsTrue(t *testing.T) { } } +// --- [gui] section --- + +func TestLoadFile_GUISectionEnabled(t *testing.T) { + dir := t.TempDir() + writeTOML(t, dir, "devcell.toml", ` +[gui] +enabled = true +wm = "fluxbox" +`) + c, err := cfg.LoadFile(filepath.Join(dir, "devcell.toml")) + if err != nil { + t.Fatal(err) + } + if !c.GUI.ResolvedEnabled() { + t.Error("expected GUI.ResolvedEnabled()=true") + } + if c.GUI.ResolvedWM() != "fluxbox" { + t.Errorf("expected WM=fluxbox, got %q", c.GUI.ResolvedWM()) + } +} + +func TestLoadFile_GUISectionDisabled(t *testing.T) { + dir := t.TempDir() + writeTOML(t, dir, "devcell.toml", ` +[gui] +enabled = false +`) + c, err := cfg.LoadFile(filepath.Join(dir, "devcell.toml")) + if err != nil { + t.Fatal(err) + } + if c.GUI.ResolvedEnabled() { + t.Error("expected GUI.ResolvedEnabled()=false") + } +} + +func TestLoadFile_GUISectionDefaultWM(t *testing.T) { + dir := t.TempDir() + writeTOML(t, dir, "devcell.toml", ` +[gui] +enabled = true +`) + c, err := cfg.LoadFile(filepath.Join(dir, "devcell.toml")) + if err != nil { + t.Fatal(err) + } + if c.GUI.ResolvedWM() != "icewm" { + t.Errorf("expected default WM=icewm, got %q", c.GUI.ResolvedWM()) + } +} + +func TestLoadFile_GUILegacyCellGUIMigratesToSection(t *testing.T) { + dir := t.TempDir() + writeTOML(t, dir, "devcell.toml", ` +[cell] +gui = false +`) + c, err := cfg.LoadFile(filepath.Join(dir, "devcell.toml")) + if err != nil { + t.Fatal(err) + } + if c.GUI.ResolvedEnabled() { + t.Error("expected legacy [cell] gui=false to migrate to GUI.Enabled=false") + } +} + +func TestLoadFile_GUISectionWinsOverLegacy(t *testing.T) { + dir := t.TempDir() + writeTOML(t, dir, "devcell.toml", ` +[cell] +gui = false + +[gui] +enabled = true +wm = "fluxbox" +`) + c, err := cfg.LoadFile(filepath.Join(dir, "devcell.toml")) + if err != nil { + t.Fatal(err) + } + if !c.GUI.ResolvedEnabled() { + t.Error("expected [gui] enabled=true to win over [cell] gui=false") + } +} + +func TestMerge_GUISectionProjectWMOverridesGlobal(t *testing.T) { + global := cfg.CellConfig{GUI: cfg.GUISection{WM: "icewm"}} + project := cfg.CellConfig{GUI: cfg.GUISection{WM: "fluxbox"}} + merged := cfg.Merge(global, project) + if merged.GUI.ResolvedWM() != "fluxbox" { + t.Errorf("expected project wm=fluxbox to win, got %q", merged.GUI.ResolvedWM()) + } +} + +func TestMerge_GUISectionGlobalWMKeptWhenProjectUnset(t *testing.T) { + global := cfg.CellConfig{GUI: cfg.GUISection{WM: "fluxbox"}} + project := cfg.CellConfig{} + merged := cfg.Merge(global, project) + if merged.GUI.ResolvedWM() != "fluxbox" { + t.Errorf("expected global wm=fluxbox preserved, got %q", merged.GUI.ResolvedWM()) + } +} + +func TestLoadFile_GUISectionResolution(t *testing.T) { + dir := t.TempDir() + writeTOML(t, dir, "devcell.toml", ` +[gui] +resolution = "2560x1440x24" +`) + c, err := cfg.LoadFile(filepath.Join(dir, "devcell.toml")) + if err != nil { + t.Fatal(err) + } + if c.GUI.ResolvedResolution() != "2560x1440x24" { + t.Errorf("expected resolution=2560x1440x24, got %q", c.GUI.ResolvedResolution()) + } +} + +func TestLoadFile_GUISectionDefaultResolution(t *testing.T) { + dir := t.TempDir() + writeTOML(t, dir, "devcell.toml", ` +[gui] +enabled = true +`) + c, err := cfg.LoadFile(filepath.Join(dir, "devcell.toml")) + if err != nil { + t.Fatal(err) + } + if c.GUI.ResolvedResolution() != "1920x1080x24" { + t.Errorf("expected default resolution=1920x1080x24, got %q", c.GUI.ResolvedResolution()) + } +} + +func TestMerge_GUISectionProjectResolutionOverridesGlobal(t *testing.T) { + global := cfg.CellConfig{GUI: cfg.GUISection{Resolution: "1920x1080x24"}} + project := cfg.CellConfig{GUI: cfg.GUISection{Resolution: "2560x1440x24"}} + merged := cfg.Merge(global, project) + if merged.GUI.ResolvedResolution() != "2560x1440x24" { + t.Errorf("expected project resolution to win, got %q", merged.GUI.ResolvedResolution()) + } +} + +func TestMerge_GUISectionGlobalResolutionKeptWhenProjectUnset(t *testing.T) { + global := cfg.CellConfig{GUI: cfg.GUISection{Resolution: "2560x1440x24"}} + project := cfg.CellConfig{} + merged := cfg.Merge(global, project) + if merged.GUI.ResolvedResolution() != "2560x1440x24" { + t.Errorf("expected global resolution preserved, got %q", merged.GUI.ResolvedResolution()) + } +} + +func TestLoadFile_GUISectionScale(t *testing.T) { + dir := t.TempDir() + writeTOML(t, dir, "devcell.toml", ` +[gui] +resolution = "1800x1169x24" +scale = 2 +`) + c, err := cfg.LoadFile(filepath.Join(dir, "devcell.toml")) + if err != nil { + t.Fatal(err) + } + if c.GUI.ResolvedScale() != 2 { + t.Errorf("expected scale=2, got %d", c.GUI.ResolvedScale()) + } + if c.GUI.ResolvedDPI() != 192 { + t.Errorf("expected DPI=192, got %d", c.GUI.ResolvedDPI()) + } + if c.GUI.ResolvedFramebufferResolution() != "3600x2338x24" { + t.Errorf("expected framebuffer=3600x2338x24, got %q", c.GUI.ResolvedFramebufferResolution()) + } +} + +func TestLoadFile_GUISectionDefaultScale(t *testing.T) { + dir := t.TempDir() + writeTOML(t, dir, "devcell.toml", ` +[gui] +enabled = true +`) + c, err := cfg.LoadFile(filepath.Join(dir, "devcell.toml")) + if err != nil { + t.Fatal(err) + } + if c.GUI.ResolvedScale() != 1 { + t.Errorf("expected default scale=1, got %d", c.GUI.ResolvedScale()) + } + if c.GUI.ResolvedDPI() != 96 { + t.Errorf("expected default DPI=96, got %d", c.GUI.ResolvedDPI()) + } + if c.GUI.ResolvedFramebufferResolution() != "1920x1080x24" { + t.Errorf("expected default framebuffer=1920x1080x24, got %q", c.GUI.ResolvedFramebufferResolution()) + } +} + +func TestLoadFile_GUISectionScale1(t *testing.T) { + dir := t.TempDir() + writeTOML(t, dir, "devcell.toml", ` +[gui] +resolution = "1800x1169x24" +scale = 1 +`) + c, err := cfg.LoadFile(filepath.Join(dir, "devcell.toml")) + if err != nil { + t.Fatal(err) + } + if c.GUI.ResolvedScale() != 1 { + t.Errorf("expected scale=1, got %d", c.GUI.ResolvedScale()) + } + if c.GUI.ResolvedDPI() != 96 { + t.Errorf("expected DPI=96, got %d", c.GUI.ResolvedDPI()) + } + if c.GUI.ResolvedFramebufferResolution() != "1800x1169x24" { + t.Errorf("expected framebuffer=1800x1169x24, got %q", c.GUI.ResolvedFramebufferResolution()) + } +} + +func TestMerge_GUISectionProjectScaleOverridesGlobal(t *testing.T) { + global := cfg.CellConfig{GUI: cfg.GUISection{Scale: 1}} + project := cfg.CellConfig{GUI: cfg.GUISection{Scale: 2}} + merged := cfg.Merge(global, project) + if merged.GUI.ResolvedScale() != 2 { + t.Errorf("expected project scale to win, got %d", merged.GUI.ResolvedScale()) + } +} + +func TestMerge_GUISectionGlobalScaleKeptWhenProjectUnset(t *testing.T) { + global := cfg.CellConfig{GUI: cfg.GUISection{Scale: 3}} + project := cfg.CellConfig{} + merged := cfg.Merge(global, project) + if merged.GUI.ResolvedScale() != 3 { + t.Errorf("expected global scale preserved, got %d", merged.GUI.ResolvedScale()) + } +} + +func TestMerge_GUISectionLegacyCellGUIMigrates(t *testing.T) { + global := cfg.CellConfig{Cell: cfg.CellSection{GUI: boolPtr(false)}} + project := cfg.CellConfig{} + merged := cfg.Merge(global, project) + if merged.GUI.ResolvedEnabled() { + t.Error("expected legacy [cell] gui=false to migrate to GUI.Enabled=false in merge") + } +} + func TestVolumeMount_PassThrough(t *testing.T) { dir := t.TempDir() writeTOML(t, dir, "devcell.toml", ` @@ -520,6 +811,161 @@ func contains(s, sub string) bool { return len(s) >= len(sub) && len(sub) > 0 && strings.Contains(s, sub) } +// --- [docker] section --- + +func TestLoadFile_DockerSection(t *testing.T) { + dir := t.TempDir() + writeTOML(t, dir, "devcell.toml", ` +[docker] +privileged = true +cap_add = ["SYS_ADMIN", "NET_ADMIN"] +mem_limit = "8g" +cpu_limit = "4" +shm_size = "2g" +`) + c, err := cfg.LoadFile(filepath.Join(dir, "devcell.toml")) + if err != nil { + t.Fatal(err) + } + if !c.Docker.Privileged { + t.Error("privileged: want true") + } + if len(c.Docker.CapAdd) != 2 || c.Docker.CapAdd[0] != "SYS_ADMIN" || c.Docker.CapAdd[1] != "NET_ADMIN" { + t.Errorf("cap_add: want [SYS_ADMIN NET_ADMIN], got %v", c.Docker.CapAdd) + } + if c.Docker.MemLimit != "8g" { + t.Errorf("mem_limit: want 8g, got %q", c.Docker.MemLimit) + } + if c.Docker.CPULimit != "4" { + t.Errorf("cpu_limit: want 4, got %q", c.Docker.CPULimit) + } + if c.Docker.ShmSize != "2g" { + t.Errorf("shm_size: want 2g, got %q", c.Docker.ShmSize) + } +} + +func TestDockerSection_ResolvedMemLimit_Default(t *testing.T) { + t.Setenv("DEVCELL_DOCKER_MEM_LIMIT", "") + got := cfg.DockerSection{}.ResolvedMemLimit() + if got != "4g" { + t.Errorf("want default 4g, got %q", got) + } +} + +func TestDockerSection_ResolvedMemLimit_TOML(t *testing.T) { + t.Setenv("DEVCELL_DOCKER_MEM_LIMIT", "") + got := cfg.DockerSection{MemLimit: "16g"}.ResolvedMemLimit() + if got != "16g" { + t.Errorf("want 16g from toml, got %q", got) + } +} + +func TestDockerSection_ResolvedMemLimit_EnvWins(t *testing.T) { + t.Setenv("DEVCELL_DOCKER_MEM_LIMIT", "32g") + got := cfg.DockerSection{MemLimit: "16g"}.ResolvedMemLimit() + if got != "32g" { + t.Errorf("env should win over toml, got %q", got) + } +} + +func TestDockerSection_ResolvedMemLimit_ZeroUncaps(t *testing.T) { + t.Setenv("DEVCELL_DOCKER_MEM_LIMIT", "") + got := cfg.DockerSection{MemLimit: "0"}.ResolvedMemLimit() + if got != "0" { + t.Errorf("want 0 (uncapped), got %q", got) + } +} + +func TestDockerSection_ResolvedCPULimit_Default(t *testing.T) { + t.Setenv("DEVCELL_DOCKER_CPU_LIMIT", "") + got := cfg.DockerSection{}.ResolvedCPULimit() + if got != "2" { + t.Errorf("want default 2, got %q", got) + } +} + +func TestDockerSection_ResolvedCPULimit_TOML(t *testing.T) { + t.Setenv("DEVCELL_DOCKER_CPU_LIMIT", "") + got := cfg.DockerSection{CPULimit: "8"}.ResolvedCPULimit() + if got != "8" { + t.Errorf("want 8 from toml, got %q", got) + } +} + +func TestDockerSection_ResolvedCPULimit_EnvWins(t *testing.T) { + t.Setenv("DEVCELL_DOCKER_CPU_LIMIT", "16") + got := cfg.DockerSection{CPULimit: "8"}.ResolvedCPULimit() + if got != "16" { + t.Errorf("env should win over toml, got %q", got) + } +} + +func TestDockerSection_ResolvedShmSize_Default(t *testing.T) { + t.Setenv("DEVCELL_DOCKER_SHM_SIZE", "") + got := cfg.DockerSection{}.ResolvedShmSize() + if got != "1g" { + t.Errorf("want default 1g, got %q", got) + } +} + +func TestDockerSection_ResolvedShmSize_TOML(t *testing.T) { + t.Setenv("DEVCELL_DOCKER_SHM_SIZE", "") + got := cfg.DockerSection{ShmSize: "4g"}.ResolvedShmSize() + if got != "4g" { + t.Errorf("want 4g from toml, got %q", got) + } +} + +func TestDockerSection_ResolvedShmSize_EnvWins(t *testing.T) { + t.Setenv("DEVCELL_DOCKER_SHM_SIZE", "8g") + got := cfg.DockerSection{ShmSize: "4g"}.ResolvedShmSize() + if got != "8g" { + t.Errorf("env should win over toml, got %q", got) + } +} + +func TestMerge_DockerProjectWins(t *testing.T) { + global := cfg.CellConfig{Docker: cfg.DockerSection{MemLimit: "4g", CPULimit: "2", ShmSize: "1g", CapAdd: []string{"SYS_ADMIN"}}} + project := cfg.CellConfig{Docker: cfg.DockerSection{Privileged: true, CapAdd: []string{"NET_ADMIN"}, MemLimit: "16g", CPULimit: "8"}} + merged := cfg.Merge(global, project) + if !merged.Docker.Privileged { + t.Error("privileged: project true should win") + } + if len(merged.Docker.CapAdd) != 2 { + t.Errorf("cap_add: want union [SYS_ADMIN NET_ADMIN], got %v", merged.Docker.CapAdd) + } + if merged.Docker.MemLimit != "16g" { + t.Errorf("mem_limit: project should win, got %q", merged.Docker.MemLimit) + } + if merged.Docker.CPULimit != "8" { + t.Errorf("cpu_limit: project should win, got %q", merged.Docker.CPULimit) + } + if merged.Docker.ShmSize != "1g" { + t.Errorf("shm_size: global should be kept when project empty, got %q", merged.Docker.ShmSize) + } +} + +func TestMerge_DockerGlobalKeptWhenProjectEmpty(t *testing.T) { + global := cfg.CellConfig{Docker: cfg.DockerSection{CapAdd: []string{"SYS_ADMIN"}, MemLimit: "8g", CPULimit: "4", ShmSize: "2g"}} + project := cfg.CellConfig{} + merged := cfg.Merge(global, project) + if merged.Docker.Privileged { + t.Error("privileged: should stay false when neither sets it") + } + if len(merged.Docker.CapAdd) != 1 || merged.Docker.CapAdd[0] != "SYS_ADMIN" { + t.Errorf("cap_add: global should be preserved, got %v", merged.Docker.CapAdd) + } + if merged.Docker.MemLimit != "8g" { + t.Errorf("mem_limit: global should be preserved, got %q", merged.Docker.MemLimit) + } + if merged.Docker.CPULimit != "4" { + t.Errorf("cpu_limit: global should be preserved, got %q", merged.Docker.CPULimit) + } + if merged.Docker.ShmSize != "2g" { + t.Errorf("shm_size: global should be preserved, got %q", merged.Docker.ShmSize) + } +} + // --- Git section --- func TestLoadFile_GitSection(t *testing.T) { @@ -609,43 +1055,126 @@ func TestGitSection_ExplicitCommitterOverridesAuthor(t *testing.T) { if g.ResolvedCommitterName() != "Bot" { t.Errorf("want Bot, got %q", g.ResolvedCommitterName()) } - if g.ResolvedCommitterEmail() != "bot@ci.com" { - t.Errorf("want bot@ci.com, got %q", g.ResolvedCommitterEmail()) + if g.ResolvedCommitterEmail() != "bot@ci.com" { + t.Errorf("want bot@ci.com, got %q", g.ResolvedCommitterEmail()) + } +} + +// --- Stack and Modules fields --- + +func TestLoadFile_StackField(t *testing.T) { + dir := t.TempDir() + writeTOML(t, dir, "devcell.toml", ` +[cell] +stack = "go" +`) + c, err := cfg.LoadFile(filepath.Join(dir, "devcell.toml")) + if err != nil { + t.Fatal(err) + } + if c.Cell.Stack != "go" { + t.Errorf("stack: want go, got %q", c.Cell.Stack) + } +} + +func TestLoadFile_ModulesField(t *testing.T) { + dir := t.TempDir() + writeTOML(t, dir, "devcell.toml", ` +[cell] +modules = ["electronics", "desktop"] +`) + c, err := cfg.LoadFile(filepath.Join(dir, "devcell.toml")) + if err != nil { + t.Fatal(err) + } + if len(c.Cell.Modules) != 2 { + t.Fatalf("want 2 modules, got %d", len(c.Cell.Modules)) + } + // Sorted at load (CELL-331), not TOML order. + if c.Cell.Modules[0] != "desktop" || c.Cell.Modules[1] != "electronics" { + t.Errorf("modules: want [desktop electronics], got %v", c.Cell.Modules) + } +} + +// CELL-331: [a,b] and [b,a] must not produce different image tags or +// home-manager closures. Modules are sorted at load so every consumer +// (tag derivation, modules CSV, flake args) sees one canonical order. +func TestLoadFile_ModulesSorted(t *testing.T) { + dir := t.TempDir() + writeTOML(t, dir, "devcell.toml", ` +[cell] +modules = ["node", "electronics", "desktop"] +`) + c, err := cfg.LoadFile(filepath.Join(dir, "devcell.toml")) + if err != nil { + t.Fatal(err) + } + want := []string{"desktop", "electronics", "node"} + if len(c.Cell.Modules) != len(want) { + t.Fatalf("want %d modules, got %d", len(want), len(c.Cell.Modules)) + } + for i, m := range want { + if c.Cell.Modules[i] != m { + t.Fatalf("modules must be sorted: want %v, got %v", want, c.Cell.Modules) + } + } +} + +func TestLoadLayered_ModulesSortedAfterMerge(t *testing.T) { + dir := t.TempDir() + writeTOML(t, dir, "devcell.toml", ` +[cell] +modules = ["scraping"] +`) + writeTOML(t, dir, ".devcell.toml", ` +[cell] +modules = ["desktop"] +`) + c, err := cfg.LoadLayered( + filepath.Join(dir, "devcell.toml"), + filepath.Join(dir, ".devcell.toml"), + func(string) string { return "" }, + ) + if err != nil { + t.Fatal(err) + } + want := []string{"desktop", "scraping"} + if len(c.Cell.Modules) != len(want) { + t.Fatalf("want %v, got %v", want, c.Cell.Modules) + } + for i, m := range want { + if c.Cell.Modules[i] != m { + t.Fatalf("merged modules must be sorted: want %v, got %v", want, c.Cell.Modules) + } } } -// --- Stack and Modules fields --- - -func TestLoadFile_StackField(t *testing.T) { +// CELL-391: [cell] stale_warning = false silences the "cell is behind — +// parallel reality" nudge at start. Default (absent) is enabled. +func TestCellSection_StaleWarningDefaultsEnabled(t *testing.T) { dir := t.TempDir() - writeTOML(t, dir, "devcell.toml", ` -[cell] -stack = "go" -`) + writeTOML(t, dir, "devcell.toml", `[cell]`) c, err := cfg.LoadFile(filepath.Join(dir, "devcell.toml")) if err != nil { t.Fatal(err) } - if c.Cell.Stack != "go" { - t.Errorf("stack: want go, got %q", c.Cell.Stack) + if !c.Cell.StaleWarningEnabled() { + t.Error("stale warning must default to enabled") } } -func TestLoadFile_ModulesField(t *testing.T) { +func TestCellSection_StaleWarningFalseDisables(t *testing.T) { dir := t.TempDir() writeTOML(t, dir, "devcell.toml", ` [cell] -modules = ["electronics", "desktop"] +stale_warning = false `) c, err := cfg.LoadFile(filepath.Join(dir, "devcell.toml")) if err != nil { t.Fatal(err) } - if len(c.Cell.Modules) != 2 { - t.Fatalf("want 2 modules, got %d", len(c.Cell.Modules)) - } - if c.Cell.Modules[0] != "electronics" || c.Cell.Modules[1] != "desktop" { - t.Errorf("modules: want [electronics desktop], got %v", c.Cell.Modules) + if c.Cell.StaleWarningEnabled() { + t.Error("stale_warning = false must disable the nudge") } } @@ -1242,6 +1771,102 @@ func TestMerge_HostnameInheritsGlobal(t *testing.T) { } } +// --- DefaultCommand --- + +func TestLoadFile_DefaultCommand(t *testing.T) { + dir := t.TempDir() + writeTOML(t, dir, "devcell.toml", ` +[cell] +default_command = "claude" +`) + c, err := cfg.LoadFile(filepath.Join(dir, "devcell.toml")) + if err != nil { + t.Fatal(err) + } + if c.Cell.DefaultCommand != "claude" { + t.Errorf("want claude, got %q", c.Cell.DefaultCommand) + } +} + +func TestResolvedDefaultCommand_Empty(t *testing.T) { + t.Setenv("DEVCELL_DEFAULT_COMMAND", "") + got := cfg.CellSection{}.ResolvedDefaultCommand() + if got != "" { + t.Errorf("want empty, got %q", got) + } +} + +func TestResolvedDefaultCommand_TOML(t *testing.T) { + t.Setenv("DEVCELL_DEFAULT_COMMAND", "") + got := cfg.CellSection{DefaultCommand: "shell"}.ResolvedDefaultCommand() + if got != "shell" { + t.Errorf("want shell, got %q", got) + } +} + +func TestResolvedDefaultCommand_EnvOverridesTOML(t *testing.T) { + t.Setenv("DEVCELL_DEFAULT_COMMAND", "codex") + got := cfg.CellSection{DefaultCommand: "shell"}.ResolvedDefaultCommand() + if got != "codex" { + t.Errorf("env should win over toml, got %q", got) + } +} + +func TestMerge_DefaultCommandProjectWins(t *testing.T) { + global := cfg.CellConfig{Cell: cfg.CellSection{DefaultCommand: "shell"}} + project := cfg.CellConfig{Cell: cfg.CellSection{DefaultCommand: "claude"}} + got := cfg.Merge(global, project) + if got.Cell.DefaultCommand != "claude" { + t.Errorf("project default_command must override global; got %q", got.Cell.DefaultCommand) + } +} + +func TestMerge_DefaultCommandInheritsGlobal(t *testing.T) { + global := cfg.CellConfig{Cell: cfg.CellSection{DefaultCommand: "shell"}} + project := cfg.CellConfig{} + got := cfg.Merge(global, project) + if got.Cell.DefaultCommand != "shell" { + t.Errorf("global default_command must survive when project leaves it empty; got %q", got.Cell.DefaultCommand) + } +} + +func TestApplyEnv_DefaultCommand(t *testing.T) { + c := cfg.CellConfig{Cell: cfg.CellSection{DefaultCommand: "shell"}} + cfg.ApplyEnv(&c, func(k string) string { + if k == "DEVCELL_DEFAULT_COMMAND" { + return "claude" + } + return "" + }) + if c.Cell.DefaultCommand != "claude" { + t.Errorf("ApplyEnv should override default_command; got %q", c.Cell.DefaultCommand) + } +} + +func TestValidateDefaultCommand_Valid(t *testing.T) { + for _, cmd := range cfg.KnownDefaultCommands() { + if err := cfg.ValidateDefaultCommand(cmd); err != nil { + t.Errorf("valid command %q should not error: %v", cmd, err) + } + } +} + +func TestValidateDefaultCommand_Empty(t *testing.T) { + if err := cfg.ValidateDefaultCommand(""); err != nil { + t.Errorf("empty should be valid: %v", err) + } +} + +func TestValidateDefaultCommand_Invalid(t *testing.T) { + err := cfg.ValidateDefaultCommand("notacommand") + if err == nil { + t.Fatal("expected error for invalid command") + } + if !strings.Contains(err.Error(), "notacommand") { + t.Errorf("error should mention the invalid command: %v", err) + } +} + // --- Op section --- func TestLoadFile_OpDocuments(t *testing.T) { @@ -1940,3 +2565,484 @@ mount = "/path/with\~invalid/escape:/bar" t.Fatal("LoadFromOSWithDirs must return an error when project TOML has a parse error") } } + +// The append surface is separate from system_prompt: after CELL-408, +// system_prompt replaces Claude Code's built-in prompt, so a distinct key is +// needed for text that layers on top of whichever base is in effect. +func TestLoadFile_LLMAppendSystemPrompt(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "devcell.toml") + if err := os.WriteFile(path, []byte(` +[llm] +append_system_prompt = "always run gofmt" +append_system_prompt_file = "prompts/extra.md" +`), 0o644); err != nil { + t.Fatal(err) + } + + c, err := cfg.LoadFile(path) + if err != nil { + t.Fatalf("LoadFile: %v", err) + } + if c.LLM.AppendSystemPrompt != "always run gofmt" { + t.Errorf("append_system_prompt = %q", c.LLM.AppendSystemPrompt) + } + if c.LLM.AppendSystemPromptFile != "prompts/extra.md" { + t.Errorf("append_system_prompt_file = %q", c.LLM.AppendSystemPromptFile) + } +} + +// Merge is hand-written per LLM field, so a new key silently ignores the +// project value unless an explicit override line is added. +func TestMerge_LLMAppendSystemPromptProjectOverridesGlobal(t *testing.T) { + global := cfg.CellConfig{LLM: cfg.LLMSection{ + AppendSystemPrompt: "global append", + AppendSystemPromptFile: "global.md", + }} + project := cfg.CellConfig{LLM: cfg.LLMSection{ + AppendSystemPrompt: "project append", + AppendSystemPromptFile: "project.md", + }} + + out := cfg.Merge(global, project) + + if out.LLM.AppendSystemPrompt != "project append" { + t.Errorf("append_system_prompt = %q, want project value", out.LLM.AppendSystemPrompt) + } + if out.LLM.AppendSystemPromptFile != "project.md" { + t.Errorf("append_system_prompt_file = %q, want project value", out.LLM.AppendSystemPromptFile) + } +} + +// An unset project value must not blank out the global one. +func TestMerge_LLMAppendSystemPromptGlobalSurvivesEmptyProject(t *testing.T) { + global := cfg.CellConfig{LLM: cfg.LLMSection{AppendSystemPrompt: "global append"}} + + out := cfg.Merge(global, cfg.CellConfig{}) + + if out.LLM.AppendSystemPrompt != "global append" { + t.Errorf("append_system_prompt = %q, want global value preserved", out.LLM.AppendSystemPrompt) + } +} + +// ── CELL-446: Packages merge ──────────────────────────────────────────────── + +func TestMerge_PackagesNpmAccumulates(t *testing.T) { + global := cfg.CellConfig{Packages: cfg.PackagesSection{ + Npm: map[string]string{"prettier": "*", "eslint": "8"}, + }} + project := cfg.CellConfig{Packages: cfg.PackagesSection{ + Npm: map[string]string{"eslint": "9", "typescript": "*"}, + }} + merged := cfg.Merge(global, project) + if merged.Packages.Npm["prettier"] != "*" { + t.Errorf("prettier should be *, got %q", merged.Packages.Npm["prettier"]) + } + if merged.Packages.Npm["eslint"] != "9" { + t.Errorf("eslint: project should win, got %q", merged.Packages.Npm["eslint"]) + } + if merged.Packages.Npm["typescript"] != "*" { + t.Errorf("typescript should be *, got %q", merged.Packages.Npm["typescript"]) + } +} + +func TestMerge_PackagesPythonAccumulates(t *testing.T) { + global := cfg.CellConfig{Packages: cfg.PackagesSection{ + Python: map[string]string{"pre-commit": "*"}, + }} + project := cfg.CellConfig{Packages: cfg.PackagesSection{ + Python: map[string]string{"black": "*"}, + }} + merged := cfg.Merge(global, project) + if merged.Packages.Python["pre-commit"] != "*" { + t.Errorf("pre-commit should be *, got %q", merged.Packages.Python["pre-commit"]) + } + if merged.Packages.Python["black"] != "*" { + t.Errorf("black should be *, got %q", merged.Packages.Python["black"]) + } +} + +func TestMerge_PackagesGlobalSurvivesEmptyProject(t *testing.T) { + global := cfg.CellConfig{Packages: cfg.PackagesSection{ + Npm: map[string]string{"prettier": "*"}, + }} + merged := cfg.Merge(global, cfg.CellConfig{}) + if merged.Packages.Npm["prettier"] != "*" { + t.Errorf("global npm packages should survive empty project, got %q", merged.Packages.Npm["prettier"]) + } +} + +// ── CELL-445: NixPackages parsing and merge ───────────────────────────────── + +func TestLoadFile_NixPackages(t *testing.T) { + dir := t.TempDir() + writeTOML(t, dir, "devcell.toml", ` +[packages.nix] +stable = ["tmux", "htop"] +unstable = ["some-tool"] +edge = ["bleeding-edge"] +`) + c, err := cfg.LoadFile(filepath.Join(dir, "devcell.toml")) + if err != nil { + t.Fatal(err) + } + wantStable := []string{"tmux", "htop"} + if len(c.Packages.Nix.Stable) != 2 || c.Packages.Nix.Stable[0] != wantStable[0] || c.Packages.Nix.Stable[1] != wantStable[1] { + t.Errorf("stable = %v, want %v", c.Packages.Nix.Stable, wantStable) + } + if len(c.Packages.Nix.Unstable) != 1 || c.Packages.Nix.Unstable[0] != "some-tool" { + t.Errorf("unstable = %v, want [some-tool]", c.Packages.Nix.Unstable) + } + if len(c.Packages.Nix.Edge) != 1 || c.Packages.Nix.Edge[0] != "bleeding-edge" { + t.Errorf("edge = %v, want [bleeding-edge]", c.Packages.Nix.Edge) + } +} + +func TestLoadFile_NixPackagesEmpty(t *testing.T) { + dir := t.TempDir() + writeTOML(t, dir, "devcell.toml", ` +[packages.nix] +stable = [] +`) + c, err := cfg.LoadFile(filepath.Join(dir, "devcell.toml")) + if err != nil { + t.Fatal(err) + } + if c.Packages.Nix.Stable == nil || len(c.Packages.Nix.Stable) != 0 { + t.Errorf("stable should be empty non-nil slice, got %v (nil=%v)", c.Packages.Nix.Stable, c.Packages.Nix.Stable == nil) + } +} + +func TestMerge_NixPackagesUnionDedup(t *testing.T) { + global := cfg.CellConfig{Packages: cfg.PackagesSection{ + Nix: cfg.NixPackages{ + Stable: []string{"tmux", "htop"}, + Unstable: []string{"tool-a"}, + }, + }} + project := cfg.CellConfig{Packages: cfg.PackagesSection{ + Nix: cfg.NixPackages{ + Stable: []string{"htop", "cowsay"}, + Unstable: []string{"tool-b"}, + Edge: []string{"edge-pkg"}, + }, + }} + merged := cfg.Merge(global, project) + wantStable := []string{"cowsay", "htop", "tmux"} + if strings.Join(merged.Packages.Nix.Stable, ",") != strings.Join(wantStable, ",") { + t.Errorf("stable = %v, want %v (union, deduped, sorted)", merged.Packages.Nix.Stable, wantStable) + } + wantUnstable := []string{"tool-a", "tool-b"} + if strings.Join(merged.Packages.Nix.Unstable, ",") != strings.Join(wantUnstable, ",") { + t.Errorf("unstable = %v, want %v", merged.Packages.Nix.Unstable, wantUnstable) + } + wantEdge := []string{"edge-pkg"} + if strings.Join(merged.Packages.Nix.Edge, ",") != strings.Join(wantEdge, ",") { + t.Errorf("edge = %v, want %v", merged.Packages.Nix.Edge, wantEdge) + } +} + +func TestMerge_NixPackagesEscapeHatch(t *testing.T) { + global := cfg.CellConfig{Packages: cfg.PackagesSection{ + Nix: cfg.NixPackages{Stable: []string{"tmux", "htop"}}, + }} + project := cfg.CellConfig{Packages: cfg.PackagesSection{ + Nix: cfg.NixPackages{Stable: []string{}}, + }} + merged := cfg.Merge(global, project) + if len(merged.Packages.Nix.Stable) != 0 { + t.Errorf("explicit empty stable in project should clear global, got %v", merged.Packages.Nix.Stable) + } +} + +func TestMerge_NixPackagesGlobalSurvivesNilProject(t *testing.T) { + global := cfg.CellConfig{Packages: cfg.PackagesSection{ + Nix: cfg.NixPackages{Stable: []string{"tmux"}}, + }} + merged := cfg.Merge(global, cfg.CellConfig{}) + if len(merged.Packages.Nix.Stable) != 1 || merged.Packages.Nix.Stable[0] != "tmux" { + t.Errorf("global nix stable should survive nil project, got %v", merged.Packages.Nix.Stable) + } +} + +// --- Wireguard --- + +func TestLoadFile_WireguardSection(t *testing.T) { + dir := t.TempDir() + writeTOML(t, dir, "devcell.toml", ` +[[wireguard]] +name = "proton-pt" +enabled = true +config = """ +[Interface] +Address = 10.2.0.2/32 +""" +`) + c, err := cfg.LoadFile(filepath.Join(dir, "devcell.toml")) + if err != nil { + t.Fatal(err) + } + if len(c.Wireguard) != 1 { + t.Fatalf("expected 1 wireguard entry, got %d", len(c.Wireguard)) + } + wg := c.Wireguard[0] + if wg.Name != "proton-pt" { + t.Errorf("name: want proton-pt, got %q", wg.Name) + } + if !wg.Enabled { + t.Error("expected enabled=true") + } + if !strings.Contains(wg.Config, "10.2.0.2/32") { + t.Errorf("config should contain address, got %q", wg.Config) + } +} + +func TestLoadFile_WireguardMultiple(t *testing.T) { + dir := t.TempDir() + writeTOML(t, dir, "devcell.toml", ` +[[wireguard]] +name = "tunnel-a" +enabled = true +config = "config-a" + +[[wireguard]] +name = "tunnel-b" +enabled = false +config = "config-b" +`) + c, err := cfg.LoadFile(filepath.Join(dir, "devcell.toml")) + if err != nil { + t.Fatal(err) + } + if len(c.Wireguard) != 2 { + t.Fatalf("expected 2 wireguard entries, got %d", len(c.Wireguard)) + } + if c.Wireguard[0].Name != "tunnel-a" || c.Wireguard[1].Name != "tunnel-b" { + t.Errorf("unexpected names: %q, %q", c.Wireguard[0].Name, c.Wireguard[1].Name) + } + if !c.Wireguard[0].Enabled || c.Wireguard[1].Enabled { + t.Error("expected first enabled, second disabled") + } +} + +func TestValidateWireguard_EnabledRequiresConfig(t *testing.T) { + c := cfg.CellConfig{ + Wireguard: []cfg.WireguardEntry{{Name: "test", Enabled: true, Config: ""}}, + } + err := cfg.ValidateWireguard(c) + if err == nil { + t.Fatal("expected error when enabled=true but config is empty") + } + if !strings.Contains(err.Error(), "config") { + t.Errorf("error should mention config, got: %v", err) + } +} + +func TestValidateWireguard_EnabledRequiresName(t *testing.T) { + c := cfg.CellConfig{ + Wireguard: []cfg.WireguardEntry{{Name: "", Enabled: true, Config: "some config"}}, + } + err := cfg.ValidateWireguard(c) + if err == nil { + t.Fatal("expected error when enabled=true but name is empty") + } + if !strings.Contains(err.Error(), "name") { + t.Errorf("error should mention name, got: %v", err) + } +} + +func TestValidateWireguard_DisabledSkipsValidation(t *testing.T) { + c := cfg.CellConfig{ + Wireguard: []cfg.WireguardEntry{{Name: "", Enabled: false, Config: ""}}, + } + if err := cfg.ValidateWireguard(c); err != nil { + t.Errorf("disabled entry should not be validated, got: %v", err) + } +} + +func TestValidateWireguard_NoEntries(t *testing.T) { + c := cfg.CellConfig{} + if err := cfg.ValidateWireguard(c); err != nil { + t.Errorf("no wireguard entries should pass validation, got: %v", err) + } +} + +func TestMerge_WireguardAccumulates(t *testing.T) { + global := cfg.CellConfig{ + Wireguard: []cfg.WireguardEntry{{Name: "global-tun", Enabled: true, Config: "g"}}, + } + project := cfg.CellConfig{ + Wireguard: []cfg.WireguardEntry{{Name: "project-tun", Enabled: true, Config: "p"}}, + } + merged := cfg.Merge(global, project) + if len(merged.Wireguard) != 2 { + t.Fatalf("expected 2 wireguard entries after merge, got %d", len(merged.Wireguard)) + } + if merged.Wireguard[0].Name != "global-tun" || merged.Wireguard[1].Name != "project-tun" { + t.Errorf("unexpected order: %q, %q", merged.Wireguard[0].Name, merged.Wireguard[1].Name) + } +} + +func TestMerge_WireguardDedupByName(t *testing.T) { + global := cfg.CellConfig{ + Wireguard: []cfg.WireguardEntry{{Name: "tun", Enabled: false, Config: "old"}}, + } + project := cfg.CellConfig{ + Wireguard: []cfg.WireguardEntry{{Name: "tun", Enabled: true, Config: "new"}}, + } + merged := cfg.Merge(global, project) + if len(merged.Wireguard) != 1 { + t.Fatalf("expected dedup to 1 entry, got %d", len(merged.Wireguard)) + } + if !merged.Wireguard[0].Enabled || merged.Wireguard[0].Config != "new" { + t.Error("project entry should win on name conflict") + } +} + +func TestWireguardEnabled_NoneEnabled(t *testing.T) { + c := cfg.CellConfig{ + Wireguard: []cfg.WireguardEntry{{Name: "tun", Enabled: false, Config: "c"}}, + } + if cfg.WireguardEnabled(c) { + t.Error("expected WireguardEnabled=false when no entry is enabled") + } +} + +func TestWireguardEnabled_OneEnabled(t *testing.T) { + c := cfg.CellConfig{ + Wireguard: []cfg.WireguardEntry{{Name: "tun", Enabled: true, Config: "c"}}, + } + if !cfg.WireguardEnabled(c) { + t.Error("expected WireguardEnabled=true when an entry is enabled") + } +} + +func TestWireguardEnabled_Empty(t *testing.T) { + c := cfg.CellConfig{} + if cfg.WireguardEnabled(c) { + t.Error("expected WireguardEnabled=false when no wireguard entries") + } +} + +func TestValidateWireguard_ValidProtonVPNConfig(t *testing.T) { + c := cfg.CellConfig{ + Wireguard: []cfg.WireguardEntry{{ + Name: "proton-pt", + Enabled: true, + Config: `[Interface] +Address = 10.2.0.2/32, 2a07:b944::2:2/128 +DNS = 10.2.0.1, 2a07:b944::2:1 +PostUp = wg set %i private-key /run/secrets/wg-private-key + +[Peer] +PublicKey = fkBdrgo6NaOI9ICRd+i2mDbieKUzEXkj4vX3ItZ+5lM= +AllowedIPs = 0.0.0.0/0, ::/0 +Endpoint = 79.127.131.222:51820 +PersistentKeepalive = 25`, + }}, + } + if err := cfg.ValidateWireguard(c); err != nil { + t.Fatalf("valid ProtonVPN config should pass, got: %v", err) + } +} + +func TestValidateWireguard_MissingPeer(t *testing.T) { + c := cfg.CellConfig{ + Wireguard: []cfg.WireguardEntry{{ + Name: "no-peer", + Enabled: true, + Config: `[Interface] +Address = 10.2.0.2/32 +DNS = 10.2.0.1`, + }}, + } + err := cfg.ValidateWireguard(c) + if err == nil { + t.Fatal("expected error when config has no [Peer] section") + } + if !strings.Contains(err.Error(), "peer") && !strings.Contains(err.Error(), "Peer") { + t.Errorf("error should mention peer, got: %v", err) + } +} + +func TestValidateWireguard_MissingPublicKey(t *testing.T) { + c := cfg.CellConfig{ + Wireguard: []cfg.WireguardEntry{{ + Name: "no-pubkey", + Enabled: true, + Config: `[Interface] +Address = 10.2.0.2/32 + +[Peer] +Endpoint = 1.2.3.4:51820 +AllowedIPs = 0.0.0.0/0`, + }}, + } + err := cfg.ValidateWireguard(c) + if err == nil { + t.Fatal("expected error when peer has no PublicKey") + } + if !strings.Contains(err.Error(), "PublicKey") { + t.Errorf("error should mention PublicKey, got: %v", err) + } +} + +func TestValidateWireguard_InvalidPublicKey(t *testing.T) { + c := cfg.CellConfig{ + Wireguard: []cfg.WireguardEntry{{ + Name: "bad-key", + Enabled: true, + Config: `[Interface] +Address = 10.2.0.2/32 + +[Peer] +PublicKey = not-valid-base64!!! +AllowedIPs = 0.0.0.0/0`, + }}, + } + err := cfg.ValidateWireguard(c) + if err == nil { + t.Fatal("expected error for invalid base64 PublicKey") + } +} + +func TestValidateWireguard_MissingAddress(t *testing.T) { + c := cfg.CellConfig{ + Wireguard: []cfg.WireguardEntry{{ + Name: "no-addr", + Enabled: true, + Config: `[Interface] +DNS = 10.2.0.1 + +[Peer] +PublicKey = fkBdrgo6NaOI9ICRd+i2mDbieKUzEXkj4vX3ItZ+5lM= +AllowedIPs = 0.0.0.0/0`, + }}, + } + err := cfg.ValidateWireguard(c) + if err == nil { + t.Fatal("expected error when config has no Address") + } + if !strings.Contains(err.Error(), "Address") { + t.Errorf("error should mention Address, got: %v", err) + } +} + +func TestValidateWireguard_PrivateKeyNotRequired(t *testing.T) { + c := cfg.CellConfig{ + Wireguard: []cfg.WireguardEntry{{ + Name: "no-privkey", + Enabled: true, + Config: `[Interface] +Address = 10.2.0.2/32 +PostUp = wg set %i private-key /run/secrets/wg-private-key + +[Peer] +PublicKey = fkBdrgo6NaOI9ICRd+i2mDbieKUzEXkj4vX3ItZ+5lM= +AllowedIPs = 0.0.0.0/0`, + }}, + } + if err := cfg.ValidateWireguard(c); err != nil { + t.Fatalf("PrivateKey should not be required (loaded via PostUp), got: %v", err) + } +} diff --git a/internal/cfg/hmoptions.go b/internal/cfg/hmoptions.go new file mode 100644 index 0000000..57a2356 --- /dev/null +++ b/internal/cfg/hmoptions.go @@ -0,0 +1,121 @@ +package cfg + +import ( + "fmt" + "reflect" + "strings" +) + +// HMOptionsNix renders the home-manager option declarations for the +// devcell.toml schema by reflecting over CellConfig. The generated file +// (nix/home-manager/options.nix, written by `task hm:generate`) is what +// keeps `devcell.*` options in lockstep with the Go schema — hand-editing +// it would reintroduce drift, hence the DO NOT EDIT header. +// +// Mapping rules: +// - string → types.str, int → types.int, bool / *bool → types.bool +// - []string → listOf str, map[string]string → attrsOf str +// - struct field → plain nested attrset (a TOML table the user sets +// directly: devcell.llm.system_prompt = "...") +// - []struct / map[string]struct → listOf/attrsOf (submodule { ... }) +// +// Every leaf is nullOr with default null: unset options never reach the +// rendered TOML, so absence semantics (e.g. modules unset vs modules = []) +// match a hand-written devcell.toml. +func HMOptionsNix() string { + var b strings.Builder + b.WriteString(`# Code generated by hmoptgen from internal/cfg.CellConfig; DO NOT EDIT. +# Regenerate: task hm:generate (runs automatically as a dep of cell:build). +# Each leaf mirrors one devcell.toml key; null (the default) omits the key. +{ lib }: +let + inherit (lib) mkOption types; + opt = type: mkOption { + type = types.nullOr type; + default = null; + }; +in +`) + writeSection(&b, reflect.TypeOf(CellConfig{}), 0) + b.WriteString("\n") + return b.String() +} + +// writeSection emits a struct as a nix attrset of options, one line per leaf. +func writeSection(b *strings.Builder, t reflect.Type, depth int) { + pad := strings.Repeat(" ", depth) + b.WriteString("{\n") + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + if !f.IsExported() { + continue + } + key := tomlKey(f) + if key == "-" { + continue + } + ft := f.Type + if ft.Kind() == reflect.Struct { + fmt.Fprintf(b, "%s %s = ", pad, key) + writeSection(b, ft, depth+1) + b.WriteString(";\n") + continue + } + fmt.Fprintf(b, "%s %s = opt %s;\n", pad, key, nixType(ft, depth+1)) + } + fmt.Fprintf(b, "%s}", pad) +} + +// nixType maps a Go type to a nix `types.*` expression. Composite types are +// parenthesized so they slot into `opt (...)` unambiguously. +func nixType(t reflect.Type, depth int) string { + switch t.Kind() { + case reflect.String: + return "types.str" + case reflect.Bool: + return "types.bool" + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return "types.int" + case reflect.Float32, reflect.Float64: + return "types.float" + case reflect.Pointer: + return nixType(t.Elem(), depth) + case reflect.Slice: + return fmt.Sprintf("(types.listOf %s)", elemType(t.Elem(), depth)) + case reflect.Map: + return fmt.Sprintf("(types.attrsOf %s)", elemType(t.Elem(), depth)) + default: + panic(fmt.Sprintf("hmoptions: unsupported Go type %s — extend nixType", t)) + } +} + +// elemType renders a slice/map element type; structs become submodules. +func elemType(t reflect.Type, depth int) string { + if t.Kind() != reflect.Struct { + return strings.TrimSuffix(strings.TrimPrefix(nixType(t, depth), "("), ")") + } + var b strings.Builder + b.WriteString("(types.submodule {\n") + pad := strings.Repeat(" ", depth) + fmt.Fprintf(&b, "%s options = ", pad) + writeSection(&b, t, depth+1) + b.WriteString(";\n") + fmt.Fprintf(&b, "%s})", pad) + return b.String() +} + +// tomlKey resolves the TOML key for a struct field: the tag's name part, +// falling back to the lowercased field name (BurntSushi's untagged default +// is a case-insensitive field match; devcell configs use lowercase keys). +func tomlKey(f reflect.StructField) string { + tag := f.Tag.Get("toml") + if tag == "" { + return strings.ToLower(f.Name) + } + name, _, _ := strings.Cut(tag, ",") + if name == "" { + return strings.ToLower(f.Name) + } + return name +} diff --git a/internal/cfg/hmoptions_test.go b/internal/cfg/hmoptions_test.go new file mode 100644 index 0000000..875a0e2 --- /dev/null +++ b/internal/cfg/hmoptions_test.go @@ -0,0 +1,116 @@ +package cfg + +import ( + "strings" + "testing" +) + +// The home-manager module's option set is generated from CellConfig so it +// can never drift from the Go TOML schema. `task hm:generate` (a dep of +// cell:build) writes nix/home-manager/options.nix from HMOptionsNix. + +func TestHMOptionsNix_HeaderMarksGenerated(t *testing.T) { + out := HMOptionsNix() + for _, want := range []string{"Code generated", "DO NOT EDIT", "task hm:generate"} { + if !strings.Contains(out, want) { + t.Errorf("header missing %q", want) + } + } +} + +func TestHMOptionsNix_EmitsAllTopLevelSections(t *testing.T) { + out := HMOptionsNix() + for _, section := range []string{ + "cell = {", "build = {", "nix = {", "llm = {", "git = {", + "ports = {", "op = {", "aws = {", "stealth = {", "gui = {", + "packages = {", + } { + if !strings.Contains(out, section) { + t.Errorf("missing section %q", section) + } + } + // Map- and list-typed top levels are leaves, not sections. + for _, leaf := range []string{ + "env = opt (types.attrsOf types.str);", + "mise = opt (types.attrsOf types.str);", + } { + if !strings.Contains(out, leaf) { + t.Errorf("missing leaf %q", leaf) + } + } +} + +func TestHMOptionsNix_MapsGoTypesToNixTypes(t *testing.T) { + out := HMOptionsNix() + cases := map[string]string{ + "string": "image_tag = opt types.str;", + "*bool": "thin = opt types.bool;", + "bool": "privileged = opt types.bool;", + "int": "qemu_cpus = opt types.int;", + "[]string": "modules = opt (types.listOf types.str);", + "map[string]string": "libvirt_path_map = opt (types.attrsOf types.str);", + } + for goType, want := range cases { + if !strings.Contains(out, want) { + t.Errorf("%s mapping: missing %q", goType, want) + } + } +} + +func TestHMOptionsNix_NestedStructsBecomeSubmodules(t *testing.T) { + out := HMOptionsNix() + // map[string]LLMProvider → attrsOf submodule + if !strings.Contains(out, "providers = opt (types.attrsOf (types.submodule") { + t.Error("llm.models.providers should be attrsOf submodule") + } + if !strings.Contains(out, "base_url = opt types.str;") { + t.Error("LLMProvider.base_url leaf missing") + } + // []VolumeMount → listOf submodule + if !strings.Contains(out, "volumes = opt (types.listOf (types.submodule") { + t.Error("volumes should be listOf submodule") + } + if !strings.Contains(out, "mount = opt types.str;") { + t.Error("VolumeMount.mount leaf missing") + } + // LLMSection.Models is a plain nested section, not a submodule + if !strings.Contains(out, "models = {") { + t.Error("llm.models should be a plain nested section") + } +} + +func TestHMOptionsNix_Deterministic(t *testing.T) { + first := HMOptionsNix() + second := HMOptionsNix() + if first != second { + t.Error("output must be deterministic") + } +} + +func TestHMOptionsNix_BalancedBracesAndParens(t *testing.T) { + out := HMOptionsNix() + if n := strings.Count(out, "{") - strings.Count(out, "}"); n != 0 { + t.Errorf("unbalanced braces: %+d", n) + } + if n := strings.Count(out, "(") - strings.Count(out, ")"); n != 0 { + t.Errorf("unbalanced parens: %+d", n) + } +} + +// The global home-manager layer must be able to express BOTH prompt layers. +// options.nix is generated from CellConfig, so the leaves appear only if the +// Go fields exist — this guards the regeneration step. +func TestHMOptionsNix_EmitsBothPromptLayers(t *testing.T) { + out := HMOptionsNix() + + for _, leaf := range []string{ + "system_prompt = opt types.str;", + "system_prompt_file = opt types.str;", + "append_system_prompt = opt types.str;", + "append_system_prompt_file = opt types.str;", + } { + if !strings.Contains(out, leaf) { + t.Errorf("generated options.nix missing %q", leaf) + } + } +} diff --git a/internal/cfg/kvm_test.go b/internal/cfg/kvm_test.go new file mode 100644 index 0000000..cffcaac --- /dev/null +++ b/internal/cfg/kvm_test.go @@ -0,0 +1,99 @@ +package cfg_test + +import ( + "path/filepath" + "testing" + + "github.com/DimmKirr/devcell/internal/cfg" +) + +// --- KVM field --- +// +// `[cell] kvm = true` opts the container into /dev/kvm passthrough. It cannot +// be auto-detected: the device lives on the *docker daemon* host (the Colima +// VM), which the CLI cannot stat from macOS. Hence explicit opt-in, with +// DEVCELL_KVM as the escape hatch for hosts without nested virtualization. + +func TestLoadFile_KVMTrue(t *testing.T) { + dir := t.TempDir() + writeTOML(t, dir, "devcell.toml", ` +[cell] +kvm = true +`) + c, err := cfg.LoadFile(filepath.Join(dir, "devcell.toml")) + if err != nil { + t.Fatal(err) + } + if !c.Cell.ResolvedKVM() { + t.Error("expected ResolvedKVM()=true after parsing kvm=true") + } +} + +func TestLoadFile_KVMFalse(t *testing.T) { + dir := t.TempDir() + writeTOML(t, dir, "devcell.toml", ` +[cell] +kvm = false +`) + c, err := cfg.LoadFile(filepath.Join(dir, "devcell.toml")) + if err != nil { + t.Fatal(err) + } + if c.Cell.ResolvedKVM() { + t.Error("expected ResolvedKVM()=false after parsing kvm=false") + } +} + +func TestLoadFile_KVMDefaultsFalse(t *testing.T) { + dir := t.TempDir() + writeTOML(t, dir, "devcell.toml", `[cell]`) + c, err := cfg.LoadFile(filepath.Join(dir, "devcell.toml")) + if err != nil { + t.Fatal(err) + } + if c.Cell.KVM != nil { + t.Error("expected KVM=nil when not set in TOML") + } + if c.Cell.ResolvedKVM() { + t.Error("expected ResolvedKVM()=false when kvm not set (opt-in)") + } +} + +func TestResolvedKVM_EnvEnables(t *testing.T) { + t.Setenv("DEVCELL_KVM", "1") + c := cfg.CellSection{} + if !c.ResolvedKVM() { + t.Error("DEVCELL_KVM=1 must enable KVM even when toml is unset") + } +} + +func TestResolvedKVM_EnvDisablesOverTOML(t *testing.T) { + t.Setenv("DEVCELL_KVM", "0") + c := cfg.CellSection{KVM: boolPtr(true)} + if c.ResolvedKVM() { + t.Error("DEVCELL_KVM=0 must override kvm=true (host without nested virt)") + } +} + +func TestMerge_KVMProjectTrueOverGlobalFalse(t *testing.T) { + global := cfg.CellConfig{Cell: cfg.CellSection{KVM: boolPtr(false)}} + project := cfg.CellConfig{Cell: cfg.CellSection{KVM: boolPtr(true)}} + if !cfg.Merge(global, project).Cell.ResolvedKVM() { + t.Error("expected project kvm=true to win over global kvm=false") + } +} + +func TestMerge_KVMProjectFalseOverGlobalTrue(t *testing.T) { + global := cfg.CellConfig{Cell: cfg.CellSection{KVM: boolPtr(true)}} + project := cfg.CellConfig{Cell: cfg.CellSection{KVM: boolPtr(false)}} + if cfg.Merge(global, project).Cell.ResolvedKVM() { + t.Error("expected project kvm=false to win over global kvm=true") + } +} + +func TestMerge_KVMGlobalKeptWhenProjectUnset(t *testing.T) { + global := cfg.CellConfig{Cell: cfg.CellSection{KVM: boolPtr(true)}} + if !cfg.Merge(global, cfg.CellConfig{}).Cell.ResolvedKVM() { + t.Error("expected global kvm=true to survive when project omits kvm") + } +} diff --git a/internal/cfg/libvirt_test.go b/internal/cfg/libvirt_test.go new file mode 100644 index 0000000..6e82fa7 --- /dev/null +++ b/internal/cfg/libvirt_test.go @@ -0,0 +1,187 @@ +package cfg_test + +import ( + "path/filepath" + "testing" + + "github.com/DimmKirr/devcell/internal/cfg" +) + +// --- libvirt_uri field (CELL-372) --- +// +// `[cell] libvirt_uri` points the libvirt remote-run mode at a libvirtd +// daemon. The default targets the macOS host's session daemon as seen from +// inside a Docker cell: qemu+tcp://host.docker.internal/session. + +func TestLoadFile_LibvirtURI(t *testing.T) { + dir := t.TempDir() + writeTOML(t, dir, "devcell.toml", ` +[cell] +libvirt_uri = "qemu+ssh://user@mac/session" +`) + c, err := cfg.LoadFile(filepath.Join(dir, "devcell.toml")) + if err != nil { + t.Fatal(err) + } + if got := c.Cell.LibvirtURI; got != "qemu+ssh://user@mac/session" { + t.Errorf("LibvirtURI = %q, want %q", got, "qemu+ssh://user@mac/session") + } +} + +func TestResolvedLibvirtURI_Default(t *testing.T) { + c := cfg.CellSection{} + if got := c.ResolvedLibvirtURI(); got != cfg.DefaultLibvirtURI { + t.Errorf("ResolvedLibvirtURI() = %q, want default %q", got, cfg.DefaultLibvirtURI) + } +} + +func TestDefaultLibvirtURI_TargetsDockerHostSession(t *testing.T) { + if cfg.DefaultLibvirtURI != "qemu+tcp://host.docker.internal/session" { + t.Errorf("DefaultLibvirtURI = %q, want qemu+tcp://host.docker.internal/session", cfg.DefaultLibvirtURI) + } +} + +func TestResolvedLibvirtURI_TOMLOverridesDefault(t *testing.T) { + c := cfg.CellSection{LibvirtURI: "qemu+tcp://10.0.0.5/system"} + if got := c.ResolvedLibvirtURI(); got != "qemu+tcp://10.0.0.5/system" { + t.Errorf("ResolvedLibvirtURI() = %q, want toml value", got) + } +} + +func TestResolvedLibvirtURI_EnvOverridesTOML(t *testing.T) { + t.Setenv("DEVCELL_LIBVIRT_URI", "qemu+tcp://envhost/session") + c := cfg.CellSection{LibvirtURI: "qemu+tcp://tomlhost/session"} + if got := c.ResolvedLibvirtURI(); got != "qemu+tcp://envhost/session" { + t.Errorf("ResolvedLibvirtURI() = %q, want env value", got) + } +} + +func TestMerge_LibvirtURIProjectWins(t *testing.T) { + global := cfg.CellConfig{Cell: cfg.CellSection{LibvirtURI: "qemu+tcp://global/session"}} + project := cfg.CellConfig{Cell: cfg.CellSection{LibvirtURI: "qemu+tcp://project/session"}} + if got := cfg.Merge(global, project).Cell.LibvirtURI; got != "qemu+tcp://project/session" { + t.Errorf("merged LibvirtURI = %q, want project value", got) + } +} + +func TestMerge_LibvirtURIGlobalKeptWhenProjectUnset(t *testing.T) { + global := cfg.CellConfig{Cell: cfg.CellSection{LibvirtURI: "qemu+tcp://global/session"}} + if got := cfg.Merge(global, cfg.CellConfig{}).Cell.LibvirtURI; got != "qemu+tcp://global/session" { + t.Errorf("merged LibvirtURI = %q, want global value preserved", got) + } +} + +// Engine must survive Merge: a project-level `engine = "libvirt"` is how the +// dispatch in runAgent/build/init selects the engine, and Merge starts from +// the global Cell section — without an explicit override the project value +// silently vanishes whenever a global config file exists. +func TestMerge_EngineProjectWins(t *testing.T) { + global := cfg.CellConfig{Cell: cfg.CellSection{Engine: "docker"}} + project := cfg.CellConfig{Cell: cfg.CellSection{Engine: "libvirt"}} + if got := cfg.Merge(global, project).Cell.Engine; got != "libvirt" { + t.Errorf("merged Engine = %q, want %q", got, "libvirt") + } +} + +// --- libvirt_path_map (CELL-375) --- + +func TestLoadFile_LibvirtPathMap(t *testing.T) { + dir := t.TempDir() + writeTOML(t, dir, "devcell.toml", ` +[cell.libvirt_path_map] +"/devcell-155" = "/Users/dmitry/dev/dimmkirr/devcell" +"/home/dmitry" = "/Users/dmitry" +`) + c, err := cfg.LoadFile(filepath.Join(dir, "devcell.toml")) + if err != nil { + t.Fatal(err) + } + if got := c.Cell.LibvirtPathMap["/devcell-155"]; got != "/Users/dmitry/dev/dimmkirr/devcell" { + t.Errorf("LibvirtPathMap[/devcell-155] = %q", got) + } + if got := c.Cell.LibvirtPathMap["/home/dmitry"]; got != "/Users/dmitry" { + t.Errorf("LibvirtPathMap[/home/dmitry] = %q", got) + } +} + +func TestMerge_LibvirtPathMapAccumulates(t *testing.T) { + global := cfg.CellConfig{Cell: cfg.CellSection{LibvirtPathMap: map[string]string{ + "/home/dmitry": "/Users/dmitry", + }}} + project := cfg.CellConfig{Cell: cfg.CellSection{LibvirtPathMap: map[string]string{ + "/devcell-155": "/Users/dmitry/dev/dimmkirr/devcell", + }}} + m := cfg.Merge(global, project).Cell.LibvirtPathMap + if m["/home/dmitry"] != "/Users/dmitry" || m["/devcell-155"] != "/Users/dmitry/dev/dimmkirr/devcell" { + t.Errorf("merged map must accumulate both entries, got %v", m) + } +} + +func TestMerge_LibvirtPathMapProjectOverridesSameKey(t *testing.T) { + global := cfg.CellConfig{Cell: cfg.CellSection{LibvirtPathMap: map[string]string{ + "/home/dmitry": "/wrong", + }}} + project := cfg.CellConfig{Cell: cfg.CellSection{LibvirtPathMap: map[string]string{ + "/home/dmitry": "/Users/dmitry", + }}} + if got := cfg.Merge(global, project).Cell.LibvirtPathMap["/home/dmitry"]; got != "/Users/dmitry" { + t.Errorf("project entry must win for same key, got %q", got) + } +} + +// --- qemu_project_sync (CELL-383) --- +// +// Controls the scp project sync used by the qemu and libvirt engines: +// "push" (default: copy project into the guest before exec), "two-way" +// (also pull it back on exit), "off". Invalid values resolve to "push". + +func TestResolvedQemuProjectSync_DefaultPush(t *testing.T) { + c := cfg.CellSection{} + if got := c.ResolvedQemuProjectSync(); got != "push" { + t.Errorf("default = %q, want push", got) + } +} + +func TestResolvedQemuProjectSync_TOML(t *testing.T) { + c := cfg.CellSection{QemuProjectSync: "two-way"} + if got := c.ResolvedQemuProjectSync(); got != "two-way" { + t.Errorf("got %q, want two-way", got) + } +} + +func TestResolvedQemuProjectSync_EnvWins(t *testing.T) { + t.Setenv("DEVCELL_QEMU_PROJECT_SYNC", "off") + c := cfg.CellSection{QemuProjectSync: "two-way"} + if got := c.ResolvedQemuProjectSync(); got != "off" { + t.Errorf("got %q, want env value off", got) + } +} + +func TestResolvedQemuProjectSync_InvalidFallsBackToPush(t *testing.T) { + c := cfg.CellSection{QemuProjectSync: "sideways"} + if got := c.ResolvedQemuProjectSync(); got != "push" { + t.Errorf("invalid value must resolve to push, got %q", got) + } +} + +func TestLoadFile_QemuProjectSync(t *testing.T) { + dir := t.TempDir() + writeTOML(t, dir, "devcell.toml", ` +[cell] +qemu_project_sync = "off" +`) + c, err := cfg.LoadFile(filepath.Join(dir, "devcell.toml")) + if err != nil { + t.Fatal(err) + } + if c.Cell.QemuProjectSync != "off" { + t.Errorf("QemuProjectSync = %q, want off", c.Cell.QemuProjectSync) + } +} + +func TestMerge_EngineGlobalKeptWhenProjectUnset(t *testing.T) { + global := cfg.CellConfig{Cell: cfg.CellSection{Engine: "qemu"}} + if got := cfg.Merge(global, cfg.CellConfig{}).Cell.Engine; got != "qemu" { + t.Errorf("merged Engine = %q, want global value preserved", got) + } +} diff --git a/internal/cfg/validate_nix_packages.go b/internal/cfg/validate_nix_packages.go new file mode 100644 index 0000000..224104c --- /dev/null +++ b/internal/cfg/validate_nix_packages.go @@ -0,0 +1,84 @@ +package cfg + +import ( + "fmt" + "regexp" + "strings" +) + +// validNixAttr matches valid nix attribute paths: letters, digits, hyphens, +// underscores, dots (for nested attrs like python3Packages.requests). +var validNixAttr = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`) + +// ValidateNixPackageNames checks that every package name in every tier is a +// syntactically valid nix attribute name. Returns nil if all names are valid. +func ValidateNixPackageNames(np NixPackages) error { + for _, tier := range []struct { + name string + pkgs []string + }{ + {"stable", np.Stable}, + {"unstable", np.Unstable}, + {"edge", np.Edge}, + } { + for _, pkg := range tier.pkgs { + if pkg == "" || !validNixAttr.MatchString(pkg) { + return fmt.Errorf( + "invalid package name %q in [packages.nix].%s: must match %s", + pkg, tier.name, validNixAttr.String(), + ) + } + } + } + return nil +} + +// ValidateNixPackageDups checks that no package appears in more than one tier. +// Two tiers providing the same package would both get lib.hiPri, causing a +// home-manager collision. +func ValidateNixPackageDups(np NixPackages) error { + type entry struct { + tier string + } + seen := make(map[string]entry) + for _, tier := range []struct { + name string + pkgs []string + }{ + {"stable", np.Stable}, + {"unstable", np.Unstable}, + {"edge", np.Edge}, + } { + for _, pkg := range tier.pkgs { + if prev, ok := seen[pkg]; ok { + return fmt.Errorf( + "package %q appears in both [packages.nix].%s and [packages.nix].%s; "+ + "pick one tier to avoid a home-manager collision", + pkg, prev.tier, tier.name, + ) + } + seen[pkg] = entry{tier: tier.name} + } + } + return nil +} + +// ValidateNixPackages runs all [packages.nix] validations: name syntax and +// cross-tier duplicates. +func ValidateNixPackages(np NixPackages) error { + if err := ValidateNixPackageNames(np); err != nil { + return err + } + return ValidateNixPackageDups(np) +} + +// FormatNixCollisionHint returns a user-friendly hint when home-manager reports +// a package collision during build. Callers match the home-manager error output +// and call this to augment the message. +func FormatNixCollisionHint(pkg string, tiers []string) string { + return fmt.Sprintf( + "Package collision: %q is provided by both %s. "+ + "Remove it from one [packages.nix] tier, or let the module's version win by removing it from [packages.nix] entirely.", + pkg, strings.Join(tiers, " and "), + ) +} diff --git a/internal/cfg/validate_nix_packages_test.go b/internal/cfg/validate_nix_packages_test.go new file mode 100644 index 0000000..de94013 --- /dev/null +++ b/internal/cfg/validate_nix_packages_test.go @@ -0,0 +1,100 @@ +package cfg_test + +import ( + "strings" + "testing" + + "github.com/DimmKirr/devcell/internal/cfg" +) + +func TestValidateNixPackageNames_Valid(t *testing.T) { + np := cfg.NixPackages{ + Stable: []string{"tmux", "htop", "python3Packages.requests"}, + Unstable: []string{"some-tool"}, + Edge: []string{"my_pkg"}, + } + if err := cfg.ValidateNixPackageNames(np); err != nil { + t.Errorf("valid names should pass, got: %v", err) + } +} + +func TestValidateNixPackageNames_Empty(t *testing.T) { + if err := cfg.ValidateNixPackageNames(cfg.NixPackages{}); err != nil { + t.Errorf("empty should pass, got: %v", err) + } +} + +func TestValidateNixPackageNames_InvalidSpace(t *testing.T) { + np := cfg.NixPackages{Stable: []string{"my package"}} + err := cfg.ValidateNixPackageNames(np) + if err == nil { + t.Fatal("expected error for name with space") + } + if !strings.Contains(err.Error(), "my package") { + t.Errorf("error should mention the bad name, got: %v", err) + } + if !strings.Contains(err.Error(), "stable") { + t.Errorf("error should mention the tier, got: %v", err) + } +} + +func TestValidateNixPackageNames_InvalidEmpty(t *testing.T) { + np := cfg.NixPackages{Unstable: []string{""}} + err := cfg.ValidateNixPackageNames(np) + if err == nil { + t.Fatal("expected error for empty name") + } +} + +func TestValidateNixPackageNames_InvalidSpecialChar(t *testing.T) { + np := cfg.NixPackages{Edge: []string{"pkg;rm -rf"}} + err := cfg.ValidateNixPackageNames(np) + if err == nil { + t.Fatal("expected error for name with semicolon") + } +} + +func TestValidateNixPackageDups_NoDups(t *testing.T) { + np := cfg.NixPackages{ + Stable: []string{"tmux"}, + Unstable: []string{"htop"}, + Edge: []string{"cowsay"}, + } + if err := cfg.ValidateNixPackageDups(np); err != nil { + t.Errorf("no dups should pass, got: %v", err) + } +} + +func TestValidateNixPackageDups_DupAcrossTiers(t *testing.T) { + np := cfg.NixPackages{ + Stable: []string{"tmux"}, + Unstable: []string{"tmux"}, + } + err := cfg.ValidateNixPackageDups(np) + if err == nil { + t.Fatal("expected error for tmux in both stable and unstable") + } + if !strings.Contains(err.Error(), "tmux") { + t.Errorf("error should mention the package, got: %v", err) + } + if !strings.Contains(err.Error(), "stable") || !strings.Contains(err.Error(), "unstable") { + t.Errorf("error should mention both tiers, got: %v", err) + } +} + +func TestValidateNixPackageDups_DupAllThree(t *testing.T) { + np := cfg.NixPackages{ + Stable: []string{"tmux"}, + Edge: []string{"tmux"}, + } + err := cfg.ValidateNixPackageDups(np) + if err == nil { + t.Fatal("expected error for tmux in stable and edge") + } +} + +func TestValidateNixPackageDups_Empty(t *testing.T) { + if err := cfg.ValidateNixPackageDups(cfg.NixPackages{}); err != nil { + t.Errorf("empty should pass, got: %v", err) + } +} diff --git a/internal/cloudmodels/openrouter.go b/internal/cloudmodels/openrouter.go index 2da9706..68e590c 100644 --- a/internal/cloudmodels/openrouter.go +++ b/internal/cloudmodels/openrouter.go @@ -53,7 +53,7 @@ func FetchProviderModels(ctx context.Context, baseURL string) ([]ollama.Model, e if err != nil { return nil, fmt.Errorf("create request: %w", err) } - req.Header.Set("HTTP-Referer", "https://github.com/DimmKirr/devcell") + req.Header.Set("HTTP-Referer", "https://github.com/devcell-sh/devcell") resp, err := http.DefaultClient.Do(req) if err != nil { diff --git a/internal/cloudmodels/openrouter_test.go b/internal/cloudmodels/openrouter_test.go index c944be5..8b09235 100644 --- a/internal/cloudmodels/openrouter_test.go +++ b/internal/cloudmodels/openrouter_test.go @@ -96,7 +96,7 @@ func TestFilterLatestGen_DifferentFamiliesKeptSeparately(t *testing.T) { } filtered := cloudmodels.FilterLatestGen(models) if len(filtered) != 2 { - t.Fatalf("expected 2 models (pro+flash are different families), got %d", len(filtered), ) + t.Fatalf("expected 2 models (pro+flash are different families), got %d", len(filtered)) } } diff --git a/internal/config/config.go b/internal/config/config.go index 0316678..d373652 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,9 +12,9 @@ import ( // Config holds all runtime variables resolved from environment and cwd. type Config struct { - Bunk string + Bunk string AppName string - CellName string + CellName string CellHome string ConfigDir string BuildDir string // build context dir: .devcell/ when project config exists, else ConfigDir @@ -47,9 +47,9 @@ func Load(cwd string, getenv func(string) string) Config { configDir := resolveConfigDir(getenv) return Config{ - Bunk: bunk, + Bunk: bunk, AppName: appName, - CellName: cellName, + CellName: cellName, CellHome: home + "/.devcell/" + cellName, ConfigDir: configDir, BuildDir: configDir, @@ -58,8 +58,8 @@ func Load(cwd string, getenv func(string) string) Config { ContainerName: "cell-" + appName + "-run", Hostname: "cell-" + appName, PortPrefix: portPrefix, - VNCPort: clampPort(portPrefix + "50"), - RDPPort: clampPort(portPrefix + "89"), + VNCPort: ClampPort(portPrefix + "50"), + RDPPort: ClampPort(portPrefix + "89"), BaseDir: cwd, HostUser: getenv("USER"), HostHome: home, @@ -144,15 +144,15 @@ func (c *Config) ResolveAvailablePorts() { // Gather docker-allocated host ports once: on Docker Desktop (linuxkit VM) // and native Linux with userland-proxy disabled, published ports never open // a host-side socket, so net.Listen alone can't see them (CELL-119). - taken := dockerAllocatedPorts() - c.VNCPort = resolveAvailablePort(c.VNCPort, taken) - c.RDPPort = resolveAvailablePort(c.RDPPort, taken) + taken := DockerAllocatedPorts() + c.VNCPort = ResolveAvailablePort(c.VNCPort, taken) + c.RDPPort = ResolveAvailablePort(c.RDPPort, taken) } -// dockerAllocatedPorts returns the set of host ports currently published by +// DockerAllocatedPorts returns the set of host ports currently published by // running docker containers. Degrades gracefully: any error (docker absent, // daemon down) yields an empty set, falling back to net.Listen-only probing. -func dockerAllocatedPorts() map[int]struct{} { +func DockerAllocatedPorts() map[int]struct{} { out, err := exec.Command("docker", "ps", "--format", "{{.Ports}}").Output() if err != nil { return map[int]struct{}{} @@ -201,7 +201,7 @@ func parseDockerPublishedPorts(psOutput string) map[int]struct{} { // Bump <1024 to ≥1024 BEFORE the scan so dockerd's bind actually succeeds // (dockerd is root but the host already has the port allocated to another // container, which is the real collision we need to detect). -func resolveAvailablePort(preferred string, taken map[int]struct{}) string { +func ResolveAvailablePort(preferred string, taken map[int]struct{}) string { port, err := strconv.Atoi(preferred) if err != nil { return preferred @@ -229,11 +229,11 @@ func resolveAvailablePort(preferred string, taken map[int]struct{}) string { return strconv.Itoa(port) } -// clampPort ensures a port string represents a valid TCP port (1024–65535). +// ClampPort ensures a port string represents a valid TCP port (1024–65535). // If the value exceeds 65535, it subtracts 65535 repeatedly until it fits, // then floors at 1024 to stay out of the privileged range. // Pure arithmetic — no I/O. Port availability is handled by ResolveAvailablePorts. -func clampPort(s string) string { +func ClampPort(s string) string { p, err := strconv.Atoi(s) if err != nil || p <= 65535 { return s diff --git a/internal/config/ports_internal_test.go b/internal/config/ports_internal_test.go index 3a1c243..11a8cda 100644 --- a/internal/config/ports_internal_test.go +++ b/internal/config/ports_internal_test.go @@ -39,7 +39,7 @@ func TestParseDockerPublishedPorts_Empty(t *testing.T) { func TestResolveAvailablePort_BumpsOffDockerAllocated(t *testing.T) { taken := map[int]struct{}{10089: {}} // preferred "89" → <1024 → hoist +10000 → 10089, which is in `taken`. - got := resolveAvailablePort("89", taken) + got := ResolveAvailablePort("89", taken) if got == "10089" { t.Fatalf("should have bumped off docker-allocated 10089, got %q", got) } @@ -51,7 +51,7 @@ func TestResolveAvailablePort_BumpsOffDockerAllocated(t *testing.T) { func TestResolveAvailablePort_EmptyTakenPreservesBehavior(t *testing.T) { // 4250 is ≥1024 and almost certainly free in test → unchanged. - got := resolveAvailablePort("4250", map[int]struct{}{}) + got := ResolveAvailablePort("4250", map[int]struct{}{}) if got != "4250" { t.Errorf("empty taken set should preserve net.Listen behavior: want 4250, got %q", got) } diff --git a/internal/nixstore/pull.go b/internal/nixstore/pull.go deleted file mode 100644 index c2e7b5b..0000000 --- a/internal/nixstore/pull.go +++ /dev/null @@ -1,243 +0,0 @@ -// Package nixstore implements push/pull of a nix-store tarball as an -// OCI image layer, against a remote registry like GHCR. -// -// Background: the CI workflow caches the populated `/nix` Docker volume -// between runs by serializing it as a single OCI layer. CELL-292 made -// this work via the `crane` CLI, but `crane append`'s stdin handler had -// behavior that caused six iterations of CI breakage (re-gzipping -// pre-gzipped input, buffering uncompressed stdin to disk, …). This -// package replaces those CLI calls with direct uses of -// `github.com/google/go-containerregistry/pkg/v1/{stream,tarball}`, -// giving deterministic encoding + true streaming + a single Go code -// path shared between local tests and CI. -package nixstore - -import ( - "archive/tar" - "context" - "errors" - "fmt" - "io" - "os" - osexec "os/exec" - "path/filepath" - "strings" - - "github.com/google/go-containerregistry/pkg/authn" - "github.com/google/go-containerregistry/pkg/name" - "github.com/google/go-containerregistry/pkg/v1/remote" -) - -// Pull resolves srcRef, downloads all non-base layers of the image, -// and extracts each gzipped tarball into dstDir. For multi-layer -// (chunked) images, layers[1:] are extracted in order; for single-layer -// images, layers[0] is extracted (backward compat with legacy cache -// images). Archive entries are written relative to dstDir, with -// stripComponents leading path elements stripped per `tar -// --strip-components=N` semantics. Symlinks and file modes are preserved. -func Pull(ctx context.Context, srcRef, dstDir string, stripComponents int) error { - ref, err := name.ParseReference(srcRef) - if err != nil { - return fmt.Errorf("parse %q: %w", srcRef, err) - } - - img, err := remote.Image(ref, - remote.WithContext(ctx), - remote.WithAuthFromKeychain(authn.DefaultKeychain), - ) - if err != nil { - return fmt.Errorf("fetch manifest %q: %w", srcRef, err) - } - - layers, err := img.Layers() - if err != nil { - return fmt.Errorf("read layers: %w", err) - } - if len(layers) == 0 { - return fmt.Errorf("image %q has no layers", srcRef) - } - - start := 0 - if len(layers) > 1 { - start = 1 - } - for i, l := range layers[start:] { - rc, err := l.Uncompressed() - if err != nil { - return fmt.Errorf("open layer %d stream: %w", start+i, err) - } - if err := extractTar(rc, dstDir, stripComponents); err != nil { - rc.Close() - return fmt.Errorf("extract layer %d into %q: %w", start+i, dstDir, err) - } - rc.Close() - } - return nil -} - -// PullToDockerVolume streams all non-base layers of srcRef into the -// named Docker volume by spawning `docker run -i alpine sh -c 'cd -// /dest && tar -x --strip-components=N'` for each layer and feeding -// the (gunzipped) tar stream over stdin. For multi-layer (chunked) -// images, layers[1:] are extracted in order; for single-layer images, -// layers[0] is extracted (backward compat). -// -// stripComponents has the same meaning as Pull: leading path elements -// to strip from each archive entry. -func PullToDockerVolume(ctx context.Context, srcRef, volName string, stripComponents int) error { - ref, err := name.ParseReference(srcRef) - if err != nil { - return fmt.Errorf("parse %q: %w", srcRef, err) - } - img, err := remote.Image(ref, - remote.WithContext(ctx), - remote.WithAuthFromKeychain(authn.DefaultKeychain), - ) - if err != nil { - return fmt.Errorf("fetch manifest %q: %w", srcRef, err) - } - layers, err := img.Layers() - if err != nil { - return fmt.Errorf("read layers: %w", err) - } - if len(layers) == 0 { - return fmt.Errorf("image %q has no layers", srcRef) - } - - start := 0 - if len(layers) > 1 { - start = 1 - } - - tarFlag := "" - if stripComponents > 0 { - tarFlag = fmt.Sprintf(" --strip-components=%d", stripComponents) - } - - for i, l := range layers[start:] { - rc, err := l.Uncompressed() - if err != nil { - return fmt.Errorf("open layer %d stream: %w", start+i, err) - } - cmd := osexec.CommandContext(ctx, "docker", "run", "--rm", "-i", - "-v", volName+":/dest", - "public.ecr.aws/docker/library/alpine:latest", - "sh", "-c", "cd /dest && tar -x"+tarFlag) - cmd.Stdin = rc - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - rc.Close() - return fmt.Errorf("docker tar -x layer %d into %q: %w", start+i, volName, err) - } - rc.Close() - } - return nil -} - -// extractTar reads a tar stream from r and writes each entry under -// dstDir, with stripComponents leading path elements stripped per -// `tar --strip-components=N` semantics. dstDir must exist. Supports -// regular files, directories, symlinks, and hardlinks (the file types -// Nix actually uses in /nix/store). -func extractTar(r io.Reader, dstDir string, stripComponents int) error { - tr := tar.NewReader(r) - for { - hdr, err := tr.Next() - if err == io.EOF { - return nil - } - if err != nil { - return fmt.Errorf("tar next: %w", err) - } - stripped, skip := stripPath(hdr.Name, stripComponents) - if skip { - continue - } - target, err := safeJoin(dstDir, stripped) - if err != nil { - return fmt.Errorf("unsafe entry %q: %w", hdr.Name, err) - } - switch hdr.Typeflag { - case tar.TypeDir: - if err := os.MkdirAll(target, os.FileMode(hdr.Mode)&0o7777); err != nil { - return fmt.Errorf("mkdir %q: %w", target, err) - } - case tar.TypeReg, tar.TypeRegA: //nolint:staticcheck // RegA still appears in older archives - if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { - return fmt.Errorf("mkdir parent of %q: %w", target, err) - } - f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(hdr.Mode)&0o7777) - if err != nil { - return fmt.Errorf("create %q: %w", target, err) - } - if _, err := io.Copy(f, tr); err != nil { - f.Close() - return fmt.Errorf("write %q: %w", target, err) - } - if err := f.Close(); err != nil { - return fmt.Errorf("close %q: %w", target, err) - } - case tar.TypeSymlink: - if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { - return fmt.Errorf("mkdir parent of %q: %w", target, err) - } - if err := os.Symlink(hdr.Linkname, target); err != nil { - return fmt.Errorf("symlink %q -> %q: %w", target, hdr.Linkname, err) - } - case tar.TypeLink: - if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { - return fmt.Errorf("mkdir parent of %q: %w", target, err) - } - linkTarget, err := safeJoin(dstDir, hdr.Linkname) - if err != nil { - return fmt.Errorf("unsafe hardlink target %q: %w", hdr.Linkname, err) - } - if err := os.Link(linkTarget, target); err != nil { - return fmt.Errorf("hardlink %q -> %q: %w", target, linkTarget, err) - } - default: - // Skip char/block devices, FIFOs, etc. — nix-store doesn't - // use them; the workflow's tar already excludes - // nix/var/nix/daemon-socket. - continue - } - } -} - -// stripPath drops the first n path elements from p (per `tar -// --strip-components=N` semantics). Returns (stripped, skip) where -// skip is true when the entry should be discarded because it has -// fewer than n components — matches GNU tar's behavior. -func stripPath(p string, n int) (string, bool) { - if n <= 0 { - return p, false - } - parts := strings.Split(filepath.ToSlash(p), "/") - if len(parts) <= n { - return "", true - } - return strings.Join(parts[n:], "/"), false -} - -// safeJoin prevents zip-slip: cleans the relative path and verifies the -// result stays inside dstDir. -func safeJoin(dstDir, rel string) (string, error) { - cleaned := filepath.Clean(rel) - if strings.HasPrefix(cleaned, "..") || filepath.IsAbs(cleaned) { - return "", errors.New("path escapes destination") - } - abs := filepath.Join(dstDir, cleaned) - absRoot, err := filepath.Abs(dstDir) - if err != nil { - return "", err - } - absTarget, err := filepath.Abs(abs) - if err != nil { - return "", err - } - if !strings.HasPrefix(absTarget, absRoot+string(filepath.Separator)) && absTarget != absRoot { - return "", errors.New("path escapes destination") - } - return abs, nil -} diff --git a/internal/nixstore/pull_test.go b/internal/nixstore/pull_test.go deleted file mode 100644 index f645d20..0000000 --- a/internal/nixstore/pull_test.go +++ /dev/null @@ -1,336 +0,0 @@ -package nixstore_test - -// Pull tests — RED phase of TDD for CELL-293. -// -// What we're testing: -// -// 1. Pull(srcRef, dstDir) downloads an OCI image's LAST layer and -// extracts it (as a gzipped tarball) into dstDir. dstDir is the -// filesystem root onto which `nix/...` archive paths land directly -// (no --strip-components needed inside the package — the workflow -// extracts into a mount where /dest IS the volume root, so archive -// entries like `nix/store/...` become /dest/nix/store/...). -// -// 2. The pulled bytes match the input bytes — i.e., what we push and -// what we pull are byte-identical. This is the exact assertion -// CELL-292 burned 30 commits failing to maintain. -// -// Setup: an in-memory OCI registry (httptest + pkg/registry from -// go-containerregistry, already imported by internal/runner/registry.go). -// We seed it with a known image (busybox base + a known tar.gz layer -// containing a fixture nix-store layout) and exercise Pull against it. - -import ( - "archive/tar" - "bytes" - "compress/gzip" - "context" - "crypto/sha256" - "encoding/hex" - "io" - "net/http" - "net/http/httptest" - "net/url" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/DimmKirr/devcell/internal/nixstore" - - "github.com/google/go-containerregistry/pkg/crane" - "github.com/google/go-containerregistry/pkg/name" - "github.com/google/go-containerregistry/pkg/registry" - v1 "github.com/google/go-containerregistry/pkg/v1" - "github.com/google/go-containerregistry/pkg/v1/empty" - "github.com/google/go-containerregistry/pkg/v1/mutate" - "github.com/google/go-containerregistry/pkg/v1/remote" - "github.com/google/go-containerregistry/pkg/v1/tarball" -) - -// TestPull_RoundTripsFixture seeds an in-memory registry with an image -// whose last layer contains a known nix-store-shaped tar.gz, calls -// nixstore.Pull, and asserts every file under dstDir matches the fixture -// byte-for-byte. -func TestPull_RoundTripsFixture(t *testing.T) { - srv := newRegistry(t) - defer srv.Close() - - regHost := mustHost(t, srv.URL) - imgRef := regHost + "/nix-store:test" - - // Fixture: a few nix-store-shaped paths. Keep it small so tests are fast. - fixture := map[string][]byte{ - "nix/store/aaa-hello/bin/hello": []byte("#!/bin/sh\necho hello\n"), - "nix/store/bbb-world/bin/world": []byte("#!/bin/sh\necho world\n"), - "nix/var/log/nix/drvs/sample.drv.log": []byte("build log line\n"), - "nix/.fixture-marker": []byte("marker"), - } - layerBytes := mustBuildTarGz(t, fixture) - - // Push: base image + one layer containing the fixture tar.gz. - mustPushImage(t, imgRef, layerBytes) - - // RED: nixstore.Pull doesn't exist yet. Once it does: - // - it should fetch the manifest - // - download the LAST layer - // - extract it into dstDir - dstDir := t.TempDir() - if err := nixstore.Pull(context.Background(), imgRef, dstDir, 0); err != nil { - t.Fatalf("nixstore.Pull(%q, %q) failed: %v", imgRef, dstDir, err) - } - - // Verify byte-for-byte content match. - for relPath, want := range fixture { - got, err := os.ReadFile(filepath.Join(dstDir, relPath)) - if err != nil { - t.Errorf("expected %s under dstDir, got error: %v", relPath, err) - continue - } - if !bytes.Equal(got, want) { - t.Errorf("%s content mismatch:\nwant %q\ngot %q", relPath, want, got) - } - } -} - -// TestPull_RejectsNonexistentImage ensures Pull returns an error (not a -// panic, not a silent success) when the source image doesn't exist. -func TestPull_RejectsNonexistentImage(t *testing.T) { - srv := newRegistry(t) - defer srv.Close() - - imgRef := mustHost(t, srv.URL) + "/no-such:image" - dstDir := t.TempDir() - err := nixstore.Pull(context.Background(), imgRef, dstDir, 0) - if err == nil { - t.Fatal("expected Pull to fail for nonexistent image, got nil") - } - if !strings.Contains(strings.ToLower(err.Error()), "manifest") && - !strings.Contains(strings.ToLower(err.Error()), "not found") && - !strings.Contains(strings.ToLower(err.Error()), "unknown") { - t.Errorf("error should reference manifest/not-found/unknown; got: %v", err) - } -} - -// TestPull_LastLayerOnly verifies Pull extracts only the LAST layer of a -// multi-layer image (the workflow's contract — the nix-store layer is -// always atop a busybox base). Earlier layers' content must not appear -// under dstDir. -func TestPull_LastLayerOnly(t *testing.T) { - srv := newRegistry(t) - defer srv.Close() - - regHost := mustHost(t, srv.URL) - imgRef := regHost + "/nix-store:multi" - - baseFixture := map[string][]byte{"unwanted-base/file": []byte("BASE LAYER")} - topFixture := map[string][]byte{"nix/store/zzz/marker": []byte("TOP LAYER")} - - mustPushImageWithLayers(t, imgRef, mustBuildTarGz(t, baseFixture), mustBuildTarGz(t, topFixture)) - - dstDir := t.TempDir() - if err := nixstore.Pull(context.Background(), imgRef, dstDir, 0); err != nil { - t.Fatalf("Pull failed: %v", err) - } - - // Top layer's file should exist. - if _, err := os.Stat(filepath.Join(dstDir, "nix/store/zzz/marker")); err != nil { - t.Errorf("expected top-layer file extracted; got: %v", err) - } - // Base layer's file must NOT exist. - if _, err := os.Stat(filepath.Join(dstDir, "unwanted-base/file")); !os.IsNotExist(err) { - t.Errorf("base-layer file should not be extracted; stat err = %v", err) - } -} - -// ── test helpers ────────────────────────────────────────────────────── - -// newRegistry spins up the in-memory go-containerregistry server on a -// random port. Tests are isolated by per-test temp dir for the blob store. -func newRegistry(t *testing.T) *httptest.Server { - t.Helper() - handler := registry.New( - registry.WithBlobHandler(registry.NewDiskBlobHandler(t.TempDir())), - ) - return httptest.NewServer(handler) -} - -// mustHost strips http:// and returns host:port for crane-style refs. -func mustHost(t *testing.T, raw string) string { - t.Helper() - u, err := url.Parse(raw) - if err != nil { - t.Fatalf("parse server URL: %v", err) - } - return u.Host -} - -// mustBuildTarGz builds a gzipped tarball from the given path → content -// map. Used to construct fixture layers. -func mustBuildTarGz(t *testing.T, contents map[string][]byte) []byte { - t.Helper() - var buf bytes.Buffer - gz := gzip.NewWriter(&buf) - tw := tar.NewWriter(gz) - for path, data := range contents { - hdr := &tar.Header{ - Name: path, - Mode: 0644, - Size: int64(len(data)), - } - if err := tw.WriteHeader(hdr); err != nil { - t.Fatalf("tar header %s: %v", path, err) - } - if _, err := tw.Write(data); err != nil { - t.Fatalf("tar write %s: %v", path, err) - } - } - if err := tw.Close(); err != nil { - t.Fatalf("tar close: %v", err) - } - if err := gz.Close(); err != nil { - t.Fatalf("gzip close: %v", err) - } - return buf.Bytes() -} - -// mustPushImage publishes a single-layer image to the test registry with -// the given gzipped-tarball layer bytes. The layer's media type is OCI -// tar+gzip (matches what the production workflow produces). -func mustPushImage(t *testing.T, imgRef string, layerGz []byte) { - t.Helper() - mustPushImageWithLayers(t, imgRef, layerGz) -} - -// mustPushImageWithLayers builds an image with N layers (in order — last -// arg is the topmost layer) and pushes it to the registry. Used by -// TestPull_LastLayerOnly to construct a multi-layer fixture. -func mustPushImageWithLayers(t *testing.T, imgRef string, layersGz ...[]byte) { - t.Helper() - img := empty.Image - for _, gz := range layersGz { - l, err := tarball.LayerFromOpener(func() (io.ReadCloser, error) { - return io.NopCloser(bytes.NewReader(gz)), nil - }) - if err != nil { - t.Fatalf("tarball.LayerFromOpener: %v", err) - } - img, err = mutate.AppendLayers(img, l) - if err != nil { - t.Fatalf("AppendLayers: %v", err) - } - } - ref, err := name.ParseReference(imgRef) - if err != nil { - t.Fatalf("parse ref %s: %v", imgRef, err) - } - if err := remote.Write(ref, img, remote.WithTransport(http.DefaultTransport)); err != nil { - t.Fatalf("remote.Write: %v", err) - } -} - -// sha256Hex is a small helper for assertions on byte content (unused for -// now but kept handy for future tests that compare layer digests). -func sha256Hex(b []byte) string { - sum := sha256.Sum256(b) - return hex.EncodeToString(sum[:]) -} - -// TestPull_MultiLayer verifies Pull extracts ALL non-base layers (not just -// the last one). This is the pull-side contract for CELL-297's chunked push: -// an image has 1 base layer + N data layers, and Pull must extract all N. -func TestPull_MultiLayer(t *testing.T) { - srv := newRegistry(t) - defer srv.Close() - - regHost := mustHost(t, srv.URL) - imgRef := regHost + "/nix-store:multi-data" - - baseFixture := map[string][]byte{"base/marker": []byte("BASE")} - dataLayer1 := map[string][]byte{ - "nix/store/aaa/bin/tool1": []byte("tool1-content"), - } - dataLayer2 := map[string][]byte{ - "nix/store/bbb/bin/tool2": []byte("tool2-content"), - } - - mustPushImageWithLayers(t, imgRef, - mustBuildTarGz(t, baseFixture), - mustBuildTarGz(t, dataLayer1), - mustBuildTarGz(t, dataLayer2), - ) - - dstDir := t.TempDir() - if err := nixstore.Pull(context.Background(), imgRef, dstDir, 0); err != nil { - t.Fatalf("Pull failed: %v", err) - } - - // Both data layers' files must be extracted. - for _, tc := range []struct{ path, want string }{ - {"nix/store/aaa/bin/tool1", "tool1-content"}, - {"nix/store/bbb/bin/tool2", "tool2-content"}, - } { - got, err := os.ReadFile(filepath.Join(dstDir, tc.path)) - if err != nil { - t.Errorf("expected %s extracted; got: %v", tc.path, err) - continue - } - if string(got) != tc.want { - t.Errorf("%s = %q, want %q", tc.path, got, tc.want) - } - } - - // Base layer content must NOT be extracted. - if _, err := os.Stat(filepath.Join(dstDir, "base/marker")); !os.IsNotExist(err) { - t.Errorf("base layer content should not be extracted; stat err = %v", err) - } -} - -// TestPull_SingleLayer_BackwardCompat verifies old-style images (base + 1 -// data layer) still pull correctly after the multi-layer extraction change. -func TestPull_SingleLayer_BackwardCompat(t *testing.T) { - srv := newRegistry(t) - defer srv.Close() - - regHost := mustHost(t, srv.URL) - imgRef := regHost + "/nix-store:compat" - - baseFixture := map[string][]byte{"base/marker": []byte("BASE")} - dataFixture := map[string][]byte{ - "nix/store/aaa/bin/tool": []byte("tool-content"), - "nix/var/log/build.log": []byte("log-content"), - } - - mustPushImageWithLayers(t, imgRef, - mustBuildTarGz(t, baseFixture), - mustBuildTarGz(t, dataFixture), - ) - - dstDir := t.TempDir() - if err := nixstore.Pull(context.Background(), imgRef, dstDir, 0); err != nil { - t.Fatalf("Pull failed: %v", err) - } - - for _, tc := range []struct{ path, want string }{ - {"nix/store/aaa/bin/tool", "tool-content"}, - {"nix/var/log/build.log", "log-content"}, - } { - got, err := os.ReadFile(filepath.Join(dstDir, tc.path)) - if err != nil { - t.Errorf("expected %s extracted; got: %v", tc.path, err) - continue - } - if string(got) != tc.want { - t.Errorf("%s = %q, want %q", tc.path, got, tc.want) - } - } - - if _, err := os.Stat(filepath.Join(dstDir, "base/marker")); !os.IsNotExist(err) { - t.Errorf("base layer content should not be extracted; stat err = %v", err) - } -} - -// Silence unused-import lints — these are kept for symmetry with future -// tests on push side. -var _ = crane.Pull -var _ v1.Image diff --git a/internal/nixstore/push.go b/internal/nixstore/push.go deleted file mode 100644 index 64d38c2..0000000 --- a/internal/nixstore/push.go +++ /dev/null @@ -1,525 +0,0 @@ -package nixstore - -import ( - "archive/tar" - "context" - "fmt" - "io" - "net/http" - "os" - "sync" - "sync/atomic" - "time" - - "github.com/google/go-containerregistry/pkg/authn" - "github.com/google/go-containerregistry/pkg/name" - v1 "github.com/google/go-containerregistry/pkg/v1" - "github.com/google/go-containerregistry/pkg/v1/mutate" - "github.com/google/go-containerregistry/pkg/v1/remote" - "github.com/google/go-containerregistry/pkg/v1/stream" - "github.com/google/go-containerregistry/pkg/v1/types" -) - -// ProgressWriter receives periodic progress lines from Push. Defaults -// to os.Stderr so CI logs surface upload activity; tests can swap it -// for a buffer. Concurrent writes are serialized inside Push. -var ProgressWriter io.Writer = os.Stderr - -// ProgressTick controls how often Push emits an in-flight progress -// line. Set short in tests; CI uses the default. -var ProgressTick = 5 * time.Second - -// ChunkSize is the soft cap (in uncompressed tar bytes) at which Push -// starts a new OCI layer. Entries are never split mid-file — a single -// entry larger than ChunkSize produces a one-entry chunk. Set to 0 to -// disable chunking (single layer, original behavior). -var ChunkSize int64 = 512 << 20 // 512 MB - -// UploadJobs controls how many OCI layer blobs are uploaded concurrently -// by remote.Write. Higher values improve throughput but risk GHCR -// throttling; 16 caused session kills, 4 is a safe default. -var UploadJobs = 4 - -// StallTimeout is the maximum duration Push tolerates zero byte -// progress before aborting the upload. Set to 0 to disable. GHCR -// occasionally throttles multi-GB layer uploads to near-zero; without -// this the process blocks until externally SIGTERMed. -var StallTimeout = 120 * time.Second - -// TotalSizeHint is the estimated uncompressed size (in bytes) of the -// tar being pushed. When > 0, progress lines show "sent X / Y" so the -// operator can gauge completion. Set from DEVCELL_NIX_TOTAL_SIZE. -var TotalSizeHint int64 - -// Debug enables verbose HTTP-level logging for registry operations. -// Set via DEVCELL_NIX_PUSH_DEBUG=1. -var Debug bool - -// loggingTransport wraps an http.RoundTripper, counts HTTP activity, -// and optionally logs every request/response with timing and status -// codes. Always created so the progress ticker can report HTTP stats; -// per-request logs only appear when verbose is true (DEVCELL_NIX_PUSH_DEBUG=1). -type loggingTransport struct { - inner http.RoundTripper - verbose bool - requests atomic.Int32 - bytesSent atomic.Int64 -} - -func (t *loggingTransport) RoundTrip(req *http.Request) (*http.Response, error) { - t.requests.Add(1) - if req.Body != nil && req.Body != http.NoBody { - req.Body = &countingBody{rc: req.Body, n: &t.bytesSent} - } - - if !t.verbose { - return t.inner.RoundTrip(req) - } - - start := time.Now() - var bodyLen string - if req.ContentLength > 0 { - bodyLen = fmt.Sprintf(" body=%s", fmtBytes(uint64(req.ContentLength))) - } else if req.ContentLength < 0 { - bodyLen = " body=chunked" - } - progressLog("[nix-store http] → %s %s%s\n", req.Method, req.URL.Path, bodyLen) - - resp, err := t.inner.RoundTrip(req) - elapsed := time.Since(start) - if err != nil { - progressLog("[nix-store http] ← %s %s ERR %v (%s)\n", req.Method, req.URL.Path, err, elapsed.Round(time.Millisecond)) - return resp, err - } - progressLog("[nix-store http] ← %s %s %d (%s)\n", req.Method, req.URL.Path, resp.StatusCode, elapsed.Round(time.Millisecond)) - return resp, err -} - -// countingBody wraps an io.ReadCloser and atomically adds bytes read -// to a shared counter. Used by loggingTransport to track actual HTTP -// body bytes sent (ContentLength is -1 for streaming/chunked uploads). -type countingBody struct { - rc io.ReadCloser - n *atomic.Int64 -} - -func (c *countingBody) Read(b []byte) (int, error) { - n, err := c.rc.Read(b) - if n > 0 { - c.n.Add(int64(n)) - } - return n, err -} - -func (c *countingBody) Close() error { return c.rc.Close() } - -// chunkTracker counts how many chunk layers are actively being read -// (by remote.Write / stream.NewLayer) and how many are fully drained. -type chunkTracker struct { - active atomic.Int32 - drained atomic.Int32 - total int32 -} - -// progressReader wraps an io.ReadCloser and atomically counts bytes -// read into a shared counter. Multiple progressReaders can share the -// same counter (used by chunked push to track total upload progress -// across all layers). When tracker is non-nil, the reader also -// maintains active/drained chunk counts for per-chunk visibility. -type progressReader struct { - rc io.ReadCloser - count *atomic.Uint64 - drained atomic.Bool - tracker *chunkTracker - started atomic.Bool -} - -func (p *progressReader) Read(b []byte) (int, error) { - if p.tracker != nil && p.started.CompareAndSwap(false, true) { - p.tracker.active.Add(1) - } - n, err := p.rc.Read(b) - if n > 0 { - p.count.Add(uint64(n)) - } - if err == io.EOF && p.drained.CompareAndSwap(false, true) { - if p.tracker != nil { - p.tracker.active.Add(-1) - p.tracker.drained.Add(1) - } - } - return n, err -} - -func (p *progressReader) Close() error { return p.rc.Close() } - -// progressMu serializes writes to ProgressWriter so a custom sink -// (e.g. a bytes.Buffer in tests) doesn't race the goroutine + the -// final "done" line emitted from Push's defer. -var progressMu sync.Mutex - -func progressLog(format string, args ...any) { - progressMu.Lock() - defer progressMu.Unlock() - fmt.Fprintf(ProgressWriter, format, args...) -} - -func fmtBytes(n uint64) string { - switch { - case n >= 1<<30: - return fmt.Sprintf("%.2f GB", float64(n)/(1<<30)) - case n >= 1<<20: - return fmt.Sprintf("%.1f MB", float64(n)/(1<<20)) - case n >= 1<<10: - return fmt.Sprintf("%.1f KB", float64(n)/(1<<10)) - default: - return fmt.Sprintf("%d B", n) - } -} - -// Push streams an uncompressed tar from r through to dstRef as one or -// more OCI tar+gzip layers atop baseRef. -// -// When ChunkSize > 0 (default 512 MB), the input tar is split on entry -// boundaries into ~ChunkSize temp files. All chunks are then uploaded -// in parallel (UploadJobs concurrent blob streams, default 4) via a -// single remote.Write call — one manifest write at the end. This -// eliminates per-chunk manifest churn and overlaps gzip+upload across -// layers. Temp files are removed after the push completes. -// -// When ChunkSize <= 0, the original single-layer streaming behavior -// is preserved: bytes flow r → gzip → registry with no disk staging. -func Push(ctx context.Context, baseRef, dstRef string, r io.ReadCloser) error { - dst, err := name.ParseReference(dstRef) - if err != nil { - return fmt.Errorf("parse dst %q: %w", dstRef, err) - } - base, err := name.ParseReference(baseRef) - if err != nil { - return fmt.Errorf("parse base %q: %w", baseRef, err) - } - - transport := &loggingTransport{inner: http.DefaultTransport, verbose: Debug} - remoteOpts := []remote.Option{ - remote.WithContext(ctx), - remote.WithAuthFromKeychain(authn.DefaultKeychain), - remote.WithTransport(transport), - } - - baseImg, err := remote.Image(base, remoteOpts...) - if err != nil { - return fmt.Errorf("fetch base %q: %w", baseRef, err) - } - - var byteCount atomic.Uint64 - start := time.Now() - - uploadCtx, cancelUpload := context.WithCancel(ctx) - defer cancelUpload() - var stallDetected atomic.Bool - - // uploading gates the progress ticker: during the split phase - // (chunked path), byteCount is 0 and progress lines are noise. - var uploading atomic.Bool - - // trackerPtr is set when the chunked path creates its chunkTracker - // so the progress goroutine can report per-chunk state. - var trackerPtr atomic.Pointer[chunkTracker] - - // stallCloser is force-closed by the stall detector to unblock a - // potentially hanging Read on stdin (non-chunked path only). - var stallCloser io.Closer - - // activeReader tracks the current chunk's progressReader so the - // stall detector can distinguish "registry won't accept data" from - // "registry is finalizing after all data was sent". - var activeReader atomic.Pointer[progressReader] - - progCtx, stopProgress := context.WithCancel(ctx) - progDone := make(chan struct{}) - go func() { - defer close(progDone) - ticker := time.NewTicker(ProgressTick) - defer ticker.Stop() - var lastBytes uint64 - lastT := start - var stallSince time.Time - for { - select { - case <-progCtx.Done(): - return - case now := <-ticker.C: - if !uploading.Load() { - continue - } - cur := byteCount.Load() - - if StallTimeout > 0 && cur > 0 && cur == lastBytes { - ar := activeReader.Load() - draining := ar != nil && ar.drained.Load() - allRead := TotalSizeHint > 0 && cur >= uint64(TotalSizeHint) - if draining || allRead { - progressLog("[nix-store push] sent %s in %s — all data read, waiting for registry to finalize\n", - fmtBytes(cur), now.Sub(start).Round(time.Second)) - stallSince = time.Time{} - } else if stallSince.IsZero() { - stallSince = now - } else if now.Sub(stallSince) >= StallTimeout { - progressLog("[nix-store push] stall: no progress for %s at %s — aborting\n", - StallTimeout.Round(time.Second), fmtBytes(cur)) - stallDetected.Store(true) - cancelUpload() - if stallCloser != nil { - stallCloser.Close() - } - return - } - } else { - stallSince = time.Time{} - } - - elapsed := now.Sub(start) - dt := now.Sub(lastT).Seconds() - var inst float64 - if dt > 0 { - inst = float64(cur-lastBytes) / dt / (1 << 20) - } - var avg float64 - if elapsed.Seconds() > 0 { - avg = float64(cur) / elapsed.Seconds() / (1 << 20) - } - - httpReqs := transport.requests.Load() - httpBytes := transport.bytesSent.Load() - - var chunkInfo string - if t := trackerPtr.Load(); t != nil { - chunkInfo = fmt.Sprintf(" — chunks: %d/%d done, %d active", - t.drained.Load(), t.total, t.active.Load()) - } - httpInfo := fmt.Sprintf(" — HTTP: %d reqs, %s uploaded", - httpReqs, fmtBytes(uint64(httpBytes))) - - if TotalSizeHint > 0 { - pct := float64(cur) / float64(TotalSizeHint) * 100 - progressLog("[nix-store push] read %s / %s (%.0f%%) in %s (avg %.0f now %.0f MB/s)%s%s\n", - fmtBytes(cur), fmtBytes(uint64(TotalSizeHint)), pct, elapsed.Round(time.Second), avg, inst, chunkInfo, httpInfo) - } else { - progressLog("[nix-store push] read %s in %s (avg %.0f now %.0f MB/s)%s%s\n", - fmtBytes(cur), elapsed.Round(time.Second), avg, inst, chunkInfo, httpInfo) - } - lastBytes = cur - lastT = now - } - } - }() - defer func() { - stopProgress() - <-progDone - elapsed := time.Since(start) - total := byteCount.Load() - var avg float64 - if elapsed.Seconds() > 0 { - avg = float64(total) / elapsed.Seconds() / (1 << 20) - } - if stallDetected.Load() { - progressLog("[nix-store push] aborted (stall): %s in %s\n", - fmtBytes(total), elapsed.Round(time.Second)) - } else { - progressLog("[nix-store push] done: %s in %s (avg %.1f MB/s)\n", - fmtBytes(total), elapsed.Round(time.Second), avg) - } - }() - - if ChunkSize > 0 { - defer r.Close() - tr := tar.NewReader(r) - - // Phase 1: split tar into temp files on disk. - splitStart := time.Now() - var chunkFiles []string - defer func() { - for _, f := range chunkFiles { - os.Remove(f) - } - }() - - var splitBytes int64 - for chunkIdx := 0; ; chunkIdx++ { - tmpFile, err := os.CreateTemp("", fmt.Sprintf("nix-chunk-%03d-", chunkIdx)) - if err != nil { - return fmt.Errorf("create temp chunk %d: %w", chunkIdx+1, err) - } - - chunkStart := time.Now() - pr, pw := io.Pipe() - more := make(chan bool, 1) - go writeOneChunk(tr, pw, ChunkSize, more) - - n, copyErr := io.Copy(tmpFile, pr) - tmpFile.Close() - chunkElapsed := time.Since(chunkStart) - - if copyErr != nil { - os.Remove(tmpFile.Name()) - <-more - return fmt.Errorf("buffer chunk %d: %w", chunkIdx+1, copyErr) - } - - splitBytes += n - chunkFiles = append(chunkFiles, tmpFile.Name()) - - var pctInfo string - if TotalSizeHint > 0 { - pct := float64(splitBytes) / float64(TotalSizeHint) * 100 - pctInfo = fmt.Sprintf(" — %.0f%% of %s", pct, fmtBytes(uint64(TotalSizeHint))) - } - var speedInfo string - if chunkElapsed.Seconds() > 0.1 { - speedInfo = fmt.Sprintf(" at %.0f MB/s", float64(n)/chunkElapsed.Seconds()/(1<<20)) - } - progressLog("[nix-store push] chunk %d split (%s in %s%s%s)\n", - chunkIdx+1, fmtBytes(uint64(n)), chunkElapsed.Round(time.Millisecond), speedInfo, pctInfo) - - if !(<-more) { - break - } - } - - var totalFileBytes int64 - for _, cf := range chunkFiles { - if fi, err := os.Stat(cf); err == nil { - totalFileBytes += fi.Size() - } - } - TotalSizeHint = totalFileBytes - progressLog("[nix-store push] %d chunks (%s) split in %s, uploading with %d parallel streams\n", - len(chunkFiles), fmtBytes(uint64(totalFileBytes)), time.Since(splitStart).Round(time.Millisecond), UploadJobs) - - // Phase 2: open all chunk files, wrap in layers, one remote.Write. - start = time.Now() - uploading.Store(true) - - tracker := &chunkTracker{total: int32(len(chunkFiles))} - trackerPtr.Store(tracker) - - var layers []v1.Layer - var openFiles []*os.File - defer func() { - for _, f := range openFiles { - f.Close() - } - }() - - for _, chunkFile := range chunkFiles { - f, err := os.Open(chunkFile) - if err != nil { - return fmt.Errorf("open chunk: %w", err) - } - openFiles = append(openFiles, f) - - chunkPR := &progressReader{rc: f, count: &byteCount, tracker: tracker} - layer := stream.NewLayer(chunkPR, stream.WithMediaType(types.OCILayer)) - layers = append(layers, layer) - } - - progressLog("[nix-store push] building image from %d layers...\n", len(layers)) - buildStart := time.Now() - img, err := mutate.AppendLayers(baseImg, layers...) - if err != nil { - return fmt.Errorf("append %d layers: %w", len(layers), err) - } - progressLog("[nix-store push] image built in %s\n", time.Since(buildStart).Round(time.Millisecond)) - - uploadOpts := make([]remote.Option, len(remoteOpts)) - copy(uploadOpts, remoteOpts) - uploadOpts = append(uploadOpts, remote.WithContext(uploadCtx), remote.WithJobs(UploadJobs)) - - progressLog("[nix-store push] remote.Write starting (%d jobs)...\n", UploadJobs) - writeStart := time.Now() - if err := remote.Write(dst, img, uploadOpts...); err != nil { - if stallDetected.Load() { - return fmt.Errorf("push %q: upload stalled — no progress for %s", dstRef, StallTimeout.Round(time.Second)) - } - return fmt.Errorf("push %q: %w", dstRef, err) - } - progressLog("[nix-store push] %d chunks committed (%s)\n", len(layers), time.Since(writeStart).Round(time.Millisecond)) - } else { - uploading.Store(true) - pr := &progressReader{rc: r, count: &byteCount} - stallCloser = pr - activeReader.Store(pr) - layer := stream.NewLayer(pr, stream.WithMediaType(types.OCILayer)) - - img, err := mutate.AppendLayers(baseImg, layer) - if err != nil { - return fmt.Errorf("append layers: %w", err) - } - - uploadOpts := make([]remote.Option, len(remoteOpts)) - copy(uploadOpts, remoteOpts) - uploadOpts = append(uploadOpts, remote.WithContext(uploadCtx)) - - if err := remote.Write(dst, img, uploadOpts...); err != nil { - if stallDetected.Load() { - return fmt.Errorf("push %q: upload stalled — no progress for %s", dstRef, StallTimeout.Round(time.Second)) - } - return fmt.Errorf("push %q: %w", dstRef, err) - } - } - return nil -} - -// writeOneChunk reads tar entries from tr and writes them to pw as a -// valid tar stream until accumulated bytes reach chunkSize (soft cap on -// entry boundaries). Sends true on more if entries remain; false on EOF -// or error (errors propagate to the pipe reader via CloseWithError). -func writeOneChunk(tr *tar.Reader, pw *io.PipeWriter, chunkSize int64, more chan<- bool) { - tw := tar.NewWriter(pw) - var chunkBytes int64 - hasMore := false - var writeErr error - - defer func() { - if writeErr != nil { - pw.CloseWithError(writeErr) - } else if err := tw.Close(); err != nil { - pw.CloseWithError(err) - hasMore = false - } else { - pw.Close() - } - more <- hasMore - }() - - for { - hdr, err := tr.Next() - if err == io.EOF { - return - } - if err != nil { - writeErr = fmt.Errorf("tar read: %w", err) - return - } - - if err := tw.WriteHeader(hdr); err != nil { - writeErr = fmt.Errorf("tar write header: %w", err) - return - } - - if hdr.Size > 0 { - n, err := io.Copy(tw, tr) - if err != nil { - writeErr = fmt.Errorf("tar copy: %w", err) - return - } - chunkBytes += n - } - chunkBytes += 512 - - if chunkBytes >= chunkSize { - hasMore = true - return - } - } -} diff --git a/internal/nixstore/push_test.go b/internal/nixstore/push_test.go deleted file mode 100644 index 9dbaa83..0000000 --- a/internal/nixstore/push_test.go +++ /dev/null @@ -1,595 +0,0 @@ -package nixstore_test - -// Push tests — RED phase of TDD for CELL-293 (push side). -// -// What we're testing: -// -// 1. Push streams an uncompressed tar from a reader through to the -// registry as a single OCI tar+gzip layer atop a base image. The -// resulting on-wire layer is SINGLE-gzipped (not double — `crane -// append --new_layer -` re-gzipped pre-gzipped stdin; the -// `stream.NewLayer` approach we're using here doesn't). -// -// 2. Round-trip integrity: what we push via Push() is what we pull via -// Pull(). Byte-for-byte. This is the test the local cache pipeline -// has needed all along — same Go code path on both sides means -// format drift between push and pull is impossible. -// -// 3. Auth + reference parsing surface — bad image refs return errors, -// not panics. - -import ( - "archive/tar" - "bytes" - "compress/gzip" - "context" - "crypto/sha256" - "encoding/hex" - "io" - "net/http/httptest" - "net/url" - "os" - "path/filepath" - "strings" - "sync" - "testing" - "time" - - "github.com/DimmKirr/devcell/internal/nixstore" - - "github.com/google/go-containerregistry/pkg/name" - "github.com/google/go-containerregistry/pkg/registry" - "github.com/google/go-containerregistry/pkg/v1/empty" - "github.com/google/go-containerregistry/pkg/v1/mutate" - "github.com/google/go-containerregistry/pkg/v1/remote" - "github.com/google/go-containerregistry/pkg/v1/tarball" -) - -// TestPush_RoundTripsViaPull seeds a base image, calls Push() with a -// raw (uncompressed) tar of a known fixture, then calls Pull() to -// retrieve it. Asserts byte-for-byte match. This is the test that -// proves push and pull share an encoding contract. -func TestPush_RoundTripsViaPull(t *testing.T) { - srv := newRegistryForPush(t) - defer srv.Close() - - regHost := mustHostForPush(t, srv.URL) - baseRef := regHost + "/base:latest" - dstRef := regHost + "/cache:latest" - - // Seed: push an empty base image so our Push has something to - // layer atop (mirrors the workflow's busybox base). - mustSeedEmptyBase(t, baseRef) - - // Fixture: an uncompressed tar of a few nix-shaped paths. - fixture := map[string][]byte{ - "nix/store/aaa-pkg/bin/cmd": []byte("#!/bin/sh\necho cmd\n"), - "nix/store/bbb-pkg/lib/libfoo.so": bytes.Repeat([]byte{0xAB}, 256), - "nix/var/log/nix/drvs/build.drv.log": []byte("build log\n"), - } - tarBytes := mustBuildTar(t, fixture) - - // Push: stream the uncompressed tar through stream.NewLayer. - if err := nixstore.Push(context.Background(), baseRef, dstRef, io.NopCloser(bytes.NewReader(tarBytes))); err != nil { - t.Fatalf("Push failed: %v", err) - } - - // Pull and verify byte-for-byte. - dstDir := t.TempDir() - if err := nixstore.Pull(context.Background(), dstRef, dstDir, 0); err != nil { - t.Fatalf("Pull after Push failed: %v", err) - } - for relPath, want := range fixture { - got, err := os.ReadFile(filepath.Join(dstDir, relPath)) - if err != nil { - t.Errorf("expected %s extracted; got: %v", relPath, err) - continue - } - if !bytes.Equal(got, want) { - t.Errorf("%s mismatch (want %d bytes sha=%s, got %d bytes sha=%s)", - relPath, len(want), sha256Hex(want), len(got), sha256Hex(got)) - } - } -} - -// TestPush_LayerIsSingleGzipped fetches the layer crane saw on the wire -// and asserts the bytes-after-one-gunzip yield a tar header — i.e. -// the layer is single-gzipped, not double. This is the precise -// regression that bit us across CELL-292 with crane stdin. -func TestPush_LayerIsSingleGzipped(t *testing.T) { - srv := newRegistryForPush(t) - defer srv.Close() - - regHost := mustHostForPush(t, srv.URL) - baseRef := regHost + "/base:latest" - dstRef := regHost + "/cache:single-gz" - - mustSeedEmptyBase(t, baseRef) - - prevChunk := nixstore.ChunkSize - nixstore.ChunkSize = 0 - defer func() { nixstore.ChunkSize = prevChunk }() - - fixture := map[string][]byte{"nix/marker": []byte("hello")} - tarBytes := mustBuildTar(t, fixture) - if err := nixstore.Push(context.Background(), baseRef, dstRef, io.NopCloser(bytes.NewReader(tarBytes))); err != nil { - t.Fatalf("Push failed: %v", err) - } - - // Fetch the pushed image's last layer and inspect its raw + - // gunzipped bytes. - ref, err := name.ParseReference(dstRef) - if err != nil { - t.Fatalf("parse %q: %v", dstRef, err) - } - img, err := remote.Image(ref) - if err != nil { - t.Fatalf("fetch image: %v", err) - } - layers, err := img.Layers() - if err != nil { - t.Fatalf("layers: %v", err) - } - last := layers[len(layers)-1] - - // Raw layer should start with gzip magic 1f8b. - compressedRC, err := last.Compressed() - if err != nil { - t.Fatalf("compressed: %v", err) - } - defer compressedRC.Close() - rawHead := make([]byte, 4) - if _, err := io.ReadFull(compressedRC, rawHead); err != nil { - t.Fatalf("read raw layer head: %v", err) - } - if rawHead[0] != 0x1f || rawHead[1] != 0x8b { - t.Errorf("layer's raw bytes don't start with gzip magic: got %x", rawHead[:2]) - } - - // After one gunzip the bytes must NOT still be gzip magic — they - // should be a tar header (filename "nix/marker" → first 4 bytes - // "nix/"). - uncompressedRC, err := last.Uncompressed() - if err != nil { - t.Fatalf("uncompressed: %v", err) - } - defer uncompressedRC.Close() - gzHead := make([]byte, 4) - if _, err := io.ReadFull(uncompressedRC, gzHead); err != nil { - t.Fatalf("read gunzipped head: %v", err) - } - if gzHead[0] == 0x1f && gzHead[1] == 0x8b { - t.Errorf("layer is DOUBLE-gzipped: after one gunzip still see gzip magic %x — Push() is wrapping pre-gzipped input again", gzHead[:2]) - } - if string(gzHead) != "nix/" { - t.Errorf("after one gunzip expected tar magic 'nix/', got %q (%x)", gzHead, gzHead) - } -} - -// TestPush_ReportsProgress verifies that Push streams progress lines to -// nixstore.ProgressWriter and emits a final "done:" line reporting the -// exact number of bytes consumed from the input reader. This is the -// instrumentation we need to tell whether the CI publish step is -// genuinely stalling on the wire vs being killed externally — without -// these lines a multi-GB upload is silent for minutes (CELL-293). -func TestPush_ReportsProgress(t *testing.T) { - srv := newRegistryForPush(t) - defer srv.Close() - - regHost := mustHostForPush(t, srv.URL) - baseRef := regHost + "/base:latest" - dstRef := regHost + "/cache:progress" - mustSeedEmptyBase(t, baseRef) - - // Pad the fixture so progress emits at least once at the tight tick - // we set below. The exact size doesn't matter — the goroutine - // observes the counter, not the writer. - const pad = 4 * 1024 * 1024 // 4 MB — enough that even a fast push has time for one tick - fixture := map[string][]byte{ - "nix/store/pad": bytes.Repeat([]byte{0x55}, pad), - "nix/store/marker": []byte("hello"), - } - tarBytes := mustBuildTar(t, fixture) - - buf := &syncBuffer{} - prevWriter := nixstore.ProgressWriter - prevTick := nixstore.ProgressTick - nixstore.ProgressWriter = buf - nixstore.ProgressTick = 50 * time.Millisecond - defer func() { - nixstore.ProgressWriter = prevWriter - nixstore.ProgressTick = prevTick - }() - - if err := nixstore.Push(context.Background(), baseRef, dstRef, io.NopCloser(bytes.NewReader(tarBytes))); err != nil { - t.Fatalf("Push failed: %v", err) - } - - out := buf.String() - if !strings.Contains(out, "[nix-store push] done:") { - t.Errorf("expected progress writer to contain a final 'done:' line, got:\n%s", out) - } - // The done-line must report at least the fixture's uncompressed - // tar byte count — if it reports zero, the counting reader isn't - // wired to the bytes that actually reach stream.NewLayer. - if !strings.Contains(out, "MB") && !strings.Contains(out, "GB") { - t.Errorf("expected progress to report MB/GB scale, got:\n%s", out) - } -} - -// syncBuffer is a goroutine-safe bytes.Buffer for capturing the -// progress goroutine's writes without races. -type syncBuffer struct { - mu sync.Mutex - buf bytes.Buffer -} - -func (s *syncBuffer) Write(p []byte) (int, error) { - s.mu.Lock() - defer s.mu.Unlock() - return s.buf.Write(p) -} - -func (s *syncBuffer) String() string { - s.mu.Lock() - defer s.mu.Unlock() - return s.buf.String() -} - -// TestPush_DetectsStallAndAborts verifies that Push aborts the upload -// when the input reader stalls (no new bytes for StallTimeout). This -// reproduces the CI hang where GHCR throttles a 5+ GB upload to near -// zero and the process blocks until externally SIGTERMed. -func TestPush_DetectsStallAndAborts(t *testing.T) { - srv := newRegistryForPush(t) - defer srv.Close() - - regHost := mustHostForPush(t, srv.URL) - baseRef := regHost + "/base:latest" - dstRef := regHost + "/cache:stall" - mustSeedEmptyBase(t, baseRef) - - prevStall := nixstore.StallTimeout - prevTick := nixstore.ProgressTick - prevWriter := nixstore.ProgressWriter - prevChunk := nixstore.ChunkSize - nixstore.StallTimeout = 200 * time.Millisecond - nixstore.ProgressTick = 50 * time.Millisecond - nixstore.ProgressWriter = &syncBuffer{} - nixstore.ChunkSize = 0 // streaming path — stallReader must block during upload - defer func() { - nixstore.StallTimeout = prevStall - nixstore.ProgressTick = prevTick - nixstore.ProgressWriter = prevWriter - nixstore.ChunkSize = prevChunk - }() - - tarBytes := mustBuildTar(t, map[string][]byte{ - "nix/store/pkg/bin/tool": bytes.Repeat([]byte{0x42}, 4096), - }) - sr := &stallReader{ - initial: tarBytes, - block: make(chan struct{}), - } - t.Cleanup(func() { sr.Close() }) - - start := time.Now() - err := nixstore.Push(context.Background(), baseRef, dstRef, sr) - elapsed := time.Since(start) - - if err == nil { - t.Fatal("expected Push to detect stall and return error, got nil") - } - if !strings.Contains(err.Error(), "stall") { - t.Errorf("error should mention stall: %v", err) - } - if elapsed > 5*time.Second { - t.Errorf("Push should abort within StallTimeout (~200ms), took %s", elapsed) - } -} - -// stallReader delivers initial bytes on Read, then blocks until Close. -type stallReader struct { - initial []byte - pos int - block chan struct{} - once sync.Once -} - -func (s *stallReader) Read(p []byte) (int, error) { - if s.pos < len(s.initial) { - n := copy(p, s.initial[s.pos:]) - s.pos += n - return n, nil - } - <-s.block - return 0, io.ErrClosedPipe -} - -func (s *stallReader) Close() error { - s.once.Do(func() { close(s.block) }) - return nil -} - -// TestPush_RejectsBadDstRef ensures Push fails cleanly when the -// destination reference is malformed. -func TestPush_RejectsBadDstRef(t *testing.T) { - srv := newRegistryForPush(t) - defer srv.Close() - - baseRef := mustHostForPush(t, srv.URL) + "/base:latest" - mustSeedEmptyBase(t, baseRef) - - err := nixstore.Push(context.Background(), baseRef, "::not a ref::", io.NopCloser(bytes.NewReader([]byte("ignored")))) - if err == nil { - t.Fatal("expected Push to fail on malformed dst ref, got nil") - } -} - -// ── helpers ─────────────────────────────────────────────────────────── - -func newRegistryForPush(t *testing.T) *httptest.Server { - t.Helper() - h := registry.New(registry.WithBlobHandler(registry.NewDiskBlobHandler(t.TempDir()))) - return httptest.NewServer(h) -} - -func mustHostForPush(t *testing.T, raw string) string { - t.Helper() - u, err := url.Parse(raw) - if err != nil { - t.Fatalf("parse server URL: %v", err) - } - return u.Host -} - -// mustBuildTar produces an UNCOMPRESSED tar archive (no gzip). Push -// expects raw tar input — the layer is gzipped during upload by -// stream.NewLayer. -func mustBuildTar(t *testing.T, contents map[string][]byte) []byte { - t.Helper() - var buf bytes.Buffer - tw := tar.NewWriter(&buf) - for path, data := range contents { - hdr := &tar.Header{Name: path, Mode: 0644, Size: int64(len(data))} - if err := tw.WriteHeader(hdr); err != nil { - t.Fatalf("tar header %s: %v", path, err) - } - if _, err := tw.Write(data); err != nil { - t.Fatalf("tar write %s: %v", path, err) - } - } - if err := tw.Close(); err != nil { - t.Fatalf("tar close: %v", err) - } - return buf.Bytes() -} - -// mustSeedEmptyBase pushes a single-layer empty image to baseRef so -// Push() has a base to layer atop. Mirrors how the workflow's -// public-ECR busybox provides a base. -func mustSeedEmptyBase(t *testing.T, baseRef string) { - t.Helper() - ref, err := name.ParseReference(baseRef) - if err != nil { - t.Fatalf("parse base ref: %v", err) - } - // Tiny stub layer so the manifest has at least one entry — - // `empty.Image` with zero layers can fail manifest validation - // in some registry implementations. - stub := mustBuildStubLayer(t) - l, err := tarball.LayerFromOpener(func() (io.ReadCloser, error) { - return io.NopCloser(bytes.NewReader(stub)), nil - }) - if err != nil { - t.Fatalf("stub layer: %v", err) - } - img, err := mutate.AppendLayers(empty.Image, l) - if err != nil { - t.Fatalf("append stub layer: %v", err) - } - if err := remote.Write(ref, img); err != nil { - t.Fatalf("push base: %v", err) - } -} - -func mustBuildStubLayer(t *testing.T) []byte { - t.Helper() - var buf bytes.Buffer - gz := gzip.NewWriter(&buf) - tw := tar.NewWriter(gz) - hdr := &tar.Header{Name: "base/marker", Mode: 0644, Size: 4} - if err := tw.WriteHeader(hdr); err != nil { - t.Fatalf("stub header: %v", err) - } - if _, err := tw.Write([]byte("base")); err != nil { - t.Fatalf("stub write: %v", err) - } - _ = tw.Close() - _ = gz.Close() - return buf.Bytes() -} - -// TestPush_ChunkedRoundTrip verifies that Push splits a large tar into -// multiple OCI layers when ChunkSize is set, and that Pull reassembles -// them correctly (byte-for-byte match). This is the core test for CELL-297. -func TestPush_ChunkedRoundTrip(t *testing.T) { - srv := newRegistryForPush(t) - defer srv.Close() - - regHost := mustHostForPush(t, srv.URL) - baseRef := regHost + "/base:latest" - dstRef := regHost + "/cache:chunked" - - mustSeedEmptyBase(t, baseRef) - - // Set a tiny chunk size so that our small fixture produces multiple layers. - prevChunk := nixstore.ChunkSize - nixstore.ChunkSize = 512 - defer func() { nixstore.ChunkSize = prevChunk }() - - // Fixture: three files, each > 512 bytes so we get multiple chunks. - fixture := map[string][]byte{ - "nix/store/aaa-pkg/bin/cmd": bytes.Repeat([]byte("A"), 600), - "nix/store/bbb-pkg/lib/libfoo.so": bytes.Repeat([]byte("B"), 600), - "nix/store/ccc-pkg/share/data": bytes.Repeat([]byte("C"), 600), - } - tarBytes := mustBuildTar(t, fixture) - - if err := nixstore.Push(context.Background(), baseRef, dstRef, io.NopCloser(bytes.NewReader(tarBytes))); err != nil { - t.Fatalf("Push failed: %v", err) - } - - // Verify the pushed image has more than 2 layers (1 base + N>1 chunks). - ref, err := name.ParseReference(dstRef) - if err != nil { - t.Fatalf("parse ref: %v", err) - } - img, err := remote.Image(ref) - if err != nil { - t.Fatalf("fetch image: %v", err) - } - layers, err := img.Layers() - if err != nil { - t.Fatalf("layers: %v", err) - } - if len(layers) <= 2 { - t.Errorf("expected >2 layers (1 base + multiple chunks), got %d", len(layers)) - } - - // Pull and verify byte-for-byte match. - dstDir := t.TempDir() - if err := nixstore.Pull(context.Background(), dstRef, dstDir, 0); err != nil { - t.Fatalf("Pull after chunked Push failed: %v", err) - } - for relPath, want := range fixture { - got, err := os.ReadFile(filepath.Join(dstDir, relPath)) - if err != nil { - t.Errorf("expected %s extracted; got: %v", relPath, err) - continue - } - if !bytes.Equal(got, want) { - t.Errorf("%s mismatch (want %d bytes, got %d bytes)", relPath, len(want), len(got)) - } - } -} - -// TestPush_ChunkBoundary verifies that a single file larger than ChunkSize -// stays in one chunk — chunk size is a soft cap that never splits mid-entry. -func TestPush_ChunkBoundary(t *testing.T) { - srv := newRegistryForPush(t) - defer srv.Close() - - regHost := mustHostForPush(t, srv.URL) - baseRef := regHost + "/base:latest" - dstRef := regHost + "/cache:boundary" - - mustSeedEmptyBase(t, baseRef) - - prevChunk := nixstore.ChunkSize - nixstore.ChunkSize = 256 - defer func() { nixstore.ChunkSize = prevChunk }() - - // One file much larger than ChunkSize — must not be split. - fixture := map[string][]byte{ - "nix/store/big/data": bytes.Repeat([]byte("X"), 2048), - } - tarBytes := mustBuildTar(t, fixture) - - if err := nixstore.Push(context.Background(), baseRef, dstRef, io.NopCloser(bytes.NewReader(tarBytes))); err != nil { - t.Fatalf("Push failed: %v", err) - } - - // Round-trip must preserve the large file intact. - dstDir := t.TempDir() - if err := nixstore.Pull(context.Background(), dstRef, dstDir, 0); err != nil { - t.Fatalf("Pull failed: %v", err) - } - got, err := os.ReadFile(filepath.Join(dstDir, "nix/store/big/data")) - if err != nil { - t.Fatalf("expected big file extracted: %v", err) - } - if !bytes.Equal(got, fixture["nix/store/big/data"]) { - t.Errorf("big file mismatch (want %d bytes, got %d bytes)", len(fixture["nix/store/big/data"]), len(got)) - } -} - -// TestPush_ChunkedLayersAreSingleGzipped verifies each chunk layer is -// single-gzipped (no double-gzip regression from CELL-292). -func TestPush_ChunkedLayersAreSingleGzipped(t *testing.T) { - srv := newRegistryForPush(t) - defer srv.Close() - - regHost := mustHostForPush(t, srv.URL) - baseRef := regHost + "/base:latest" - dstRef := regHost + "/cache:gz-check" - - mustSeedEmptyBase(t, baseRef) - - prevChunk := nixstore.ChunkSize - nixstore.ChunkSize = 256 - defer func() { nixstore.ChunkSize = prevChunk }() - - fixture := map[string][]byte{ - "nix/store/aaa/file": bytes.Repeat([]byte("A"), 300), - "nix/store/bbb/file": bytes.Repeat([]byte("B"), 300), - } - tarBytes := mustBuildTar(t, fixture) - - if err := nixstore.Push(context.Background(), baseRef, dstRef, io.NopCloser(bytes.NewReader(tarBytes))); err != nil { - t.Fatalf("Push failed: %v", err) - } - - ref, err := name.ParseReference(dstRef) - if err != nil { - t.Fatalf("parse ref: %v", err) - } - img, err := remote.Image(ref) - if err != nil { - t.Fatalf("fetch image: %v", err) - } - allLayers, err := img.Layers() - if err != nil { - t.Fatalf("layers: %v", err) - } - - // Check each non-base layer. - for i, l := range allLayers[1:] { - sz, _ := l.Size() - if sz < 100 { - continue - } - - compressedRC, err := l.Compressed() - if err != nil { - t.Fatalf("layer %d compressed: %v", i+1, err) - } - head := make([]byte, 2) - if _, err := io.ReadFull(compressedRC, head); err != nil { - t.Fatalf("layer %d read compressed head: %v", i+1, err) - } - compressedRC.Close() - if head[0] != 0x1f || head[1] != 0x8b { - t.Errorf("layer %d raw bytes don't start with gzip magic: got %x", i+1, head) - } - - uncompressedRC, err := l.Uncompressed() - if err != nil { - t.Fatalf("layer %d uncompressed: %v", i+1, err) - } - uhead := make([]byte, 2) - if _, err := io.ReadFull(uncompressedRC, uhead); err != nil { - t.Fatalf("layer %d read uncompressed head: %v", i+1, err) - } - uncompressedRC.Close() - if uhead[0] == 0x1f && uhead[1] == 0x8b { - t.Errorf("layer %d is DOUBLE-gzipped", i+1) - } - } -} - -// re-use sha256Hex from pull_test.go (same package). Declaration to -// silence unused-import warnings: -var _ = sha256.New -var _ = hex.EncodeToString -var _ = strings.NewReader diff --git a/internal/nixstore/resolve.go b/internal/nixstore/resolve.go deleted file mode 100644 index 39e0cb3..0000000 --- a/internal/nixstore/resolve.go +++ /dev/null @@ -1,32 +0,0 @@ -package nixstore - -import ( - "context" - - "github.com/google/go-containerregistry/pkg/authn" - "github.com/google/go-containerregistry/pkg/name" - "github.com/google/go-containerregistry/pkg/v1/remote" -) - -// ResolveImage probes each candidate image reference in order and returns -// the first one whose manifest exists in the registry. Returns "" if none -// resolve. Empty strings in candidates are skipped. -func ResolveImage(ctx context.Context, candidates ...string) (string, error) { - for _, ref := range candidates { - if ref == "" { - continue - } - r, err := name.ParseReference(ref, name.Insecure) - if err != nil { - continue - } - _, err = remote.Head(r, - remote.WithContext(ctx), - remote.WithAuthFromKeychain(authn.DefaultKeychain), - ) - if err == nil { - return ref, nil - } - } - return "", nil -} diff --git a/internal/nixstore/resolve_test.go b/internal/nixstore/resolve_test.go deleted file mode 100644 index f4cb301..0000000 --- a/internal/nixstore/resolve_test.go +++ /dev/null @@ -1,98 +0,0 @@ -package nixstore_test - -import ( - "context" - "net/http/httptest" - "net/url" - "testing" - - "github.com/DimmKirr/devcell/internal/nixstore" - - "github.com/google/go-containerregistry/pkg/name" - "github.com/google/go-containerregistry/pkg/registry" - "github.com/google/go-containerregistry/pkg/v1/empty" - "github.com/google/go-containerregistry/pkg/v1/remote" -) - -func setupResolveRegistry(t *testing.T) (string, func()) { - t.Helper() - reg := registry.New() - srv := httptest.NewServer(reg) - u, _ := url.Parse(srv.URL) - return u.Host, srv.Close -} - -func pushEmpty(t *testing.T, ref string) { - t.Helper() - r, err := name.ParseReference(ref, name.Insecure) - if err != nil { - t.Fatalf("parse %q: %v", ref, err) - } - if err := remote.Write(r, empty.Image, remote.WithNondistributable); err != nil { - t.Fatalf("push %q: %v", ref, err) - } -} - -func TestResolveImage_ReturnsPrimaryWhenExists(t *testing.T) { - host, cleanup := setupResolveRegistry(t) - defer cleanup() - - primary := host + "/repo:primary" - fallback := host + "/repo:fallback" - pushEmpty(t, primary) - pushEmpty(t, fallback) - - got, err := nixstore.ResolveImage(context.Background(), primary, fallback) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got != primary { - t.Errorf("got %q, want %q", got, primary) - } -} - -func TestResolveImage_FallsBackWhenPrimaryMissing(t *testing.T) { - host, cleanup := setupResolveRegistry(t) - defer cleanup() - - primary := host + "/repo:does-not-exist" - fallback := host + "/repo:fallback" - pushEmpty(t, fallback) - - got, err := nixstore.ResolveImage(context.Background(), primary, fallback) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got != fallback { - t.Errorf("got %q, want %q", got, fallback) - } -} - -func TestResolveImage_ReturnsEmptyWhenNoneExist(t *testing.T) { - host, cleanup := setupResolveRegistry(t) - defer cleanup() - - got, err := nixstore.ResolveImage(context.Background(), host+"/repo:nope", host+"/repo:also-nope") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got != "" { - t.Errorf("got %q, want empty string", got) - } -} - -func TestResolveImage_SkipsEmptyCandidates(t *testing.T) { - host, cleanup := setupResolveRegistry(t) - defer cleanup() - - existing := host + "/repo:exists" - pushEmpty(t, existing) - - got, err := nixstore.ResolveImage(context.Background(), "", existing, "") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got != existing { - t.Errorf("got %q, want %q", got, existing) - } -} diff --git a/internal/nixstore/volume.go b/internal/nixstore/volume.go deleted file mode 100644 index 31f210a..0000000 --- a/internal/nixstore/volume.go +++ /dev/null @@ -1,191 +0,0 @@ -package nixstore - -import ( - "context" - "fmt" - "io" - "os" - "os/exec" - "strconv" - "strings" - "time" -) - -// RetryBaseDelay is the base delay between retry attempts. Actual delay -// is attempt * RetryBaseDelay (linear backoff matching the Taskfile's -// sleep $((attempt * 15))). Tests set this short. -var RetryBaseDelay = 15 * time.Second - -// PushOpts configures PushFromVolume behavior. -type PushOpts struct { - TagAlias string // create alias tag after push via docker buildx imagetools create - Retries int // total push attempts (0 or 1 = single attempt, no retry) - MinSize int64 // skip push if volume content < MinSize bytes (0 = always push) -} - -// ParseSize parses a human-readable size string (e.g. "1GB", "500MB", -// "1024KB") into bytes. Case-insensitive. -func ParseSize(s string) (int64, error) { - s = strings.TrimSpace(s) - if s == "" { - return 0, fmt.Errorf("empty size string") - } - upper := strings.ToUpper(s) - - for _, suffix := range []struct { - s string - m int64 - }{ - {"GB", 1 << 30}, - {"MB", 1 << 20}, - {"KB", 1 << 10}, - } { - if strings.HasSuffix(upper, suffix.s) { - n, err := strconv.ParseInt(upper[:len(upper)-len(suffix.s)], 10, 64) - if err != nil { - return 0, fmt.Errorf("invalid size %q: %w", s, err) - } - return n * suffix.m, nil - } - } - - n, err := strconv.ParseInt(upper, 10, 64) - if err != nil { - return 0, fmt.Errorf("invalid size %q: %w", s, err) - } - return n, nil -} - -// WithRetry calls fn up to maxAttempts times. On failure, waits -// attempt * RetryBaseDelay before the next attempt (linear backoff). -// Returns the last error if all attempts fail. Respects context -// cancellation between attempts. -func WithRetry(ctx context.Context, maxAttempts int, fn func() error) error { - if maxAttempts <= 0 { - maxAttempts = 1 - } - - var lastErr error - for attempt := 1; attempt <= maxAttempts; attempt++ { - lastErr = fn() - if lastErr == nil { - return nil - } - - if attempt < maxAttempts { - if ctx.Err() != nil { - return ctx.Err() - } - delay := time.Duration(attempt) * RetryBaseDelay - progressLog("[nix-store push] attempt %d/%d failed: %v — retrying in %s\n", - attempt, maxAttempts, lastErr, delay.Round(time.Second)) - - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(delay): - } - } - } - return lastErr -} - -// PushFromVolume pushes the contents of a Docker volume to a registry -// as OCI layers. Handles size checking, retry with backoff, and tag -// aliasing — replacing the ~48-line shell pipeline in Taskfile's -// nix-cache:publish task. -func PushFromVolume(ctx context.Context, volume, baseRef, dstRef string, opts PushOpts) error { - sizeBytes, err := volumeSize(ctx, volume) - if err != nil { - return fmt.Errorf("check volume size: %w", err) - } - if opts.MinSize > 0 && sizeBytes < opts.MinSize { - progressLog("[nix-store push] skipping: volume %s is %s (min %s)\n", - volume, fmtBytes(uint64(sizeBytes)), fmtBytes(uint64(opts.MinSize))) - return nil - } - - TotalSizeHint = sizeBytes - sizeGB := float64(sizeBytes) / (1 << 30) - compressedGB := sizeGB * 0.45 - progressLog("[nix-store push] volume %s: %.1f GB uncompressed, estimated ~%.1f GB compressed\n", - volume, sizeGB, compressedGB) - - attempts := opts.Retries - if attempts <= 0 { - attempts = 1 - } - - err = WithRetry(ctx, attempts, func() error { - r, tarErr := volumeTarReader(ctx, volume) - if tarErr != nil { - return fmt.Errorf("create volume tar: %w", tarErr) - } - return Push(ctx, baseRef, dstRef, r) - }) - if err != nil { - return err - } - - if opts.TagAlias != "" { - progressLog("[nix-store push] creating tag alias %s\n", opts.TagAlias) - return createTagAlias(ctx, dstRef, opts.TagAlias) - } - return nil -} - -func volumeSize(ctx context.Context, volume string) (int64, error) { - cmd := exec.CommandContext(ctx, "docker", "run", "--rm", - "-v", volume+":/nix:ro", - "alpine", "du", "-s", "/nix") - out, err := cmd.Output() - if err != nil { - return 0, fmt.Errorf("du on volume %s: %w", volume, err) - } - fields := strings.Fields(strings.TrimSpace(string(out))) - if len(fields) == 0 { - return 0, fmt.Errorf("empty du output for volume %s", volume) - } - kb, err := strconv.ParseInt(fields[0], 10, 64) - if err != nil { - return 0, fmt.Errorf("parse du output %q: %w", fields[0], err) - } - return kb * 1024, nil -} - -func volumeTarReader(ctx context.Context, volume string) (io.ReadCloser, error) { - cmd := exec.CommandContext(ctx, "docker", "run", "--rm", - "-v", volume+":/nix:ro", - "alpine", "tar", "-cf", "-", - "--exclude=nix/var/nix/daemon-socket", - "-C", "/", "nix") - cmd.Stderr = os.Stderr - - stdout, err := cmd.StdoutPipe() - if err != nil { - return nil, err - } - if err := cmd.Start(); err != nil { - return nil, err - } - - return &cmdReader{ReadCloser: stdout, cmd: cmd}, nil -} - -type cmdReader struct { - io.ReadCloser - cmd *exec.Cmd -} - -func (c *cmdReader) Close() error { - c.ReadCloser.Close() - return c.cmd.Wait() -} - -func createTagAlias(ctx context.Context, src, alias string) error { - cmd := exec.CommandContext(ctx, "docker", "buildx", "imagetools", "create", - "--tag", alias, src) - cmd.Stdout = os.Stderr - cmd.Stderr = os.Stderr - return cmd.Run() -} diff --git a/internal/nixstore/volume_test.go b/internal/nixstore/volume_test.go deleted file mode 100644 index a2dae08..0000000 --- a/internal/nixstore/volume_test.go +++ /dev/null @@ -1,142 +0,0 @@ -package nixstore_test - -import ( - "context" - "errors" - "testing" - "time" - - "github.com/DimmKirr/devcell/internal/nixstore" -) - -func TestParseSize(t *testing.T) { - tests := []struct { - input string - want int64 - err bool - }{ - {"1GB", 1 << 30, false}, - {"1gb", 1 << 30, false}, - {"2GB", 2 << 30, false}, - {"500MB", 500 << 20, false}, - {"500mb", 500 << 20, false}, - {"1024KB", 1024 << 10, false}, - {"1024kb", 1024 << 10, false}, - {"0", 0, false}, - {"", 0, true}, - {"abc", 0, true}, - } - for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - got, err := nixstore.ParseSize(tt.input) - if tt.err { - if err == nil { - t.Errorf("ParseSize(%q) = %d, want error", tt.input, got) - } - return - } - if err != nil { - t.Fatalf("ParseSize(%q) error: %v", tt.input, err) - } - if got != tt.want { - t.Errorf("ParseSize(%q) = %d, want %d", tt.input, got, tt.want) - } - }) - } -} - -func TestWithRetry_SucceedsImmediately(t *testing.T) { - old := nixstore.RetryBaseDelay - nixstore.RetryBaseDelay = time.Millisecond - defer func() { nixstore.RetryBaseDelay = old }() - - calls := 0 - err := nixstore.WithRetry(context.Background(), 3, func() error { - calls++ - return nil - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if calls != 1 { - t.Errorf("calls = %d, want 1", calls) - } -} - -func TestWithRetry_SucceedsAfterFailures(t *testing.T) { - old := nixstore.RetryBaseDelay - nixstore.RetryBaseDelay = time.Millisecond - defer func() { nixstore.RetryBaseDelay = old }() - - calls := 0 - err := nixstore.WithRetry(context.Background(), 3, func() error { - calls++ - if calls < 3 { - return errors.New("transient") - } - return nil - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if calls != 3 { - t.Errorf("calls = %d, want 3", calls) - } -} - -func TestWithRetry_ExhaustsRetries(t *testing.T) { - old := nixstore.RetryBaseDelay - nixstore.RetryBaseDelay = time.Millisecond - defer func() { nixstore.RetryBaseDelay = old }() - - calls := 0 - err := nixstore.WithRetry(context.Background(), 3, func() error { - calls++ - return errors.New("permanent") - }) - if err == nil { - t.Fatal("expected error, got nil") - } - if calls != 3 { - t.Errorf("calls = %d, want 3", calls) - } -} - -func TestWithRetry_ZeroMeansOnce(t *testing.T) { - old := nixstore.RetryBaseDelay - nixstore.RetryBaseDelay = time.Millisecond - defer func() { nixstore.RetryBaseDelay = old }() - - calls := 0 - err := nixstore.WithRetry(context.Background(), 0, func() error { - calls++ - return errors.New("fail") - }) - if err == nil { - t.Fatal("expected error, got nil") - } - if calls != 1 { - t.Errorf("calls = %d, want 1", calls) - } -} - -func TestWithRetry_RespectsContextCancellation(t *testing.T) { - old := nixstore.RetryBaseDelay - nixstore.RetryBaseDelay = time.Millisecond - defer func() { nixstore.RetryBaseDelay = old }() - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - calls := 0 - err := nixstore.WithRetry(ctx, 5, func() error { - calls++ - cancel() - return errors.New("fail") - }) - if !errors.Is(err, context.Canceled) { - t.Errorf("err = %v, want context.Canceled", err) - } - if calls != 1 { - t.Errorf("calls = %d, want 1", calls) - } -} diff --git a/internal/ollama/hardware.go b/internal/ollama/hardware.go index 818aabd..c16e03b 100644 --- a/internal/ollama/hardware.go +++ b/internal/ollama/hardware.go @@ -108,4 +108,3 @@ func CheckHardwareSafe(parameterSize string, systemRAMGB float64) (bool, float64 needed := EstimateRAMGB(paramsB) return needed <= systemRAMGB*0.75, needed } - diff --git a/internal/ollama/hardware_test.go b/internal/ollama/hardware_test.go index 2655a02..97b0753 100644 --- a/internal/ollama/hardware_test.go +++ b/internal/ollama/hardware_test.go @@ -54,10 +54,10 @@ func TestEstimateRAMGB_Q4Quantized(t *testing.T) { paramsB float64 expected float64 }{ - {7.0, 5.85}, // 7*0.55 + 2 = 5.85 - {8.0, 6.4}, // 8*0.55 + 2 = 6.4 - {32.0, 19.6}, // 32*0.55 + 2 = 19.6 - {70.0, 40.5}, // 70*0.55 + 2 = 40.5 + {7.0, 5.85}, // 7*0.55 + 2 = 5.85 + {8.0, 6.4}, // 8*0.55 + 2 = 6.4 + {32.0, 19.6}, // 32*0.55 + 2 = 19.6 + {70.0, 40.5}, // 70*0.55 + 2 = 40.5 } for _, tt := range tests { got := ollama.EstimateRAMGB(tt.paramsB) diff --git a/internal/ollama/ratings.go b/internal/ollama/ratings.go index 068c7a5..181ab7e 100644 --- a/internal/ollama/ratings.go +++ b/internal/ollama/ratings.go @@ -131,15 +131,15 @@ var cloudModelRatings = map[string]float64{ "claude-3-opus": 11.1, // Claude 3 Opus (older baseline) // OpenAI — SWE-bench Verified scores - "o3": 71.7, // o3 (high-compute) - "o4-mini": 68.1, // o4-mini - "o3-mini": 49.3, // o3-mini - "gpt-4-1": 54.6, // GPT-4.1 - "o1": 48.9, // o1 - "gpt-4o": 33.2, // GPT-4o + "o3": 71.7, // o3 (high-compute) + "o4-mini": 68.1, // o4-mini + "o3-mini": 49.3, // o3-mini + "gpt-4-1": 54.6, // GPT-4.1 + "o1": 48.9, // o1 + "gpt-4o": 33.2, // GPT-4o "gpt-4-1-mini": 34.6, // GPT-4.1 mini - "o1-mini": 16.7, // o1-mini - "gpt-4o-mini": 23.7, // GPT-4o mini + "o1-mini": 16.7, // o1-mini + "gpt-4o-mini": 23.7, // GPT-4o mini // Google — SWE-bench Verified scores "gemini-2-5-pro": 63.8, // Gemini 2.5 Pro @@ -199,9 +199,9 @@ func EstimateCloudSpeedTPM(pricePerToken float64) float64 { func EstimateLocalSpeedTPM(paramsB, bandwidthGBs float64) float64 { if bandwidthGBs > 0 && paramsB > 0 { const ( - q4BytesPerParam = 0.5625 // Q4_K_M ≈ 4.5 bits/param = 0.5625 bytes/param - bandwidthEff = 0.78 // llama.cpp Metal achieves ~75-80% of theoretical bandwidth - maxTokPerSec = 200 // compute-bound ceiling for sub-3B models on Apple Silicon + q4BytesPerParam = 0.5625 // Q4_K_M ≈ 4.5 bits/param = 0.5625 bytes/param + bandwidthEff = 0.78 // llama.cpp Metal achieves ~75-80% of theoretical bandwidth + maxTokPerSec = 200 // compute-bound ceiling for sub-3B models on Apple Silicon ) tokPerSec := (bandwidthGBs * bandwidthEff) / (paramsB * q4BytesPerParam) if tokPerSec > maxTokPerSec { diff --git a/internal/ollama/ratings_test.go b/internal/ollama/ratings_test.go index cc008a1..802c72f 100644 --- a/internal/ollama/ratings_test.go +++ b/internal/ollama/ratings_test.go @@ -11,13 +11,13 @@ func TestEstimateCloudSpeedTPM(t *testing.T) { pricePerToken float64 expected float64 }{ - {0.0000005, 18000}, // < $1/1M → very fast - {0.000001, 9000}, // boundary: exactly $1/1M, falls to next bucket - {0.0000009, 18000}, // just under $1/1M - {0.000002, 9000}, // $1-5/1M - {0.000008, 5400}, // $5-15/1M - {0.000030, 2400}, // $15-50/1M - {0.000100, 1200}, // > $50/1M → premium/slow + {0.0000005, 18000}, // < $1/1M → very fast + {0.000001, 9000}, // boundary: exactly $1/1M, falls to next bucket + {0.0000009, 18000}, // just under $1/1M + {0.000002, 9000}, // $1-5/1M + {0.000008, 5400}, // $5-15/1M + {0.000030, 2400}, // $15-50/1M + {0.000100, 1200}, // > $50/1M → premium/slow } for _, tt := range tests { got := ollama.EstimateCloudSpeedTPM(tt.pricePerToken) diff --git a/internal/runner/baseprompt_test.go b/internal/runner/baseprompt_test.go new file mode 100644 index 0000000..1eb64f1 --- /dev/null +++ b/internal/runner/baseprompt_test.go @@ -0,0 +1,180 @@ +package runner + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DimmKirr/devcell/internal/cfg" +) + +// After CELL-408 the two layers resolve from different sources: +// - system_prompt -> base, REPLACES Claude Code's built-in prompt +// - append_system_prompt -> overlay, layers on top +// +// The overlay must therefore ignore system_prompt entirely. +func TestResolveAppendPrompt_PrecedenceAcrossTiers(t *testing.T) { + dir := t.TempDir() + write := func(name, body string) string { + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return p + } + flagFile := write("flag.md", "from-append-flag-file") + envFile := write("env.md", "from-append-env-file") + tomlFile := write("toml.md", "from-append-toml-file") + + full := ResolveOpts{ + AppendFlagFile: flagFile, + AppendFlagInline: "from-append-flag-inline", + AppendEnvFile: envFile, + AppendEnvInline: "from-append-env-inline", + CellCfg: cfg.CellConfig{LLM: cfg.LLMSection{ + AppendSystemPromptFile: tomlFile, + AppendSystemPrompt: "from-append-toml-inline", + }}, + } + + tests := []struct { + name string + mut func(*ResolveOpts) + want string + }{ + {"flag file wins", func(o *ResolveOpts) { o.AppendFlagInline = "" }, "from-append-flag-file"}, + {"flag inline next", func(o *ResolveOpts) { o.AppendFlagFile = "" }, "from-append-flag-inline"}, + {"env file next", func(o *ResolveOpts) { + o.AppendFlagFile, o.AppendFlagInline, o.AppendEnvInline = "", "", "" + }, "from-append-env-file"}, + {"env inline next", func(o *ResolveOpts) { + o.AppendFlagFile, o.AppendFlagInline, o.AppendEnvFile = "", "", "" + }, "from-append-env-inline"}, + {"toml file next", func(o *ResolveOpts) { + o.AppendFlagFile, o.AppendFlagInline, o.AppendEnvFile, o.AppendEnvInline = "", "", "", "" + o.CellCfg.LLM.AppendSystemPrompt = "" + }, "from-append-toml-file"}, + {"toml inline last", func(o *ResolveOpts) { + o.AppendFlagFile, o.AppendFlagInline, o.AppendEnvFile, o.AppendEnvInline = "", "", "", "" + o.CellCfg.LLM.AppendSystemPromptFile = "" + }, "from-append-toml-inline"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + opts := full + tc.mut(&opts) + got, err := ResolveAppendPrompt(opts) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.TrimSpace(got) != tc.want { + t.Errorf("got %q, want %q", got, tc.want) + } + }) + } +} + +// system_prompt must never leak into the overlay — it is the base now. +func TestResolveAppendPrompt_IgnoresBaseSources(t *testing.T) { + got, err := ResolveAppendPrompt(ResolveOpts{ + FlagInline: "this is the BASE", + CellCfg: cfg.CellConfig{LLM: cfg.LLMSection{SystemPrompt: "also base"}}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "" { + t.Errorf("overlay resolved from base sources: %q", got) + } +} + +func TestResolveAppendPrompt_AmbiguousWithinTier(t *testing.T) { + for _, tc := range []struct { + name string + opts ResolveOpts + }{ + {"flags", ResolveOpts{AppendFlagInline: "a", AppendFlagFile: "/x.md"}}, + {"env", ResolveOpts{AppendEnvInline: "a", AppendEnvFile: "/x.md"}}, + {"toml", ResolveOpts{CellCfg: cfg.CellConfig{LLM: cfg.LLMSection{ + AppendSystemPrompt: "a", AppendSystemPromptFile: "/x.md", + }}}}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := ResolveAppendPrompt(tc.opts); err == nil { + t.Error("expected mutually-exclusive error") + } + }) + } +} + +// The base file is only written when a base is actually configured — +// otherwise the stock prompt must stay in effect. +func TestWriteBasePrompt_EmptyWhenUnconfigured(t *testing.T) { + c := promptFileConfig(t, "main") + + path, err := WriteBasePrompt(c, ResolveOpts{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if path != "" { + t.Errorf("expected no base path when unconfigured, got %q", path) + } + if _, statErr := os.Stat(filepath.Join(c.BaseDir, ".devcell", "prompts", "main", "system-prompt.md")); statErr == nil { + t.Error("base prompt file must not be written when unconfigured") + } +} + +func TestWriteBasePrompt_WritesVerbatimWithoutContainerContext(t *testing.T) { + c := promptFileConfig(t, "main") + + path, err := WriteBasePrompt(c, ResolveOpts{FlagInline: "you are a release bot"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if path != "/devcell-85/.devcell/prompts/main/system-prompt.md" { + t.Errorf("base container path = %q", path) + } + + body, err := os.ReadFile(filepath.Join(c.BaseDir, ".devcell", "prompts", "main", "system-prompt.md")) + if err != nil { + t.Fatalf("read: %v", err) + } + got := string(body) + if got != "you are a release bot" { + t.Errorf("base file = %q, want the resolved prompt verbatim", got) + } + // Container context belongs on the overlay: it is regenerated per run and + // must not be something a user's base prompt can displace. + if strings.Contains(got, "Docker container") { + t.Error("container context leaked into the base prompt") + } +} + +// The overlay must carry container context plus append sources only. +func TestWriteOverlayPrompt_UsesAppendSourcesNotBase(t *testing.T) { + c := promptFileConfig(t, "main") + + if _, err := WriteOverlayPrompt(c, cfg.CellConfig{}, ResolveOpts{ + FlagInline: "BASE-TEXT", + AppendEnvInline: "OVERLAY-TEXT", + }); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + body, err := os.ReadFile(filepath.Join(c.BaseDir, ".devcell", "prompts", "main", "additional-systemprompt.md")) + if err != nil { + t.Fatalf("read: %v", err) + } + got := string(body) + if !strings.Contains(got, "Docker container") { + t.Error("overlay missing container context") + } + if !strings.Contains(got, "OVERLAY-TEXT") { + t.Error("overlay missing append-sourced text") + } + if strings.Contains(got, "BASE-TEXT") { + t.Error("base prompt leaked into the overlay") + } +} diff --git a/internal/runner/boot_watcher.go b/internal/runner/boot_watcher.go index 9192dc2..a4727bb 100644 --- a/internal/runner/boot_watcher.go +++ b/internal/runner/boot_watcher.go @@ -38,35 +38,35 @@ type BootEvent struct { // Add a new component → one new entry; not exported (consumer reads the // Title field on the event, not the map). var titles = map[string]string{ - "container.ready": "Container started", - "entrypoint.ready": "Entrypoint ready", - "nix.starting": "Configuring nix", - "nix.ready": "Nix ready", - "shell.starting": "Configuring shell", - "shell.ready": "Shell ready", - "nix-lib.starting": "Wiring nix library paths", - "nix-lib.ready": "Nix library paths ready", - "mise.starting": "Loading mise tools", - "mise.ready": "Mise ready", - "home.starting": "Setting up home directory", - "home.ready": "Home directory ready", - "secrets.starting": "Injecting secrets", - "secrets.ready": "Secrets ready", - "chromium.starting": "Releasing chromium locks", - "chromium.ready": "Chromium ready", - "claude.starting": "Initializing Claude", - "claude.ready": "Claude ready", - "codex.starting": "Initializing Codex", - "codex.ready": "Codex ready", - "gemini.starting": "Initializing Gemini", - "gemini.ready": "Gemini ready", - "opencode.starting": "Initializing OpenCode", - "opencode.ready": "OpenCode ready", - "postgres.starting": "Starting PostgreSQL", - "postgres.ready": "PostgreSQL ready", - "gui.starting": "Starting GUI", - "gui.ready": "GUI ready", - "boot.ready": "", // sealing event — consumer treats as terminal, no row rendered + "container.ready": "Container started", + "entrypoint.ready": "Entrypoint ready", + "nix.starting": "Configuring nix", + "nix.ready": "Nix ready", + "shell.starting": "Configuring shell", + "shell.ready": "Shell ready", + "nix-lib.starting": "Wiring nix library paths", + "nix-lib.ready": "Nix library paths ready", + "mise.starting": "Loading mise tools", + "mise.ready": "Mise ready", + "home.starting": "Setting up home directory", + "home.ready": "Home directory ready", + "secrets.starting": "Injecting secrets", + "secrets.ready": "Secrets ready", + "chromium.starting": "Releasing chromium locks", + "chromium.ready": "Chromium ready", + "claude.starting": "Initializing Claude", + "claude.ready": "Claude ready", + "codex.starting": "Initializing Codex", + "codex.ready": "Codex ready", + "gemini.starting": "Initializing Gemini", + "gemini.ready": "Gemini ready", + "opencode.starting": "Initializing OpenCode", + "opencode.ready": "OpenCode ready", + "postgres.starting": "Starting PostgreSQL", + "postgres.ready": "PostgreSQL ready", + "gui.starting": "Starting GUI", + "gui.ready": "GUI ready", + "boot.ready": "", // sealing event — consumer treats as terminal, no row rendered } // titleFor looks up the human title for a sentinel. Returns "" for the diff --git a/internal/runner/cleanup.go b/internal/runner/cleanup.go new file mode 100644 index 0000000..d4674d0 --- /dev/null +++ b/internal/runner/cleanup.go @@ -0,0 +1,227 @@ +package runner + +import ( + "fmt" + "io" + "path" + "strings" +) + +// CELL-334: `cell cleanup` reaper + prune preflight gate. +// +// Both consumers share one primitive: map RUNNING devcell containers to the +// volume-resident closures they depend on (CollectLiveClosures). The reaper +// removes gcroots/devcell/ entries no running container references; the +// prune preflight stamps roots for every running container so the safe-GC +// invariant ("running implies rooted") holds by construction instead of +// being assumed. +// +// Retention policy (decided 2026-08-01): "in use" means a RUNNING container +// (docker ps), not any existing one. A stopped cell's closure is reapable — +// `cell shell` on it hits the CELL-38 hydration gate and rebuilds cleanly. + +// Symlinks inside a running container that resolve to its closure's +// volume-resident store paths. Resolved with `docker exec readlink -f`. +const ( + ContainerProfileLink = "/opt/devcell/.local/state/nix/profiles/profile" + ContainerGenerationLink = "/opt/devcell/.local/state/nix/profiles/home-manager" +) + +// LiveClosure is one running container's protected store paths. +type LiveClosure struct { + Container string + ProfilePath string // /nix/store/-home-manager-path + GenerationPath string // /nix/store/-home-manager-generation +} + +// storeHash extracts the nix store hash from a store path +// (/nix/store/abc123-name → abc123). Empty input yields "". +func storeHash(storePath string) string { + base := path.Base(storePath) + if i := strings.Index(base, "-"); i > 0 { + return base[:i] + } + return "" +} + +// CollectLiveClosures enumerates running devcell containers via ps and +// resolves each one's profile + generation store paths via resolve. +// Dependency-injected for tests; the runtime call site wires `docker ps` +// and `docker exec readlink -f`. +// +// A running container whose closure cannot be resolved fails the whole +// collection: an unknown closure means neither the reaper nor the prune +// gate can guarantee safety, so both must refuse — naming the cell. +// +// logf, when non-nil, receives one line per datapoint (--debug visibility). +func CollectLiveClosures( + ps func() ([]string, error), + resolve func(container, link string) (string, error), + logf func(format string, args ...any), +) ([]LiveClosure, error) { + if logf == nil { + logf = func(string, ...any) {} + } + containers, err := ps() + if err != nil { + return nil, fmt.Errorf("listing running devcell containers: %w", err) + } + logf("live-closure scan: %d running devcell container(s)", len(containers)) + closures := make([]LiveClosure, 0, len(containers)) + for _, c := range containers { + profile, err := resolve(c, ContainerProfileLink) + if err != nil { + return nil, fmt.Errorf("container %q: resolving %s: %w — refusing, its closure cannot be protected", c, ContainerProfileLink, err) + } + generation, err := resolve(c, ContainerGenerationLink) + if err != nil { + return nil, fmt.Errorf("container %q: resolving %s: %w — refusing, its closure cannot be protected", c, ContainerGenerationLink, err) + } + logf(" %s: profile=%s generation=%s", c, profile, generation) + closures = append(closures, LiveClosure{ + Container: c, + ProfilePath: profile, + GenerationPath: generation, + }) + } + return closures, nil +} + +// liveHashes returns the deduplicated hash set of all live closures, in +// first-seen order. +func liveHashes(closures []LiveClosure) []string { + seen := make(map[string]bool) + var hashes []string + for _, c := range closures { + for _, p := range []string{c.ProfilePath, c.GenerationPath} { + h := storeHash(p) + if h != "" && !seen[h] { + seen[h] = true + hashes = append(hashes, h) + } + } + } + return hashes +} + +// StampRootsScript emits the shell that stamps hash-named GC roots for +// every live closure. Idempotent — re-stamping the same closure is a no-op +// (`ln -sfT` to the identical target). Only creates; never deletes, never +// GCs. Empty input yields an empty script (nothing to stamp). +func StampRootsScript(closures []LiveClosure) string { + if len(closures) == 0 { + return "" + } + var b strings.Builder + b.WriteString("set -e\nmkdir -p /nix/var/nix/gcroots/devcell\n") + for _, c := range closures { + if h := storeHash(c.ProfilePath); h != "" { + fmt.Fprintf(&b, "ln -sfT %q /nix/var/nix/gcroots/devcell/%s-profile\n", c.ProfilePath, h) + } + if h := storeHash(c.GenerationPath); h != "" { + fmt.Fprintf(&b, "ln -sfT %q /nix/var/nix/gcroots/devcell/%s-generation\n", c.GenerationPath, h) + } + } + b.WriteString("echo \"Stamped GC roots for running containers\"\n") + return b.String() +} + +// BuildCleanupScript emits the reaper shell: remove gcroots/devcell/ +// entries whose hash is not in the live set. Only root symlinks and their +// -meta files are touched — never auto/ roots (namespace-local, CELL-330) +// and never the store itself (that's `cell build prune`'s job). +func BuildCleanupScript(closures []LiveClosure) string { + live := " " + strings.Join(liveHashes(closures), " ") + " " + return `set -e +LIVE="` + live + `" +REAPED=0 +KEPT=0 +if [ -d /nix/var/nix/gcroots/devcell ]; then + for f in /nix/var/nix/gcroots/devcell/*; do + [ -e "$f" ] || [ -L "$f" ] || continue + hash=$(basename "$f" | cut -d- -f1) + case "$LIVE" in + *" $hash "*) KEPT=$((KEPT + 1)) ;; + *) + echo "reaping: $f (no running container references $hash)" + rm -f "$f" + REAPED=$((REAPED + 1)) + ;; + esac + done +fi +echo "Cleanup: reaped $REAPED root file(s), kept $KEPT live"` +} + +// BuildCleanupPrompt is the confirmation text for `cell cleanup`. States +// the retention rule (running-only) and how many containers stay protected +// — the primary non-debug UX for the reaper. +func BuildCleanupPrompt(closures []LiveClosure) string { + const tail = " Continue? [y/N]" + if len(closures) == 0 { + return "⚠ No devcell containers are running — this will reap ALL GC roots\n" + + " under /nix/var/nix/gcroots/devcell/. The next `cell build prune --pure`\n" + + " may then garbage-collect every cell closure on the volume.\n" + + " (Stopped cells rebuild automatically on next start.)\n" + + tail + } + plural := "" + if len(closures) != 1 { + plural = "s" + } + return fmt.Sprintf( + "⚠ This will reap GC roots that no running container references.\n"+ + " Protected: %d running container%s (%s).\n"+ + " Roots of stopped cells are reaped — they rebuild on next start.\n"+ + tail, + len(closures), plural, strings.Join(containerNames(closures), ", "), + ) +} + +func containerNames(closures []LiveClosure) []string { + names := make([]string, len(closures)) + for i, c := range closures { + names[i] = c.Container + } + return names +} + +// RunCleanupArgs bundles inputs to RunCleanup, mirroring RunPruneArgs. +type RunCleanupArgs struct { + Closures []LiveClosure + Exec func(step PruneStep) error + Out io.Writer + In io.Reader + SkipYes bool + IsTTY bool +} + +// RunCleanup orchestrates the reaper: build the plan, prompt, execute. +// Rejection is a clean no-op (user intent, not an error). +func RunCleanup(a RunCleanupArgs) error { + if !ConfirmDestructive(a.Out, a.In, a.SkipYes, a.IsTTY, BuildCleanupPrompt(a.Closures)) { + fmt.Fprintln(a.Out, "Aborted, nothing was reaped.") + return nil + } + for _, step := range BuildCleanupSteps(a.Closures) { + fmt.Fprintln(a.Out, "→ "+strings.Join(step.Argv[:6], " ")+" …") + if err := a.Exec(step); err != nil { + return fmt.Errorf("cleanup step failed: %w", err) + } + } + return nil +} + +// BuildCleanupSteps wraps the reaper script in a docker-run step mounting +// the nix volume — the only namespace where the devcell roots and their +// volume-resident targets both resolve (CELL-333 lesson). +func BuildCleanupSteps(closures []LiveClosure) []PruneStep { + return []PruneStep{ + {Argv: []string{ + "docker", "run", "--rm", + "-v", DefaultThinStoreVolume + ":/nix", + NixCoreImage, + "sh", "-c", BuildCleanupScript(closures), + }}, + } +} diff --git a/internal/runner/cleanup_docker.go b/internal/runner/cleanup_docker.go new file mode 100644 index 0000000..c8b9ede --- /dev/null +++ b/internal/runner/cleanup_docker.go @@ -0,0 +1,49 @@ +package runner + +import ( + "context" + "fmt" + "os/exec" + "strings" +) + +// Runtime seams for CollectLiveClosures — thin exec wrappers, injected as +// the ps/resolve callbacks so the collection logic stays unit-testable. + +// DockerRunningDevcellContainers lists RUNNING devcell containers by the +// devcell.basedir label every `cell` launch stamps (runner.go BuildArgv). +// Running-only on purpose: `docker ps` without -a (CELL-334 retention +// decision, 2026-08-01). +func DockerRunningDevcellContainers(ctx context.Context) ([]string, error) { + out, err := exec.CommandContext(ctx, + "docker", "ps", + "--filter", "label=devcell.basedir", + "--format", "{{.Names}}", + ).Output() + if err != nil { + return nil, fmt.Errorf("docker ps: %w", err) + } + var names []string + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if line = strings.TrimSpace(line); line != "" { + names = append(names, line) + } + } + return names, nil +} + +// DockerResolveContainerLink resolves a symlink inside a running container +// to its final target — the only namespace where /opt/devcell resolves. +func DockerResolveContainerLink(ctx context.Context, container, link string) (string, error) { + out, err := exec.CommandContext(ctx, + "docker", "exec", container, "readlink", "-f", link, + ).Output() + if err != nil { + return "", fmt.Errorf("docker exec %s readlink -f %s: %w", container, link, err) + } + target := strings.TrimSpace(string(out)) + if target == "" { + return "", fmt.Errorf("empty readlink target for %s in %s", link, container) + } + return target, nil +} diff --git a/internal/runner/cleanup_test.go b/internal/runner/cleanup_test.go new file mode 100644 index 0000000..044f00f --- /dev/null +++ b/internal/runner/cleanup_test.go @@ -0,0 +1,295 @@ +package runner_test + +import ( + "errors" + "strings" + "testing" + + "github.com/DimmKirr/devcell/internal/runner" +) + +// CELL-334: `cell cleanup` + prune preflight share one primitive — map +// running devcell containers to the volume-resident closures they depend +// on. Retention policy (decided 2026-08-01): "in use" means a RUNNING +// container (docker ps), not any existing one. Stopped cells' closures are +// reapable; recovery is the CELL-38 hydration-gate rebuild path. + +func fakeResolve(m map[string]string) func(container, path string) (string, error) { + return func(container, path string) (string, error) { + v, ok := m[container+" "+path] + if !ok { + return "", errors.New("no such path") + } + return v, nil + } +} + +func TestCollectLiveClosures_ResolvesProfileAndGenerationPerContainer(t *testing.T) { + ps := func() ([]string, error) { return []string{"cell-a", "cell-b"}, nil } + resolve := fakeResolve(map[string]string{ + "cell-a " + runner.ContainerProfileLink: "/nix/store/aaa111-home-manager-path", + "cell-a " + runner.ContainerGenerationLink: "/nix/store/bbb222-home-manager-generation", + "cell-b " + runner.ContainerProfileLink: "/nix/store/ccc333-home-manager-path", + "cell-b " + runner.ContainerGenerationLink: "/nix/store/ddd444-home-manager-generation", + }) + + closures, err := runner.CollectLiveClosures(ps, resolve, nil) + if err != nil { + t.Fatal(err) + } + if len(closures) != 2 { + t.Fatalf("want 2 closures, got %d: %+v", len(closures), closures) + } + if closures[0].Container != "cell-a" || + closures[0].ProfilePath != "/nix/store/aaa111-home-manager-path" || + closures[0].GenerationPath != "/nix/store/bbb222-home-manager-generation" { + t.Errorf("closure[0] wrong: %+v", closures[0]) + } +} + +// A running container whose closure cannot be resolved must fail the whole +// collection — an unknown closure means neither the cleanup reaper nor the +// prune gate can guarantee safety, so both must refuse, naming the cell. +func TestCollectLiveClosures_UnresolvableContainerFailsNamingIt(t *testing.T) { + ps := func() ([]string, error) { return []string{"cell-broken"}, nil } + resolve := fakeResolve(map[string]string{}) + + _, err := runner.CollectLiveClosures(ps, resolve, nil) + if err == nil { + t.Fatal("want error for unresolvable container, got nil") + } + if !strings.Contains(err.Error(), "cell-broken") { + t.Errorf("error must name the container: %v", err) + } +} + +func TestCollectLiveClosures_NoRunningContainersIsEmptyNotError(t *testing.T) { + ps := func() ([]string, error) { return nil, nil } + closures, err := runner.CollectLiveClosures(ps, fakeResolve(nil), nil) + if err != nil { + t.Fatal(err) + } + if len(closures) != 0 { + t.Fatalf("want 0 closures, got %+v", closures) + } +} + +func TestCollectLiveClosures_LogsDatapoints(t *testing.T) { + ps := func() ([]string, error) { return []string{"cell-a"}, nil } + resolve := fakeResolve(map[string]string{ + "cell-a " + runner.ContainerProfileLink: "/nix/store/aaa111-home-manager-path", + "cell-a " + runner.ContainerGenerationLink: "/nix/store/bbb222-home-manager-generation", + }) + var lines []string + logf := func(format string, args ...any) { + lines = append(lines, format) + } + if _, err := runner.CollectLiveClosures(ps, resolve, logf); err != nil { + t.Fatal(err) + } + if len(lines) == 0 { + t.Error("CollectLiveClosures must log datapoints via logf for --debug visibility") + } +} + +// StampRootsScript makes the prune-gate invariant true by construction: +// instead of refusing when a running container has no GC root, stamp the +// (idempotent, hash-named) roots for every live closure before GC runs. +func TestStampRootsScript_StampsHashNamedRootsForEveryLiveClosure(t *testing.T) { + closures := []runner.LiveClosure{ + { + Container: "cell-a", + ProfilePath: "/nix/store/aaa111-home-manager-path", + GenerationPath: "/nix/store/bbb222-home-manager-generation", + }, + } + script := runner.StampRootsScript(closures) + + wants := []string{ + "mkdir -p /nix/var/nix/gcroots/devcell", + `ln -sfT "/nix/store/aaa111-home-manager-path" /nix/var/nix/gcroots/devcell/aaa111-profile`, + `ln -sfT "/nix/store/bbb222-home-manager-generation" /nix/var/nix/gcroots/devcell/bbb222-generation`, + } + for _, w := range wants { + if !strings.Contains(script, w) { + t.Errorf("stamp script missing %q\nscript:\n%s", w, script) + } + } + if strings.Contains(script, "rm ") || strings.Contains(script, "nix-store") { + t.Errorf("stamp script must only create roots, never delete or GC:\n%s", script) + } +} + +func TestStampRootsScript_EmptyClosuresIsEmpty(t *testing.T) { + if s := runner.StampRootsScript(nil); s != "" { + t.Errorf("no live closures → empty stamp script, got %q", s) + } +} + +// The reaper: remove devcell/ roots whose hash no RUNNING container's +// closure matches. Only touches /nix/var/nix/gcroots/devcell/ — never +// auto/ roots (namespace-local, CELL-330), never the store itself. +func TestBuildCleanupScript_KeepsLiveReapsStale(t *testing.T) { + closures := []runner.LiveClosure{ + { + Container: "cell-a", + ProfilePath: "/nix/store/aaa111-home-manager-path", + GenerationPath: "/nix/store/bbb222-home-manager-generation", + }, + } + script := runner.BuildCleanupScript(closures) + + if !strings.Contains(script, `LIVE=" aaa111 bbb222 "`) { + t.Errorf("cleanup script must embed the live hash set, got:\n%s", script) + } + if !strings.Contains(script, "/nix/var/nix/gcroots/devcell/") { + t.Errorf("cleanup script must scan gcroots/devcell/:\n%s", script) + } + if strings.Contains(script, "gcroots/auto") { + t.Errorf("cleanup script must never touch auto/ roots (CELL-330):\n%s", script) + } + for _, forbidden := range []string{"nix-store", "nix-collect-garbage", "rm -rf /nix/store"} { + if strings.Contains(script, forbidden) { + t.Errorf("cleanup script must only reap root symlinks, not GC (%q found):\n%s", forbidden, script) + } + } +} + +// Running-only semantics: with nothing running, every root is reapable. +// The script must still be valid (empty LIVE set) — `cell cleanup` is +// explicit and confirmation-gated, so this is intended, not a foot-gun. +func TestBuildCleanupScript_NoLiveClosuresReapsAll(t *testing.T) { + script := runner.BuildCleanupScript(nil) + if !strings.Contains(script, `LIVE=" "`) && !strings.Contains(script, `LIVE=" "`) { + t.Errorf("empty live set must produce an empty LIVE list:\n%s", script) + } +} + +func TestBuildCleanupSteps_RunsInContainerOnNixVolume(t *testing.T) { + steps := runner.BuildCleanupSteps(nil) + if len(steps) != 1 { + t.Fatalf("want 1 step, got %d: %+v", len(steps), steps) + } + joined := strings.Join(steps[0].Argv, " ") + if !strings.Contains(joined, "docker run") || + !strings.Contains(joined, runner.DefaultThinStoreVolume+":/nix") { + t.Errorf("cleanup must run in a container mounting %s:/nix, got: %v", + runner.DefaultThinStoreVolume, steps[0].Argv) + } +} + +// RunCleanup orchestrates: build the reaper plan, prompt, execute. Same +// confirmation semantics as RunPrune — rejection is a clean no-op, non-TTY +// without --yes refuses. +func TestRunCleanup_RejectedPromptExecutesNothing(t *testing.T) { + executed := 0 + var out strings.Builder + err := runner.RunCleanup(runner.RunCleanupArgs{ + Closures: nil, + Exec: func(runner.PruneStep) error { executed++; return nil }, + Out: &out, + In: strings.NewReader("n\n"), + IsTTY: true, + }) + if err != nil { + t.Fatal(err) + } + if executed != 0 { + t.Errorf("rejected prompt must execute nothing, executed %d step(s)", executed) + } +} + +func TestRunCleanup_AcceptedPromptExecutesReaperStep(t *testing.T) { + var got []runner.PruneStep + var out strings.Builder + err := runner.RunCleanup(runner.RunCleanupArgs{ + Closures: []runner.LiveClosure{ + { + Container: "cell-a", + ProfilePath: "/nix/store/aaa111-home-manager-path", + GenerationPath: "/nix/store/bbb222-home-manager-generation", + }, + }, + Exec: func(s runner.PruneStep) error { got = append(got, s); return nil }, + Out: &out, + In: strings.NewReader("y\n"), + IsTTY: true, + }) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 { + t.Fatalf("want 1 executed step, got %d", len(got)) + } + joined := strings.Join(got[0].Argv, " ") + if !strings.Contains(joined, runner.DefaultThinStoreVolume+":/nix") { + t.Errorf("executed step must mount the nix volume: %v", got[0].Argv) + } +} + +// The prompt must tell the user the retention rule and how many containers +// are protected — this is the primary non-debug UX for cleanup. +func TestBuildCleanupPrompt_NamesRetentionRuleAndLiveCount(t *testing.T) { + closures := []runner.LiveClosure{ + {Container: "cell-a", ProfilePath: "/nix/store/aaa111-p", GenerationPath: "/nix/store/bbb222-g"}, + {Container: "cell-b", ProfilePath: "/nix/store/aaa111-p", GenerationPath: "/nix/store/bbb222-g"}, + } + prompt := runner.BuildCleanupPrompt(closures) + for _, want := range []string{"running", "2 running container", "Continue? [y/N]"} { + if !strings.Contains(prompt, want) { + t.Errorf("cleanup prompt missing %q\nprompt:\n%s", want, prompt) + } + } +} + +func TestBuildCleanupPrompt_WarnsWhenNothingRunning(t *testing.T) { + prompt := runner.BuildCleanupPrompt(nil) + if !strings.Contains(prompt, "ALL") { + t.Errorf("with nothing running the prompt must warn that ALL roots are reaped:\n%s", prompt) + } +} + +// CELL-334 secondary: PROTECTED must be collected from BOTH *-profile and +// *-generation roots — generations carry home-manager-files, the very +// closure CELL-320 exists to protect. +func TestSafeNixGCScript_CollectsProtectedFromBothRootKinds(t *testing.T) { + if !strings.Contains(runner.SafeNixGCScript, + "/nix/var/nix/gcroots/devcell/*-profile /nix/var/nix/gcroots/devcell/*-generation") { + t.Error("SafeNixGCScript must collect PROTECTED from both *-profile and *-generation globs (CELL-334)") + } +} + +// The prune gate: when live closures are supplied, the plan must stamp +// roots for them (making "running implies rooted" true) before the safe GC +// step. Order matters — stamp, then GC. +func TestBuildNixPruneSteps_LiveClosuresInsertStampStepBeforeGC(t *testing.T) { + closures := []runner.LiveClosure{ + { + Container: "cell-a", + ProfilePath: "/nix/store/aaa111-home-manager-path", + GenerationPath: "/nix/store/bbb222-home-manager-generation", + }, + } + opts := runner.PruneOpts{GOOS: "linux", Pure: true, LiveClosures: closures} + steps := runner.BuildNixPruneSteps(opts) + + stampIdx, gcIdx := -1, -1 + for i, s := range steps { + joined := strings.Join(s.Argv, " ") + if strings.Contains(joined, "aaa111-profile") { + stampIdx = i + } + if strings.Contains(joined, "nix-store --gc") { + gcIdx = i + } + } + if stampIdx == -1 { + t.Fatalf("no stamp step found in plan: %+v", steps) + } + if gcIdx == -1 { + t.Fatalf("no safe GC step found in plan: %+v", steps) + } + if stampIdx >= gcIdx { + t.Errorf("stamp step (%d) must run before safe GC (%d)", stampIdx, gcIdx) + } +} diff --git a/internal/runner/closure_alive.go b/internal/runner/closure_alive.go new file mode 100644 index 0000000..6cc3713 --- /dev/null +++ b/internal/runner/closure_alive.go @@ -0,0 +1,49 @@ +package runner + +import ( + "strings" +) + +// CELL-418: detect whether the thin image's baked-in nix closure still +// exists on the shared nix-store volume. A closure dies when a later +// build for a different stack triggers GC that reaps the store paths +// the image's profile symlink points at. + +// ProfilePath is the canonical location of the home-manager profile +// baked into every thin image by thin_build.go. +const ProfilePath = "/opt/devcell/.local/state/nix/profiles/profile" + +// ClosureAliveArgv returns the docker argv for the closure-alive probe. +// Runs inside the target image with the nix volume mounted so the +// profile's /nix/store/... symlink target can be resolved. +func ClosureAliveArgv(volume, image string) []string { + script := `t=$(readlink -f "` + ProfilePath + `" 2>/dev/null) && [ -n "$t" ] && [ -d "$t" ] && printf '%s\n' "$t"` + return []string{ + "docker", "run", "--rm", "--network", "none", + "-v", volume + ":/nix", + "--entrypoint", "/bin/sh", + image, "-c", script, + } +} + +// ClosureDeadWarning returns a user-facing warning and dead=true when the +// closure probe reports the image's nix paths are gone. +func ClosureDeadWarning(alive bool) (warning string, dead bool) { + if alive { + return "", false + } + return "This image's nix closure was garbage collected. Rebuild? (Y/n)", true +} + +// ParseClosureAliveResult interprets the probe's stdout + error. +// Returns the resolved store path and whether the closure is alive. +func ParseClosureAliveResult(stdout string, err error) (resolvedPath string, alive bool) { + if err != nil { + return "", false + } + p := strings.TrimSpace(stdout) + if p == "" { + return "", false + } + return p, true +} diff --git a/internal/runner/closure_alive_test.go b/internal/runner/closure_alive_test.go new file mode 100644 index 0000000..78adaa8 --- /dev/null +++ b/internal/runner/closure_alive_test.go @@ -0,0 +1,90 @@ +package runner_test + +import ( + "strings" + "testing" + + "github.com/DimmKirr/devcell/internal/runner" +) + +// CELL-418: the probe argv must mount the nix volume at /nix and run inside +// the target image so the baked-in profile symlink can be followed. +func TestClosureAliveArgv_MountsVolumeAndImage(t *testing.T) { + argv := runner.ClosureAliveArgv("devcell-nix-store", "devcell-user:base-thin") + joined := strings.Join(argv, " ") + + if !strings.Contains(joined, "-v devcell-nix-store:/nix") { + t.Errorf("expected nix volume mount, got: %s", joined) + } + if !strings.Contains(joined, "devcell-user:base-thin") { + t.Errorf("expected image name in argv, got: %s", joined) + } + if !strings.Contains(joined, "--entrypoint") { + t.Errorf("expected --entrypoint override, got: %s", joined) + } +} + +func TestClosureAliveArgv_ChecksProfileSymlink(t *testing.T) { + argv := runner.ClosureAliveArgv("vol", "img:tag") + script := argv[len(argv)-1] + + if !strings.Contains(script, runner.ProfilePath) { + t.Errorf("script must check the profile path, got: %s", script) + } + if !strings.Contains(script, "readlink") { + t.Errorf("script must readlink the profile, got: %s", script) + } +} + +func TestParseClosureAliveResult_Alive(t *testing.T) { + path, alive := runner.ParseClosureAliveResult("/nix/store/abc123-user-environment\n", nil) + if !alive { + t.Error("expected alive=true for successful probe") + } + if path != "/nix/store/abc123-user-environment" { + t.Errorf("expected parsed path, got: %q", path) + } +} + +func TestParseClosureAliveResult_Dead(t *testing.T) { + path, alive := runner.ParseClosureAliveResult("", errStub("exit status 1")) + if alive { + t.Error("expected alive=false for failed probe") + } + if path != "" { + t.Errorf("expected empty path on failure, got: %q", path) + } +} + +func TestParseClosureAliveResult_NilErrorEmptyOutput(t *testing.T) { + _, alive := runner.ParseClosureAliveResult("", nil) + if alive { + t.Error("expected alive=false when output is empty even with nil error") + } +} + +// ClosureDeadWarning must produce a clear, actionable message when the +// closure is dead, and silence when alive. +func TestClosureDeadWarning_DeadProducesMessage(t *testing.T) { + msg, dead := runner.ClosureDeadWarning(false) + if !dead { + t.Fatal("alive=false must produce a warning") + } + if !strings.Contains(msg, "garbage collected") { + t.Errorf("warning must explain the closure was GC'd, got: %s", msg) + } + if !strings.Contains(msg, "Rebuild") { + t.Errorf("warning must offer rebuild, got: %s", msg) + } +} + +func TestClosureDeadWarning_AliveIsSilent(t *testing.T) { + _, dead := runner.ClosureDeadWarning(true) + if dead { + t.Error("alive=true must not warn") + } +} + +type errStub string + +func (e errStub) Error() string { return string(e) } diff --git a/internal/runner/container_context.tmpl.md b/internal/runner/container_context.tmpl.md new file mode 100644 index 0000000..e8e5985 --- /dev/null +++ b/internal/runner/container_context.tmpl.md @@ -0,0 +1,93 @@ +Environment: Docker container (cell-{{.AppName}}) +Project: {{.AppDir}} (alias for {{.HostDir}} on host) +Both paths are bind-mounted from the same host directory and resolve to the same filesystem. Working directory is +{{.AppDir}}. If the user mentions host paths like {{.HostDir}}/..., they map to {{.AppDir}}/... + +## Bind mounts + +| Container path | Host path | Access | +|-------------------------------|----------------|----------------------------------------------| +| {{.AppDir}} | {{.HostDir}} | read-write, project source | +| {{.HomeDir}} | — | persistent home, survives container restarts | +| {{.HomeDir}}/.claude/skills | — | read-write | +| {{.HomeDir}}/.claude/commands | — | read-only, from host | +| {{.HomeDir}}/.claude/agents | — | read-only, from host | +| /etc/devcell/config | {{.ConfigDir}} | user build config | +{{- range .Volumes}} +| {{.Container}} | {{.Host}} | {{.Mode}}, from devcell.toml | +{{- end}} + +## Host path mapping + +Use these to translate paths the user mentions: + +| Host | Container | +|---------------|--------------| +| {{.HostDir}} | {{.HostDir}} | +| {{.HostHome}} | {{.HomeDir}} | +{{- range .Volumes}} +| {{.Host}} | {{.Container}} | +{{- end}} + +## Constraints + +- `/opt/devcell` is the nix environment — do not modify at runtime. +- Nix profile: `/opt/devcell/.local/state/nix/profiles/profile` + +## Nix runtime + +## Installing packages + +To persist a package across image rebuilds, add it to nixhome — the Nix Home Manager configuration at `/opt/devcell/nixhome`. Pick the module matching the tool's domain (e.g. `go.nix`, `node.nix`, `base.nix`, `infra.nix`), add the package to `home.packages`, then run `task nix:validate` before building. See `.claude/rules/nixhome.md` for the full workflow. + +### Installing packages ad-hoc + +If a package is needed only for the current container, use `nix profile install`: + +```sh +nix profile install nixpkgs# +``` + +Installed binaries land in `~/.local/state/nix/profiles/profile/bin/`, already on PATH. To verify an attribute exists first: `nix eval nixpkgs#.pname --raw`. These installs survive container restarts (persistent `$HOME`) but are not baked into the image — they will be lost on image rebuild. + +### PATH layout + +First match wins: + +1. `~/go/bin` — Go binaries built by the user +2. `~/.local/state/nix/profiles/profile/bin` — ad-hoc nix profile installs +3. `/opt/devcell/.local/state/nix/profiles/profile/bin` — baked image packages +4. `~/.local/share/mise/shims` — mise-managed runtimes (go, node, python, terraform) +5. system PATH + +This is a Nix environment — binaries are NOT in `/usr/bin` or `/usr/local/bin`. If you can't find something at a standard path, do not assume the software is missing. Check the directories above and use `which ` or `command -v ` before concluding a tool is not installed. Ad-hoc installs (2) shadow baked packages (3). + +### C libraries and dynamic linking + +Nix does not use `/usr/lib` or `/usr/include`. The nix-ld shim handles non-nix binaries. + +- Shared libraries (`.so`) from the profile closure are symlinked into `/opt/devcell/.nix-ld-libs/`. +- `NIX_LD_LIBRARY_PATH` (not `LD_LIBRARY_PATH`) points there — this is what nix-ld reads. +- If a binary fails with "cannot open shared object": install the library via `nix profile install`, then check if the + `.so` appears in `~/.local/state/nix/profiles/profile/lib/`. +- For ad-hoc installs, you may need to extend the search path: + +```sh +export NIX_LD_LIBRARY_PATH="$HOME/.local/state/nix/profiles/profile/lib${NIX_LD_LIBRARY_PATH:+:$NIX_LD_LIBRARY_PATH}" +``` + +### Go + C (CGO) + +`CC` is set to `cc` (resolves to clang via nix). `gcc` is not available. + +For Go packages with C dependencies (`CGO_ENABLED=1`), install the C library via nix profile, then point CGO at the nix +store paths: + +```sh +PKG=$(nix eval nixpkgs#.outPath --raw) +export CGO_CFLAGS="-I$PKG/include" +export CGO_LDFLAGS="-L$PKG/lib" +``` + +`pkg-config` works if the library provides a `.pc` file — the nix profile puts them in +`~/.local/state/nix/profiles/profile/lib/pkgconfig/` or `share/pkgconfig/`. Set `PKG_CONFIG_PATH` accordingly if needed. diff --git a/internal/runner/df.go b/internal/runner/df.go index 3209517..004fa73 100644 --- a/internal/runner/df.go +++ b/internal/runner/df.go @@ -130,8 +130,8 @@ func boolToInt(b bool) int { // FormatOpts controls FormatTable / FormatJSON output. type FormatOpts struct { - TopN int // 0 means "all" - Kinds []EntryKind // empty means all kinds + TopN int // 0 means "all" + Kinds []EntryKind // empty means all kinds } // FormatTable renders the ranked entries as a human-readable table with a @@ -223,10 +223,10 @@ func FormatTableWithVM(snap DFSnapshot, opts FormatOpts, vm VMDiskInfo, containe } // selectEntries is the canonical pipeline used by both formatters: -// 1. rank everything by reclaimable bytes -// 2. filter by kind (so --kind cache returns the top cache rows, -// not the top-of-everything filtered down to cache) -// 3. cap to TopN +// 1. rank everything by reclaimable bytes +// 2. filter by kind (so --kind cache returns the top cache rows, +// not the top-of-everything filtered down to cache) +// 3. cap to TopN // // Doing #3 before #2 (the previous order) silently dropped all rows when // the top-N globally happened to be entirely the unwanted kind. @@ -505,11 +505,11 @@ type rawImage struct { } type rawContainer struct { - ID string `json:"ID"` - Names string `json:"Names"` - Image string `json:"Image"` - State string `json:"State"` - Size string `json:"Size"` + ID string `json:"ID"` + Names string `json:"Names"` + Image string `json:"Image"` + State string `json:"State"` + Size string `json:"Size"` } type rawVolume struct { diff --git a/internal/runner/df_collect.go b/internal/runner/df_collect.go index 49f5b0b..770e002 100644 --- a/internal/runner/df_collect.go +++ b/internal/runner/df_collect.go @@ -130,5 +130,13 @@ func RunDF(a RunDFArgs) error { vmDisk, _ := CollectVMDisk(a.Ctx) containers, _ := CollectRunningContainers(a.Ctx) volMounts := CollectVolumeMounts(a.Ctx) - return FormatTableWithVM(snap, fopts, vmDisk, containers, volMounts, a.Out) + if err := FormatTableWithVM(snap, fopts, vmDisk, containers, volMounts, a.Out); err != nil { + return err + } + // Nix store section: counts, root names, stale markers, per-root + // metadata (read-only probe; omitted when the volume is unreachable). + if nix, ok := CollectNixStore(a.Ctx); ok { + FormatNixStoreSection(nix, a.Out) + } + return nil } diff --git a/internal/runner/df_nix.go b/internal/runner/df_nix.go new file mode 100644 index 0000000..817ddde --- /dev/null +++ b/internal/runner/df_nix.go @@ -0,0 +1,170 @@ +package runner + +import ( + "context" + "fmt" + "io" + "os/exec" + "strings" +) + +// `cell build df` nix section — the standalone "what's on the nix volume" +// analysis surface: counts, root NAMES, stale markers, per-root metadata. +// Same safety contract as the CELL-390 startup probe (which only shows +// counts): read-only, runs in a volume-mounted container, never invokes +// nix, never evaluates auto/ roots. + +// nixRootsListScript emits one line per devcell root and one per meta: +// +// root name= stale=0|1 +// meta hash= project=

stack= nixpkgs= +const nixRootsListScript = `for l in /nix/var/nix/gcroots/devcell/*-profile /nix/var/nix/gcroots/devcell/*-generation; do + [ -L "$l" ] || continue + if [ -e "$l" ]; then s=0; else s=1; fi + echo "root name=$(basename "$l") stale=$s" +done +for m in /nix/var/nix/gcroots/devcell/*-meta; do + [ -f "$m" ] || continue + echo "meta hash=$(basename "$m" | cut -d- -f1) project=$(grep '^project=' "$m" 2>/dev/null | cut -d= -f2) stack=$(grep '^stack=' "$m" 2>/dev/null | cut -d= -f2) nixpkgs=$(grep '^nixpkgs=' "$m" 2>/dev/null | cut -d= -f2)" +done` + +// NixDFReportScript is the health probe plus the root/meta listing — +// still purely read-only. +const NixDFReportScript = NixHealthProbeScript + "\n" + nixRootsListScript + +// NixRootEntry is one GC root symlink on the volume. +type NixRootEntry struct { + Name string + Stale bool +} + +// NixRootMeta is the parsed -meta file for one root hash. +type NixRootMeta struct { + Hash string + Project string + Stack string + Nixpkgs string +} + +// NixStoreReport is the full parsed df report for the nix volume. +type NixStoreReport struct { + Volume string + Health NixStoreHealth + Roots []NixRootEntry + Metas []NixRootMeta +} + +// NixDFReportArgv wraps the report script in a docker-run of the volume. +func NixDFReportArgv(volume string) []string { + return []string{ + "docker", "run", "--rm", + "-v", volume + ":/nix", + NixCoreImage, + "sh", "-c", NixDFReportScript, + } +} + +// ParseNixStoreReport parses combined probe + listing output. +func ParseNixStoreReport(volume, out string) (NixStoreReport, error) { + health, err := ParseNixStoreHealth(out) + if err != nil { + return NixStoreReport{}, err + } + r := NixStoreReport{Volume: volume, Health: health} + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + kind, rest, ok := strings.Cut(line, " ") + if !ok { + continue + } + kv := map[string]string{} + for _, field := range strings.Fields(rest) { + if k, v, ok := strings.Cut(field, "="); ok { + kv[k] = v + } + } + switch kind { + case "root": + if kv["name"] != "" { + r.Roots = append(r.Roots, NixRootEntry{Name: kv["name"], Stale: kv["stale"] == "1"}) + } + case "meta": + if kv["hash"] != "" { + r.Metas = append(r.Metas, NixRootMeta{ + Hash: kv["hash"], + Project: kv["project"], + Stack: kv["stack"], + Nixpkgs: kv["nixpkgs"], + }) + } + } + } + return r, nil +} + +// CollectNixStore runs the report against the thin store volume. Any +// failure returns ok=false — the df section is simply omitted, matching +// the CELL-390 degrade-to-silence contract. +func CollectNixStore(ctx context.Context) (NixStoreReport, bool) { + volume := ThinStoreVolume() + out, err := exec.CommandContext(ctx, "docker", "run", "--rm", + "-v", volume+":/nix", NixCoreImage, "sh", "-c", NixDFReportScript, + ).CombinedOutput() + if err != nil { + return NixStoreReport{}, false + } + r, perr := ParseNixStoreReport(volume, string(out)) + if perr != nil { + return NixStoreReport{}, false + } + return r, true +} + +// FormatNixStoreSection renders the nix block of `cell build df`. An +// empty report (no volume data) renders nothing. +func FormatNixStoreSection(r NixStoreReport, w io.Writer) { + if r.Volume == "" && r.Health.TotalRoots == 0 && len(r.Roots) == 0 { + return + } + metaByHash := make(map[string]NixRootMeta, len(r.Metas)) + for _, m := range r.Metas { + metaByHash[m.Hash] = m + } + + fmt.Fprintf(w, "\nNix store (%s):\n", r.Volume) + fmt.Fprintf(w, " %s, %s, %s (%d orphaned — reclaimable)\n", + plural(r.Health.TotalRoots, "root"), + plural(r.Health.ProfileHashes, "profile hash"), + plural(r.Health.Generations, "generation"), + r.Health.OrphanedGenerations, + ) + if r.Health.DistinctRevs > 1 { + fmt.Fprintf(w, " drift: %d nixpkgs revs live (newest %s on %s)\n", + r.Health.DistinctRevs, shortRev(r.Health.NewestRev), + plural(r.Health.NewestProjects, "project")) + } + for _, root := range r.Roots { + line := " " + root.Name + hash, _, _ := strings.Cut(root.Name, "-") + if m, ok := metaByHash[hash]; ok && strings.HasSuffix(root.Name, "-profile") { + parts := []string{} + if m.Project != "" { + parts = append(parts, "project="+m.Project) + } + if m.Stack != "" { + parts = append(parts, "stack="+m.Stack) + } + if m.Nixpkgs != "" { + parts = append(parts, "nixpkgs="+shortRev(m.Nixpkgs)) + } + if len(parts) > 0 { + line += " " + strings.Join(parts, " ") + } + } + if root.Stale { + line += " (stale)" + } + fmt.Fprintln(w, line) + } + fmt.Fprintln(w, " To reclaim: cell cleanup && cell build prune --pure") +} diff --git a/internal/runner/df_nix_test.go b/internal/runner/df_nix_test.go new file mode 100644 index 0000000..052fc31 --- /dev/null +++ b/internal/runner/df_nix_test.go @@ -0,0 +1,129 @@ +package runner_test + +import ( + "strings" + "testing" + + "github.com/DimmKirr/devcell/internal/runner" +) + +// `cell build df` nix section: default output shows nix-store state — +// counts, root NAMES, stale markers, and per-root metadata. Same safety +// contract as the CELL-390 probe: read-only, volume-mounted container, +// no nix invocation, no auto/ roots. + +func TestNixDFReportScript_IsReadOnly(t *testing.T) { + forbidden := []string{ + "rm ", "rm\t", "ln -s", "mkdir", "mv ", "touch", + "nix-store", "nix-collect-garbage", "nix ", + "> /", ">> /", + } + for _, tok := range forbidden { + if strings.Contains(runner.NixDFReportScript, tok) { + t.Errorf("df report script must be read-only, found %q", tok) + } + } + if strings.Contains(runner.NixDFReportScript, "gcroots/auto") { + t.Error("df report script must not evaluate auto/ roots") + } +} + +func TestParseNixStoreReport_ParsesHealthRootsAndMetas(t *testing.T) { + out := `total=3 stale=1 hashes=2 generations=4 orphaned=2 revs=2 newest_rev=9f8e7d6 newest_projects=1 +root name=dikb1y8-profile stale=0 +root name=dikb1y8-generation stale=0 +root name=old4321-profile stale=1 +meta hash=dikb1y8 project=devcell stack=ultimate nixpkgs=9f8e7d6 +` + r, err := runner.ParseNixStoreReport("devcell-nix-store", out) + if err != nil { + t.Fatal(err) + } + if r.Volume != "devcell-nix-store" { + t.Errorf("volume not carried: %+v", r) + } + if r.Health.TotalRoots != 3 || r.Health.OrphanedGenerations != 2 { + t.Errorf("health not parsed: %+v", r.Health) + } + if len(r.Roots) != 3 { + t.Fatalf("want 3 roots, got %d: %+v", len(r.Roots), r.Roots) + } + if r.Roots[0].Name != "dikb1y8-profile" || r.Roots[0].Stale { + t.Errorf("root[0] wrong: %+v", r.Roots[0]) + } + if r.Roots[2].Name != "old4321-profile" || !r.Roots[2].Stale { + t.Errorf("stale root not flagged: %+v", r.Roots[2]) + } + if len(r.Metas) != 1 || r.Metas[0].Project != "devcell" || + r.Metas[0].Stack != "ultimate" || r.Metas[0].Nixpkgs != "9f8e7d6" { + t.Errorf("meta wrong: %+v", r.Metas) + } +} + +func TestFormatNixStoreSection_ShowsCountsNamesMetaAndHints(t *testing.T) { + r := runner.NixStoreReport{ + Volume: "devcell-nix-store", + Health: runner.NixStoreHealth{ + TotalRoots: 3, StaleRoots: 1, ProfileHashes: 2, + Generations: 4, OrphanedGenerations: 2, + }, + Roots: []runner.NixRootEntry{ + {Name: "dikb1y8-profile"}, + {Name: "dikb1y8-generation"}, + {Name: "old4321-profile", Stale: true}, + }, + Metas: []runner.NixRootMeta{ + {Hash: "dikb1y8", Project: "devcell", Stack: "ultimate", Nixpkgs: "9f8e7d6abcdef"}, + }, + } + var b strings.Builder + runner.FormatNixStoreSection(r, &b) + out := b.String() + + for _, want := range []string{ + "Nix store (devcell-nix-store)", + "3 roots", "2 profile hashes", "4 generations", "2 orphaned", + "dikb1y8-profile", "dikb1y8-generation", + "old4321-profile", "(stale)", + "project=devcell", "stack=ultimate", "nixpkgs=9f8e7d6", + "cell cleanup", "cell build prune --pure", + } { + if !strings.Contains(out, want) { + t.Errorf("nix section missing %q\noutput:\n%s", want, out) + } + } +} + +// A root whose hash has a -meta file gets the metadata inline on its +// -profile row; unmetad roots render bare. +func TestFormatNixStoreSection_MetaJoinedByHash(t *testing.T) { + r := runner.NixStoreReport{ + Volume: "v", + Health: runner.NixStoreHealth{TotalRoots: 2, ProfileHashes: 2}, + Roots: []runner.NixRootEntry{ + {Name: "aaa1111-profile"}, + {Name: "bbb2222-profile"}, + }, + Metas: []runner.NixRootMeta{{Hash: "aaa1111", Project: "trips"}}, + } + var b strings.Builder + runner.FormatNixStoreSection(r, &b) + out := b.String() + for _, line := range strings.Split(out, "\n") { + if strings.Contains(line, "aaa1111-profile") && !strings.Contains(line, "project=trips") { + t.Errorf("meta must be joined onto its root's row: %q", line) + } + if strings.Contains(line, "bbb2222-profile") && strings.Contains(line, "project=") { + t.Errorf("unmetad root must render bare: %q", line) + } + } +} + +// Probe failure → empty section, df must not break. +func TestFormatNixStoreSection_EmptyReportRendersNothing(t *testing.T) { + var b strings.Builder + runner.FormatNixStoreSection(runner.NixStoreReport{}, &b) + if b.Len() != 0 { + t.Errorf("empty report must render nothing, got %q", b.String()) + } +} diff --git a/internal/runner/df_test.go b/internal/runner/df_test.go index 7c6463e..33ace0f 100644 --- a/internal/runner/df_test.go +++ b/internal/runner/df_test.go @@ -13,7 +13,10 @@ import ( "github.com/DimmKirr/devcell/internal/runner" ) -type fakeCollector struct{ raw []byte; err error } +type fakeCollector struct { + raw []byte + err error +} func (f fakeCollector) CollectSystemDF(context.Context) ([]byte, error) { return f.raw, f.err @@ -209,7 +212,9 @@ func TestComputeTotals_ImagesUsesUniqueBytesNotSize(t *testing.T) { t.Fatal(err) } var got struct { - Totals struct{ ImagesBytes int64 `json:"imagesBytes"` } `json:"totals"` + Totals struct { + ImagesBytes int64 `json:"imagesBytes"` + } `json:"totals"` } if err := json.Unmarshal(buf.Bytes(), &got); err != nil { t.Fatal(err) @@ -350,9 +355,9 @@ func TestFormatTable_HighlightsPinnedAndPrintsHints(t *testing.T) { wantSubstrings := []string{ "TYPE", "SIZE", "RECLAIM", "PINNED", "devcell-user:ultimate-pure", - "✓ (3)", // pinned marker with count - "docker image rm aded93bf10dc", // orphan reclaim hint, short id form - "docker buildx prune", // cache/volume hint + "✓ (3)", // pinned marker with count + "docker image rm aded93bf10dc", // orphan reclaim hint, short id form + "docker buildx prune", // cache/volume hint "Totals:", } for _, want := range wantSubstrings { diff --git a/internal/runner/docker_debug.go b/internal/runner/docker_debug.go new file mode 100644 index 0000000..1e65398 --- /dev/null +++ b/internal/runner/docker_debug.go @@ -0,0 +1,136 @@ +package runner + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// DockerDebugInfo describes the client-to-daemon connection used by Cell. +// It deliberately excludes registry and authentication configuration. +type DockerDebugInfo struct { + Context string + Endpoint string + DockerHostEnv string + Runtime string + Name string + ServerVersion string + OperatingOS string + Architecture string + RootDir string + CPUs int + MemoryBytes int64 + Socket string + SocketTarget string +} + +// CollectDockerDebugInfo reports the actual daemon selected by the current +// Docker CLI environment. This matters inside a cell, where /var/run/docker.sock +// was fixed when the outer container was created. +func CollectDockerDebugInfo(ctx context.Context) (DockerDebugInfo, error) { + var info DockerDebugInfo + info.DockerHostEnv = strings.TrimSpace(os.Getenv("DOCKER_HOST")) + info.Context = dockerOutput(ctx, "context", "show") + if info.DockerHostEnv != "" { + info.Endpoint = info.DockerHostEnv + } else { + info.Endpoint = dockerOutput(ctx, "context", "inspect", info.Context, + "--format", `{{(index .Endpoints "docker").Host}}`) + } + + raw, err := exec.CommandContext(ctx, "docker", "info", "--format", "{{json .}}").Output() + if err != nil { + return info, fmt.Errorf("docker info: %w", err) + } + var daemon struct { + Name string + ServerVersion string + OperatingSystem string + Architecture string + DockerRootDir string + NCPU int + MemTotal int64 + Labels []string + } + if err := json.Unmarshal(raw, &daemon); err != nil { + return info, fmt.Errorf("decode docker info: %w", err) + } + info.Name = daemon.Name + info.ServerVersion = daemon.ServerVersion + info.OperatingOS = daemon.OperatingSystem + info.Architecture = daemon.Architecture + info.RootDir = daemon.DockerRootDir + info.CPUs = daemon.NCPU + info.MemoryBytes = daemon.MemTotal + info.Runtime = classifyDockerRuntime(daemon.Name, daemon.OperatingSystem, daemon.Labels) + + if strings.HasPrefix(info.Endpoint, "unix://") { + info.Socket = strings.TrimPrefix(info.Endpoint, "unix://") + } else if info.Endpoint == "" || info.Endpoint == "unix:///var/run/docker.sock" { + info.Socket = "/var/run/docker.sock" + } + if info.Socket != "" { + if target, err := filepath.EvalSymlinks(info.Socket); err == nil { + info.SocketTarget = target + } + } + return info, nil +} + +func dockerOutput(ctx context.Context, args ...string) string { + out, err := exec.CommandContext(ctx, "docker", args...).Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +func classifyDockerRuntime(name, operatingSystem string, labels []string) string { + haystack := strings.ToLower(name + " " + operatingSystem + " " + strings.Join(labels, " ")) + switch { + case strings.Contains(haystack, "docker desktop"), + strings.Contains(haystack, "docker-desktop"), + strings.Contains(haystack, "docker.desktop"): + return "docker-desktop" + case strings.Contains(haystack, "colima"): + return "colima" + default: + return "docker" + } +} + +// ProbeDockerBind verifies both that the daemon accepts source and that marker +// is visible inside a container. It is intended for --debug diagnostics only. +func ProbeDockerBind(ctx context.Context, image, volume, source, marker string) (string, error) { + args := DockerBindProbeArgv(image, volume, source, marker) + out, err := exec.CommandContext(ctx, args[0], args[1:]...).CombinedOutput() + return strings.TrimSpace(string(out)), err +} + +// DockerBindProbeArgv composes the non-mutating debug probe command. +func DockerBindProbeArgv(image, volume, source, marker string) []string { + const destination = "/__devcell_bind_probe" + args := []string{ + "docker", "run", "--rm", "--network", "none", "--user", "0", + "--mount", "type=bind,src=" + source + ",dst=" + destination + ",readonly", + } + if volume != "" { + args = append(args, "-v", volume+":/nix") + } + args = append(args, + "--entrypoint", "/bin/sh", image, "-c", + `test -f "$1" && printf 'visible:%s\n' "$1" || { ls -la "`+destination+`" >&2; exit 42; }`, + "probe", destination+"/"+marker, + ) + return args +} + +// DockerVolumeDebug returns daemon-side metadata for a named volume. +func DockerVolumeDebug(ctx context.Context, volume string) string { + return dockerOutput(ctx, "volume", "inspect", volume, "--format", + `name={{.Name}} driver={{.Driver}} scope={{.Scope}} mountpoint={{.Mountpoint}}`) +} diff --git a/internal/runner/docker_debug_test.go b/internal/runner/docker_debug_test.go new file mode 100644 index 0000000..f50e0e9 --- /dev/null +++ b/internal/runner/docker_debug_test.go @@ -0,0 +1,45 @@ +package runner + +import ( + "strings" + "testing" +) + +func TestClassifyDockerRuntime(t *testing.T) { + tests := []struct { + name string + daemon string + os string + labels []string + want string + }{ + {name: "desktop OS", daemon: "docker-desktop", os: "Docker Desktop", want: "docker-desktop"}, + {name: "desktop label", daemon: "linux", labels: []string{"com.docker.desktop.address=x"}, want: "docker-desktop"}, + {name: "colima", daemon: "colima", os: "Ubuntu", want: "colima"}, + {name: "plain docker", daemon: "builder-1", os: "Ubuntu", want: "docker"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := classifyDockerRuntime(tt.daemon, tt.os, tt.labels); got != tt.want { + t.Fatalf("classifyDockerRuntime() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestDockerBindProbeArgvUsesStrictBindMount(t *testing.T) { + argv := DockerBindProbeArgv("devcell:test", "devcell-nix-store", "/Users/me/project", ".devcell.toml") + joined := strings.Join(argv, "\n") + if !strings.Contains(joined, "type=bind,src=/Users/me/project,dst=/__devcell_bind_probe,readonly") { + t.Fatalf("probe must use strict --mount bind syntax: %v", argv) + } + if strings.Contains(joined, "/Users/me/project:/__devcell_bind_probe") { + t.Fatalf("probe must not use legacy -v for the host path: %v", argv) + } + if !strings.Contains(joined, "devcell-nix-store:/nix") { + t.Fatalf("thin probe must attach the Nix volume: %v", argv) + } + if !strings.Contains(joined, "/__devcell_bind_probe/.devcell.toml") { + t.Fatalf("probe marker missing: %v", argv) + } +} diff --git a/internal/runner/errors.go b/internal/runner/errors.go index c8a8f72..36ce5a3 100644 --- a/internal/runner/errors.go +++ b/internal/runner/errors.go @@ -23,6 +23,12 @@ func TranslateError(err error) string { strings.Contains(lower, "docker daemon"): return "Docker isn't running. Start Docker Desktop / OrbStack / Colima and re-run." + // `[cell] kvm = true` on a daemon host without /dev/kvm. Checked before the + // generic device/disk cases because the fix is specific: enable nested + // virtualization, or turn the flag back off. + case strings.Contains(lower, "adding custom device") && strings.Contains(lower, "/dev/kvm"): + return "No /dev/kvm on the docker daemon host, but `[cell] kvm = true` asked for it. Enable nested virtualization (Colima: `vmType: vz` + `nestedVirtualization: true`, needs M3+/macOS 15+), or set `kvm = false` in .devcell.toml (or DEVCELL_KVM=0) to fall back to TCG emulation." + // Network glitch fetching a Nix-pinned source (registry hash != download hash). // Usually transient; a retry fixes it. case strings.Contains(lower, "hash mismatch in fixed-output derivation"): @@ -53,7 +59,7 @@ func TranslateError(err error) string { // Host UID mismatch — files in mounted project become owned by wrong UID. case strings.Contains(lower, "uid") && strings.Contains(lower, "mismatch"): - return "UID mismatch between host and container. Pass `--uid $(id -u)` or check `[cell].docker_privileged` in .devcell.toml." + return "UID mismatch between host and container. Pass `--uid $(id -u)` or check `[docker].privileged` in .devcell.toml." // Port already in use — common when multiple cells try to claim the same port. case strings.Contains(lower, "address already in use") || diff --git a/internal/runner/errors_test.go b/internal/runner/errors_test.go index 2142c77..c8cb9b4 100644 --- a/internal/runner/errors_test.go +++ b/internal/runner/errors_test.go @@ -89,3 +89,29 @@ func TestTranslateError_NilSafe(t *testing.T) { t.Errorf("nil error should return empty string, got: %s", got) } } + +// TestTranslateError_MissingKVMDevice: `[cell] kvm = true` on a daemon host +// without nested virtualization. The raw text is verbatim from docker 29.2.1. +func TestTranslateError_MissingKVMDevice(t *testing.T) { + raw := errors.New(`docker: Error response from daemon: error gathering device information while adding custom device "/dev/kvm": no such file or directory`) + got := runner.TranslateError(raw) + lower := strings.ToLower(got) + if !strings.Contains(lower, "kvm") { + t.Errorf("expected mention of KVM, got: %s", got) + } + if !strings.Contains(lower, "nested") { + t.Errorf("expected mention of nested virtualization as the cause, got: %s", got) + } + if !strings.Contains(lower, "kvm = false") && !strings.Contains(lower, "devcell_kvm=0") { + t.Errorf("expected an opt-out hint, got: %s", got) + } +} + +// A non-KVM custom device must not be mistranslated into KVM advice. +func TestTranslateError_MissingOtherDeviceIsNotKVMAdvice(t *testing.T) { + raw := errors.New(`docker: Error response from daemon: error gathering device information while adding custom device "/dev/ttyUSB0": no such file or directory`) + got := runner.TranslateError(raw) + if strings.Contains(strings.ToLower(got), "kvm") { + t.Errorf("non-KVM device error must not produce KVM advice, got: %s", got) + } +} diff --git a/internal/runner/exec.go b/internal/runner/exec.go new file mode 100644 index 0000000..29d46b5 --- /dev/null +++ b/internal/runner/exec.go @@ -0,0 +1,20 @@ +package runner + +// ExecSpec holds the parameters for building a docker exec argv. +type ExecSpec struct { + ContainerName string + Binary string + Args []string + TTY bool +} + +// BuildExecArgv constructs a docker exec argv for attaching to a running container. +func BuildExecArgv(spec ExecSpec) []string { + argv := []string{"docker", "exec"} + if spec.TTY { + argv = append(argv, "-it") + } + argv = append(argv, spec.ContainerName, spec.Binary) + argv = append(argv, spec.Args...) + return argv +} diff --git a/internal/runner/exec_test.go b/internal/runner/exec_test.go new file mode 100644 index 0000000..48c454e --- /dev/null +++ b/internal/runner/exec_test.go @@ -0,0 +1,64 @@ +package runner_test + +import ( + "testing" + + "github.com/DimmKirr/devcell/internal/runner" +) + +func TestBuildExecArgv_Basic(t *testing.T) { + argv := runner.BuildExecArgv(runner.ExecSpec{ + ContainerName: "cell-foo-0-run", + Binary: "zsh", + TTY: true, + }) + want := []string{"docker", "exec", "-it", "cell-foo-0-run", "zsh"} + if len(argv) != len(want) { + t.Fatalf("got %v, want %v", argv, want) + } + for i := range want { + if argv[i] != want[i] { + t.Errorf("argv[%d] = %q, want %q", i, argv[i], want[i]) + } + } +} + +func TestBuildExecArgv_WithArgs(t *testing.T) { + argv := runner.BuildExecArgv(runner.ExecSpec{ + ContainerName: "cell-foo-0-run", + Binary: "ls", + Args: []string{"-la", "/workspace"}, + TTY: true, + }) + want := []string{"docker", "exec", "-it", "cell-foo-0-run", "ls", "-la", "/workspace"} + if len(argv) != len(want) { + t.Fatalf("got %v, want %v", argv, want) + } + for i := range want { + if argv[i] != want[i] { + t.Errorf("argv[%d] = %q, want %q", i, argv[i], want[i]) + } + } +} + +func TestBuildExecArgv_NoTTY(t *testing.T) { + argv := runner.BuildExecArgv(runner.ExecSpec{ + ContainerName: "cell-foo-0-run", + Binary: "zsh", + TTY: false, + }) + for _, a := range argv { + if a == "-it" { + t.Error("-it should not be present when TTY is false") + } + } + want := []string{"docker", "exec", "cell-foo-0-run", "zsh"} + if len(argv) != len(want) { + t.Fatalf("got %v, want %v", argv, want) + } + for i := range want { + if argv[i] != want[i] { + t.Errorf("argv[%d] = %q, want %q", i, argv[i], want[i]) + } + } +} diff --git a/internal/runner/kvm_argv_test.go b/internal/runner/kvm_argv_test.go new file mode 100644 index 0000000..38c192b --- /dev/null +++ b/internal/runner/kvm_argv_test.go @@ -0,0 +1,94 @@ +package runner_test + +import ( + "strings" + "testing" + + "github.com/DimmKirr/devcell/internal/cfg" + "github.com/DimmKirr/devcell/internal/runner" +) + +// KVM passthrough — `[cell] kvm = true` hands the daemon host's /dev/kvm to +// the container so QEMU can use hardware acceleration instead of TCG. +// +// It must be `--device`, not `-v`. A bind-mount creates the device node but +// the cgroup device controller still denies open(2) — verified against a live +// Colima daemon: `-v /dev/kvm:/dev/kvm` yields EPERM, `--device=/dev/kvm` +// opens fine. + +func TestBuildArgv_KVMAddsDeviceFlag(t *testing.T) { + argv := buildArgv(t, func(s *runner.RunSpec) { + s.CellCfg.Cell.KVM = boolPtr(true) + }) + if !hasArg(argv, "--device=/dev/kvm") { + t.Errorf("kvm=true must emit --device=/dev/kvm; argv: %v", argv) + } +} + +func TestBuildArgv_KVMUsesDeviceNotVolume(t *testing.T) { + argv := buildArgv(t, func(s *runner.RunSpec) { + s.CellCfg.Cell.KVM = boolPtr(true) + }) + for i, a := range argv { + if a == "-v" && i+1 < len(argv) && strings.Contains(argv[i+1], "/dev/kvm") { + t.Errorf("/dev/kvm must be passed with --device, not -v (cgroup denies open); got -v %q", argv[i+1]) + } + } +} + +func TestBuildArgv_KVMOffByDefault(t *testing.T) { + argv := buildArgv(t) // CellCfg zero value: KVM unset + for _, a := range argv { + if strings.Contains(a, "/dev/kvm") { + t.Errorf("unset kvm must not emit any /dev/kvm flag; got %q", a) + } + } +} + +func TestBuildArgv_KVMExplicitFalseOmitsDevice(t *testing.T) { + argv := buildArgv(t, func(s *runner.RunSpec) { + s.CellCfg.Cell.KVM = boolPtr(false) + }) + if hasArg(argv, "--device=/dev/kvm") { + t.Errorf("kvm=false must not emit --device=/dev/kvm; argv: %v", argv) + } +} + +// The existing /dev/fuse device must survive — it is unconditional and +// unrelated to KVM. +func TestBuildArgv_KVMKeepsFuseDevice(t *testing.T) { + argv := buildArgv(t, func(s *runner.RunSpec) { + s.CellCfg.Cell.KVM = boolPtr(true) + }) + if !hasArg(argv, "--device=/dev/fuse") { + t.Errorf("--device=/dev/fuse must still be present; argv: %v", argv) + } +} + +// Guard against the flag drifting out of the docker-run flag block (it must +// precede the image name, like --device=/dev/fuse does). +func TestBuildArgv_KVMDeviceBeforeImage(t *testing.T) { + spec := runner.RunSpec{ + Config: baseConfig(), + CellCfg: cfg.CellConfig{Cell: cfg.CellSection{KVM: boolPtr(true)}}, + Binary: "claude", + Image: "devcell-user:test", + } + argv := runner.BuildArgv(spec, noopFS(), noopLookPath) + + devIdx, imgIdx := -1, -1 + for i, a := range argv { + if a == "--device=/dev/kvm" { + devIdx = i + } + if a == "devcell-user:test" { + imgIdx = i + } + } + if devIdx < 0 || imgIdx < 0 { + t.Fatalf("expected both --device=/dev/kvm and the image in argv: %v", argv) + } + if devIdx > imgIdx { + t.Errorf("--device=/dev/kvm (idx %d) must come before the image (idx %d)", devIdx, imgIdx) + } +} diff --git a/internal/runner/nix_health.go b/internal/runner/nix_health.go new file mode 100644 index 0000000..b9fb285 --- /dev/null +++ b/internal/runner/nix_health.go @@ -0,0 +1,201 @@ +package runner + +import ( + "fmt" + "strconv" + "strings" +) + +// CELL-390: startup nix-store health check. +// +// Read-only by construction: the probe inspects symlinks and counts — +// nothing is created, deleted, or retargeted, and nix itself is never +// invoked (its root-finding pass mutates: `--print-dead` deleted 12 live +// auto roots in one measured "preview", CELL-333). The probe runs inside a +// throwaway container mounting the volume at /nix, the only namespace +// where gcroots/devcell targets resolve truthfully (CELL-330 lesson). +// auto/ roots are not evaluated at all — their targets are +// container-private and unanswerable from any other namespace. + +// NixHealthProbeScript emits one machine-readable line: +// +// total=N stale=N hashes=N generations=N orphaned=N +// +// total/stale: devcell root symlinks and how many dangle. hashes: distinct +// profile hashes (>1 means config/lock drift). generations/orphaned: +// per-user profile generations and how many no devcell root protects +// (reclaimable by `cell build prune --pure`). +const NixHealthProbeScript = `total=0; stale=0; PROTECTED="" +for l in /nix/var/nix/gcroots/devcell/*-profile /nix/var/nix/gcroots/devcell/*-generation; do + [ -L "$l" ] || continue + total=$((total+1)) + if [ -e "$l" ]; then + PROTECTED="$PROTECTED $(readlink "$l")" + else + stale=$((stale+1)) + fi +done +hashes=0; HSEEN="" +for p in /nix/var/nix/gcroots/devcell/*-profile; do + [ -L "$p" ] || continue + h=$(basename "$p" | cut -d- -f1) + case " $HSEEN " in + *" $h "*) ;; + *) HSEEN="$HSEEN $h"; hashes=$((hashes+1)) ;; + esac +done +gens=0; orphaned=0 +for g in /nix/var/nix/profiles/per-user/root/profile-*-link; do + [ -L "$g" ] || continue + gens=$((gens+1)) + t=$(readlink "$g") + case "$PROTECTED" in + *" $t"*) ;; + *) orphaned=$((orphaned+1)) ;; + esac +done +newest=""; newest_stamp=""; seen_revs=""; nproj=0 +for m in /nix/var/nix/gcroots/devcell/*-meta; do + [ -f "$m" ] || continue + r=$(grep '^nixpkgs=' "$m" 2>/dev/null | cut -d= -f2) + s=$(grep '^stamped=' "$m" 2>/dev/null | cut -d= -f2) + [ -n "$r" ] || continue + case " $seen_revs " in + *" $r "*) ;; + *) seen_revs="$seen_revs $r" ;; + esac + if [ -z "$newest_stamp" ] || [ "$s" \> "$newest_stamp" ]; then + newest_stamp="$s"; newest="$r" + fi +done +nrevs=$(echo "$seen_revs" | wc -w | tr -d ' ') +for m in /nix/var/nix/gcroots/devcell/*-meta; do + [ -f "$m" ] || continue + r=$(grep '^nixpkgs=' "$m" 2>/dev/null | cut -d= -f2) + [ -n "$r" ] && [ "$r" = "$newest" ] && nproj=$((nproj+1)) +done +echo "total=$total stale=$stale hashes=$hashes generations=$gens orphaned=$orphaned revs=$nrevs newest_rev=$newest newest_projects=$nproj"` + +// NixStoreHealth is the parsed probe result. +type NixStoreHealth struct { + TotalRoots int + StaleRoots int + ProfileHashes int + Generations int + OrphanedGenerations int + + // Lock-drift datapoints from the *-meta files stamped at container + // start (CELL-332). Zero values on pre-CELL-332 volumes. CELL-391 + // consumes these for the stale-cell warning. + DistinctRevs int // distinct nixpkgs revs across metas + NewestRev string // rev with the most recent stamped= timestamp + NewestProjects int // how many metas sit on NewestRev +} + +// DebugArgv renders an argv for --debug output. Multi-line elements +// (embedded `sh -c` scripts) are elided to a one-line placeholder — dumping +// the probe script made the health check look like it printed the script +// instead of running it. +func DebugArgv(argv []string) string { + parts := make([]string, len(argv)) + for i, a := range argv { + if strings.Contains(a, "\n") { + parts[i] = fmt.Sprintf("", strings.Count(a, "\n")+1) + continue + } + parts[i] = a + } + return strings.Join(parts, " ") +} + +// NixHealthProbeArgv wraps the probe in a docker-run of the given volume. +func NixHealthProbeArgv(volume string) []string { + return []string{ + "docker", "run", "--rm", + "-v", volume + ":/nix", + NixCoreImage, + "sh", "-c", NixHealthProbeScript, + } +} + +// ParseNixStoreHealth finds the datapoint line in probe output (docker may +// prepend image-pull noise) and parses its key=value fields. Missing keys +// are zero values (older probes / pre-CELL-332 volumes emit fewer fields). +// No datapoint line means the probe degraded — callers treat that as +// "skip silently", never as fatal. +func ParseNixStoreHealth(out string) (NixStoreHealth, error) { + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "total=") { + continue + } + var h NixStoreHealth + for _, field := range strings.Fields(line) { + k, v, ok := strings.Cut(field, "=") + if !ok { + continue + } + switch k { + case "total": + h.TotalRoots = atoi(v) + case "stale": + h.StaleRoots = atoi(v) + case "hashes": + h.ProfileHashes = atoi(v) + case "generations": + h.Generations = atoi(v) + case "orphaned": + h.OrphanedGenerations = atoi(v) + case "revs": + h.DistinctRevs = atoi(v) + case "newest_rev": + h.NewestRev = v + case "newest_projects": + h.NewestProjects = atoi(v) + } + } + return h, nil + } + return NixStoreHealth{}, fmt.Errorf("no datapoint line in probe output") +} + +func atoi(s string) int { + n, err := strconv.Atoi(s) + if err != nil { + return 0 + } + return n +} + +func plural(n int, word string) string { + if n == 1 { + return fmt.Sprintf("%d %s", n, word) + } + if strings.HasSuffix(word, "sh") { + return fmt.Sprintf("%d %ses", n, word) + } + return fmt.Sprintf("%d %ss", n, word) +} + +// Summary renders the one-line non-debug UX for the "Nix store" phase row. +// The bool return signals whether the result is a warning (should render ⚠ +// instead of ✓). +func (h NixStoreHealth) Summary() (string, bool) { + hashPart := plural(h.ProfileHashes, "profile hash") + if h.StaleRoots == 0 && h.OrphanedGenerations == 0 && h.ProfileHashes <= 1 { + return fmt.Sprintf("clean — %s, %s", plural(h.TotalRoots, "root"), hashPart), false + } + var parts []string + if h.StaleRoots > 0 { + parts = append(parts, plural(h.StaleRoots, "stale root")) + } + if h.OrphanedGenerations > 0 { + parts = append(parts, plural(h.OrphanedGenerations, "orphaned generation")) + } + if h.ProfileHashes > 1 { + parts = append(parts, hashPart+" (drift)") + } + s := strings.Join(parts, ", ") + s += " — run: cell build prune --pure" + return s, true +} diff --git a/internal/runner/nix_health_test.go b/internal/runner/nix_health_test.go new file mode 100644 index 0000000..2650de5 --- /dev/null +++ b/internal/runner/nix_health_test.go @@ -0,0 +1,174 @@ +package runner_test + +import ( + "strings" + "testing" + + "github.com/DimmKirr/devcell/internal/runner" +) + +// CELL-390: startup nix-store health check. Read-only by construction — +// the probe reports, only `cell build prune` / `cell cleanup` act. + +// The probe must be pure filesystem inspection. nix must never be invoked +// (`nix-store --gc --print-dead` mutates: its root-finding pass deletes +// indirect roots — measured live, CELL-333). Nothing may be created, +// deleted, or retargeted. +func TestNixHealthProbeScript_ContainsNoMutatingTokens(t *testing.T) { + forbidden := []string{ + "rm ", "rm\t", "ln -s", "mkdir", "mv ", "touch", + "nix-store", "nix-collect-garbage", "nix ", + "> /", ">> /", + } + for _, tok := range forbidden { + if strings.Contains(runner.NixHealthProbeScript, tok) { + t.Errorf("probe script must be read-only, found %q:\n%s", tok, runner.NixHealthProbeScript) + } + } +} + +// auto/ roots are namespace-local (CELL-330) — the probe must not even +// evaluate them, from any namespace their targets are unanswerable. +func TestNixHealthProbeScript_IgnoresAutoRoots(t *testing.T) { + if strings.Contains(runner.NixHealthProbeScript, "gcroots/auto") { + t.Error("probe must not evaluate auto/ roots — targets are container-private") + } + if !strings.Contains(runner.NixHealthProbeScript, "gcroots/devcell") { + t.Error("probe must inspect gcroots/devcell/ (volume-resident targets)") + } +} + +// The probe runs in the nixos/nix image whose sh has NO sed, and whose ls +// follows symlinks (a root symlink to a store dir lists the dir contents). +// Verified live 2026-08-01: `sed: command not found`, hashes silently 0. +// Every container-side script must stick to basename/cut/grep/readlink. +func TestVolumeScripts_NoSedNoLs(t *testing.T) { + scripts := map[string]string{ + "NixHealthProbeScript": runner.NixHealthProbeScript, + "NixDFReportScript": runner.NixDFReportScript, + "SafeNixGCScript": runner.SafeNixGCScript, + "NixGCRootReportScript": runner.NixGCRootReportScript, + } + for name, s := range scripts { + if strings.Contains(s, "sed ") || strings.Contains(s, "| sed") { + t.Errorf("%s uses sed — not present in the nixos/nix probe image", name) + } + // ls as a command ($(ls …), piped, or line-start) — plain-word + // matches like "cells" are fine. + if strings.Contains(s, "$(ls ") || strings.Contains(s, "| ls ") || + strings.Contains(s, "\nls ") || strings.HasPrefix(s, "ls ") { + t.Errorf("%s uses ls — it follows root symlinks into store dirs", name) + } + } +} + +func TestParseNixStoreHealth_ParsesProbeOutput(t *testing.T) { + h, err := runner.ParseNixStoreHealth("total=8 stale=3 hashes=2 generations=5 orphaned=4\n") + if err != nil { + t.Fatal(err) + } + if h.TotalRoots != 8 || h.StaleRoots != 3 || h.ProfileHashes != 2 || + h.Generations != 5 || h.OrphanedGenerations != 4 { + t.Errorf("wrong parse: %+v", h) + } +} + +// Docker may prepend pull noise when the probe image isn't local — the +// parser must find the datapoint line anywhere in the output. +func TestParseNixStoreHealth_SkipsLeadingNoise(t *testing.T) { + out := "Unable to find image locally\nlatest: Pulling...\ntotal=1 stale=0 hashes=1 generations=1 orphaned=0\n" + h, err := runner.ParseNixStoreHealth(out) + if err != nil { + t.Fatal(err) + } + if h.TotalRoots != 1 || h.ProfileHashes != 1 { + t.Errorf("wrong parse: %+v", h) + } +} + +func TestParseNixStoreHealth_NoDatapointLineIsError(t *testing.T) { + if _, err := runner.ParseNixStoreHealth("garbage\n"); err == nil { + t.Error("output without a datapoint line must error (probe degraded)") + } +} + +// Summary is the non-debug UX: one line for the "Nix store" phase row. +func TestNixStoreHealth_SummaryClean(t *testing.T) { + h := runner.NixStoreHealth{TotalRoots: 4, ProfileHashes: 1, Generations: 2} + s, warn := h.Summary() + if warn { + t.Error("clean store must not be a warning") + } + for _, want := range []string{"clean", "4 roots", "1 profile hash"} { + if !strings.Contains(s, want) { + t.Errorf("clean summary missing %q, got %q", want, s) + } + } +} + +func TestNixStoreHealth_SummaryFindingsIncludePruneHint(t *testing.T) { + h := runner.NixStoreHealth{TotalRoots: 6, StaleRoots: 2, ProfileHashes: 3, Generations: 8, OrphanedGenerations: 5} + s, warn := h.Summary() + if !warn { + t.Error("findings must be a warning") + } + for _, want := range []string{"2 stale root", "5 orphaned generation", "cell build prune --pure"} { + if !strings.Contains(s, want) { + t.Errorf("findings summary missing %q, got %q", want, s) + } + } +} + +func TestNixStoreHealth_SummaryReportsDrift(t *testing.T) { + h := runner.NixStoreHealth{TotalRoots: 4, ProfileHashes: 3, Generations: 2} + s, warn := h.Summary() + if !warn { + t.Error("drift must be a warning") + } + if !strings.Contains(s, "3 profile hashes") { + t.Errorf("drift (multiple hashes) must be visible in summary, got %q", s) + } + if !strings.Contains(s, "cell build prune --pure") { + t.Errorf("drift-only summary must include prune hint, got %q", s) + } +} + +func TestNixHealthProbeArgv_RunsInContainerOnNixVolume(t *testing.T) { + argv := runner.NixHealthProbeArgv("devcell-nix-store") + joined := strings.Join(argv, " ") + for _, want := range []string{"docker", "run", "--rm", "devcell-nix-store:/nix"} { + if !strings.Contains(joined, want) { + t.Errorf("probe argv missing %q: %v", want, argv) + } + } +} + +// The --debug rendering of a docker-run argv must not dump embedded shell +// scripts to the console — a multi-line `sh -c` payload made the health +// check look like it printed the script instead of running it. +func TestDebugArgv_ElidesMultilineScripts(t *testing.T) { + argv := runner.NixHealthProbeArgv("devcell-nix-store") + got := runner.DebugArgv(argv) + if strings.Contains(got, "\n") { + t.Errorf("DebugArgv must be a single line, got:\n%s", got) + } + if strings.Contains(got, "gcroots") { + t.Errorf("DebugArgv must not include the script body, got:\n%s", got) + } + for _, want := range []string{"docker run --rm", "devcell-nix-store:/nix", "sh -c"} { + if !strings.Contains(got, want) { + t.Errorf("DebugArgv missing %q, got: %s", want, got) + } + } + if !strings.Contains(got, "script:") { + t.Errorf("DebugArgv should mark the elided script, got: %s", got) + } +} + +// Single-line argvs pass through untouched. +func TestDebugArgv_KeepsSingleLineArgs(t *testing.T) { + got := runner.DebugArgv([]string{"docker", "volume", "inspect", "devcell-nix-store"}) + if got != "docker volume inspect devcell-nix-store" { + t.Errorf("unexpected rendering: %s", got) + } +} diff --git a/internal/runner/platform_preflight.go b/internal/runner/platform_preflight.go index 5eac28f..715caae 100644 --- a/internal/runner/platform_preflight.go +++ b/internal/runner/platform_preflight.go @@ -38,13 +38,25 @@ func PreflightPlatformCheckWithLookPath(ctx context.Context, nixhomeFlakeRef, ta cmd.Stderr = &stderr if err := cmd.Run(); err != nil { - errLines := extractNixErrors(stderr.String()) + stderrStr := stderr.String() + if isAttributeMissing(stderrStr) { + return nil + } + errLines := extractNixErrors(stderrStr) return fmt.Errorf("platform compatibility check failed for %s:\n%s\n\nFix: move the incompatible package behind a platform guard (lib.optionals pkgs.stdenv.isLinux/isDarwin) in its nixhome module", targetSystem, errLines) } return nil } +// isAttributeMissing detects nix errors indicating the queried attribute +// doesn't exist in the flake (e.g. platformStrictCheck has no key for +// the target system). Nix says "does not provide attribute" when the +// flake output path is valid but the specific key is absent. +func isAttributeMissing(stderr string) bool { + return strings.Contains(stderr, "does not provide attribute") +} + func extractNixErrors(stderr string) string { var lines []string for _, line := range strings.Split(stderr, "\n") { diff --git a/internal/runner/platform_preflight_test.go b/internal/runner/platform_preflight_test.go index 039616b..29c2b37 100644 --- a/internal/runner/platform_preflight_test.go +++ b/internal/runner/platform_preflight_test.go @@ -46,6 +46,25 @@ func TestPreflightPlatformCheck_NixFailure_ReturnsActionableError(t *testing.T) } } +func TestPreflightPlatformCheck_MissingAttribute_SkipsGracefully(t *testing.T) { + _, lookErr := lookPathNix() + if lookErr != nil { + t.Skip("nix not in PATH") + } + + // Use a real flake ref but a system key that doesn't exist. + // The nixhome flake has platformStrictCheck.{x86_64,aarch64}-linux + // but not "mips64el-linux". + err := runner.PreflightPlatformCheck( + context.Background(), + "path:../../nixhome", + "mips64el-linux", + ) + if err != nil { + t.Errorf("should skip when attribute is missing, got: %v", err) + } +} + func lookPathNix() (string, error) { return runner.LookPathNix() } diff --git a/internal/runner/preflight_test.go b/internal/runner/preflight_test.go index 3546610..e1352cf 100644 --- a/internal/runner/preflight_test.go +++ b/internal/runner/preflight_test.go @@ -50,9 +50,9 @@ func TestPreflightNixBuilder_DarwinNoBuilder_ReturnsActionableError(t *testing.T t.Fatal("failed probe should return error") } for _, want := range []string{ - "linux-builder", // tells user what's missing - "DEVCELL_PURE_SKIP", // tells user how to bypass - "ultimate", // mentions the stack + "linux-builder", // tells user what's missing + "DEVCELL_PURE_SKIP", // tells user how to bypass + "ultimate", // mentions the stack } { if !strings.Contains(err.Error(), want) { t.Errorf("error should mention %q so user sees the fix path; got: %s", want, err.Error()) diff --git a/internal/runner/promptfile.go b/internal/runner/promptfile.go new file mode 100644 index 0000000..1e0ff7a --- /dev/null +++ b/internal/runner/promptfile.go @@ -0,0 +1,90 @@ +package runner + +import ( + "fmt" + "os" + "path" + "path/filepath" + + "github.com/DimmKirr/devcell/internal/cfg" + "github.com/DimmKirr/devcell/internal/config" +) + +// promptDirName is the project-relative directory holding generated prompt +// files. It sits under .devcell/ because that tree is gitignored build +// output — these files are transport, regenerated on every launch, never +// hand-edited. +const promptDirName = "prompts" + +// OverlayPromptFile is the generated file carrying container context plus the +// resolved operator prompt — everything that layers *on top of* whichever base +// prompt is in effect. +const OverlayPromptFile = "additional-systemprompt.md" + +// BasePromptFile is the generated file carrying the base prompt — the one +// that REPLACES Claude Code's built-in prompt. +const BasePromptFile = "system-prompt.md" + +// WriteOverlayPrompt assembles the overlay and materializes it, returning the +// container path for --append-system-prompt-file. +// +// Container context is always present, so this file is always written even +// when no append prompt is configured. +func WriteOverlayPrompt(c config.Config, cellCfg cfg.CellConfig, opts ResolveOpts) (string, error) { + content, err := AssembleOverlayPrompt(c, cellCfg, opts) + if err != nil { + return "", err + } + return WritePromptFile(c, OverlayPromptFile, content) +} + +// WriteBasePrompt materializes the base prompt, returning the container path +// for --system-prompt-file — or "" when no base is configured. +// +// The empty return is the switch that keeps this opt-in: with no base set, +// the caller emits no flag and Claude Code's stock prompt stays in effect. +// The content is written verbatim; container context belongs on the overlay. +func WriteBasePrompt(c config.Config, opts ResolveOpts) (string, error) { + content, err := ResolveSystemPrompt(opts) + if err != nil { + return "", err + } + if content == "" { + return "", nil + } + return WritePromptFile(c, BasePromptFile, content) +} + +// WritePromptFile materializes prompt content to disk and returns the path +// the *container* will read it at. +// +// Prompts used to travel as a single argv element. That capped them at +// MAX_ARG_STRLEN (128 KiB on Linux) and published their full text to +// `ps aux` and `docker inspect`. Writing a file and passing its path +// removes both limits — claude reads it via --system-prompt-file / +// --append-system-prompt-file. +// +// Files are namespaced per cell. .devcell/ is per project, but a container is +// per (cell, project) pair and ContainerContext embeds the cell name, so a +// shared path would let one cell boot with another cell's container context. +// +// The returned path is the container-side path: the project is bind-mounted +// at /, so translating is a prefix swap of BaseDir for that root. +func WritePromptFile(c config.Config, name, content string) (string, error) { + cell := c.CellName + if cell == "" { + cell = "main" + } + + hostDir := filepath.Join(c.BaseDir, ".devcell", promptDirName, cell) + if err := os.MkdirAll(hostDir, 0o755); err != nil { + return "", fmt.Errorf("create prompt dir %s: %w", hostDir, err) + } + + hostPath := filepath.Join(hostDir, name) + if err := os.WriteFile(hostPath, []byte(content), 0o644); err != nil { + return "", fmt.Errorf("write prompt file %s: %w", hostPath, err) + } + + return path.Join("/"+c.AppName, ".devcell", promptDirName, cell, name), nil +} diff --git a/internal/runner/promptfile_test.go b/internal/runner/promptfile_test.go new file mode 100644 index 0000000..54a35fc --- /dev/null +++ b/internal/runner/promptfile_test.go @@ -0,0 +1,189 @@ +package runner + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DimmKirr/devcell/internal/cfg" + "github.com/DimmKirr/devcell/internal/config" +) + +// promptFileConfig mirrors sampleConfig() but points BaseDir at a temp dir so +// the writer has somewhere real to land, and carries a CellName so the +// per-cell namespacing is exercised. +func promptFileConfig(t *testing.T, cellName string) config.Config { + t.Helper() + return config.Config{ + AppName: "devcell-85", + BaseDir: t.TempDir(), + CellName: cellName, + HostUser: "dmitry", + HostHome: "/Users/dmitry", + } +} + +func TestWritePromptFile_WritesContentToHostPath(t *testing.T) { + c := promptFileConfig(t, "main") + + if _, err := WritePromptFile(c, "additional-systemprompt.md", "hello prompt\n"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + hostPath := filepath.Join(c.BaseDir, ".devcell", "prompts", "main", "additional-systemprompt.md") + got, err := os.ReadFile(hostPath) + if err != nil { + t.Fatalf("expected file at %s: %v", hostPath, err) + } + if string(got) != "hello prompt\n" { + t.Errorf("content mismatch:\n got %q\nwant %q", got, "hello prompt\n") + } +} + +func TestWritePromptFile_ReturnsContainerPathNotHostPath(t *testing.T) { + c := promptFileConfig(t, "main") + + got, err := WritePromptFile(c, "additional-systemprompt.md", "x") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + want := "/devcell-85/.devcell/prompts/main/additional-systemprompt.md" + if got != want { + t.Errorf("container path:\n got %q\nwant %q", got, want) + } + // The host BaseDir must not leak into the path handed to the container. + if filepath.IsAbs(c.BaseDir) && got != want { + t.Errorf("returned path still carries the host base dir %q", c.BaseDir) + } +} + +// Two cells open on the same project must not clobber each other's file: +// ContainerContext embeds the cell name, so a shared path would let one cell +// boot with another's container context. +func TestWritePromptFile_NamespacedPerCell(t *testing.T) { + base := t.TempDir() + mk := func(cell string) config.Config { + return config.Config{AppName: "devcell-85", BaseDir: base, CellName: cell} + } + + pathA, err := WritePromptFile(mk("alpha"), "additional-systemprompt.md", "content-alpha") + if err != nil { + t.Fatalf("alpha: %v", err) + } + pathB, err := WritePromptFile(mk("beta"), "additional-systemprompt.md", "content-beta") + if err != nil { + t.Fatalf("beta: %v", err) + } + + if pathA == pathB { + t.Fatalf("both cells resolved to the same container path %q", pathA) + } + + for _, tc := range []struct{ cell, want string }{ + {"alpha", "content-alpha"}, + {"beta", "content-beta"}, + } { + got, err := os.ReadFile(filepath.Join(base, ".devcell", "prompts", tc.cell, "additional-systemprompt.md")) + if err != nil { + t.Fatalf("%s: %v", tc.cell, err) + } + if string(got) != tc.want { + t.Errorf("%s clobbered: got %q want %q", tc.cell, got, tc.want) + } + } +} + +// Re-running a cell must overwrite, not append or fail. +func TestWritePromptFile_OverwritesExisting(t *testing.T) { + c := promptFileConfig(t, "main") + + if _, err := WritePromptFile(c, "additional-systemprompt.md", "first run"); err != nil { + t.Fatalf("first write: %v", err) + } + if _, err := WritePromptFile(c, "additional-systemprompt.md", "second"); err != nil { + t.Fatalf("second write: %v", err) + } + + got, err := os.ReadFile(filepath.Join(c.BaseDir, ".devcell", "prompts", "main", "additional-systemprompt.md")) + if err != nil { + t.Fatalf("read: %v", err) + } + if string(got) != "second" { + t.Errorf("expected overwrite, got %q", got) + } +} + +// An unset cell name must still produce a usable path rather than a directory +// with an empty segment. +func TestWritePromptFile_EmptyCellNameFallsBack(t *testing.T) { + c := promptFileConfig(t, "") + + got, err := WritePromptFile(c, "additional-systemprompt.md", "x") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := "/devcell-85/.devcell/prompts/main/additional-systemprompt.md" + if got != want { + t.Errorf("empty cell name should fall back to %q, got %q", want, got) + } +} + +// WriteOverlayPrompt is the seam both surfaces use: assemble container +// context + resolved prompt, materialize it, hand back the container path. +func TestWriteOverlayPrompt_WritesAssembledContent(t *testing.T) { + c := promptFileConfig(t, "main") + + got, err := WriteOverlayPrompt(c, cfg.CellConfig{}, ResolveOpts{AppendFlagInline: "be terse"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "/devcell-85/.devcell/prompts/main/additional-systemprompt.md" { + t.Errorf("container path = %q", got) + } + + body, err := os.ReadFile(filepath.Join(c.BaseDir, ".devcell", "prompts", "main", "additional-systemprompt.md")) + if err != nil { + t.Fatalf("read: %v", err) + } + content := string(body) + if !strings.Contains(content, "Docker container") { + t.Error("overlay file missing container context") + } + if !strings.Contains(content, "be terse") { + t.Error("overlay file missing resolved prompt") + } + if strings.Index(content, "be terse") <= strings.Index(content, "Docker container") { + t.Error("container context must come before the resolved prompt") + } +} + +// With nothing configured the overlay still exists — container context is +// always present — so the flag is always emitted. +func TestWriteOverlayPrompt_ContextOnlyWhenNothingConfigured(t *testing.T) { + c := promptFileConfig(t, "main") + + if _, err := WriteOverlayPrompt(c, cfg.CellConfig{}, ResolveOpts{}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + body, err := os.ReadFile(filepath.Join(c.BaseDir, ".devcell", "prompts", "main", "additional-systemprompt.md")) + if err != nil { + t.Fatalf("read: %v", err) + } + if !strings.Contains(string(body), "Docker container") { + t.Error("overlay file missing container context") + } +} + +func TestWriteOverlayPrompt_ResolverErrorPropagates(t *testing.T) { + c := promptFileConfig(t, "main") + + _, err := WriteOverlayPrompt(c, cfg.CellConfig{}, ResolveOpts{ + AppendFlagInline: "a", + AppendFlagFile: "/nonexistent/b.md", + }) + if err == nil { + t.Fatal("expected mutually-exclusive resolver error to propagate") + } +} diff --git a/internal/runner/prune.go b/internal/runner/prune.go index 4d33b03..d8351bb 100644 --- a/internal/runner/prune.go +++ b/internal/runner/prune.go @@ -7,6 +7,94 @@ import ( "strings" ) +// SafeNixGCScript is the shell script that performs project-aware nix +// garbage collection. It removes only orphaned profile generations that +// aren't protected by project-scoped GC roots, then runs nix-store --gc. +// +// CELL-334: also reads *-meta files to identify and clean up stale roots +// (roots whose metadata exists but whose profile target is dangling). +// Reports stale root count for drift visibility. +// +// Exported for test assertions. +const SafeNixGCScript = `set -e +PROTECTED="" +STALE=0 +if [ -d /nix/var/nix/gcroots/devcell ]; then + for root in /nix/var/nix/gcroots/devcell/*-profile /nix/var/nix/gcroots/devcell/*-generation; do + [ -L "$root" ] || continue + target=$(readlink "$root") + if [ -d "$target" ]; then + PROTECTED="$PROTECTED $target" + else + hash=$(basename "$root" | cut -d- -f1) + echo "stale root: $root -> $target (removing)" + rm -f "/nix/var/nix/gcroots/devcell/${hash}-profile" + rm -f "/nix/var/nix/gcroots/devcell/${hash}-generation" + rm -f "/nix/var/nix/gcroots/devcell/${hash}-meta" + STALE=$((STALE + 1)) + fi + done + for meta in /nix/var/nix/gcroots/devcell/*-meta; do + [ -f "$meta" ] || continue + hash=$(basename "$meta" | cut -d- -f1) + if [ ! -L "/nix/var/nix/gcroots/devcell/${hash}-profile" ]; then + echo "orphaned metadata: $meta (removing)" + rm -f "$meta" + fi + done +fi +if [ -z "$PROTECTED" ]; then + echo "No project GC roots found in /nix/var/nix/gcroots/devcell/ — skipping safe prune (use --force for blanket cleanup)" + exit 0 +fi +if [ "$STALE" -gt 0 ]; then + echo "Cleaned $STALE stale root(s)" +fi +REMOVED=0 +for gen in /nix/var/nix/profiles/per-user/root/profile-*-link; do + [ -L "$gen" ] || continue + target=$(readlink "$gen") + if ! echo "$PROTECTED" | grep -qF "$target"; then + rm -v "$gen" && REMOVED=$((REMOVED + 1)) + fi +done +echo "Removed $REMOVED orphaned profile generations" +nix-store --gc` + +// NixGCRootReportScript prints the current GC root state: how many roots, +// how many unique hashes, and details from each -meta file. Used by the +// prune preflight to give drift visibility before destructive operations. +// Exported for test assertions. +const NixGCRootReportScript = `set -e +echo "=== Nix GC Root Report ===" +ROOT_COUNT=0 +HASHES="" +for root in /nix/var/nix/gcroots/devcell/*-profile; do + [ -L "$root" ] || continue + ROOT_COUNT=$((ROOT_COUNT + 1)) + h=$(basename "$root" | cut -d- -f1) + case " $HASHES " in + *" $h "*) ;; + *) HASHES="$HASHES $h" ;; + esac +done +UNIQUE=$(echo "$HASHES" | wc -w) +echo "Roots: $ROOT_COUNT (${UNIQUE} unique profile hash(es))" +for meta in /nix/var/nix/gcroots/devcell/*-meta; do + [ -f "$meta" ] || continue + h=$(basename "$meta" | cut -d- -f1) + proj=$(grep '^project=' "$meta" 2>/dev/null | cut -d= -f2) + stack=$(grep '^stack=' "$meta" 2>/dev/null | cut -d= -f2) + stamped=$(grep '^stamped=' "$meta" 2>/dev/null | cut -d= -f2) + echo " $h: project=$proj stack=$stack stamped=$stamped" +done +if [ "$UNIQUE" -gt 1 ]; then + echo "" + echo "WARNING: config drift detected — $UNIQUE different profile hashes" + echo " Different hashes mean different nixpkgs revisions anchored on disk." + echo " Rebuild all cells with the same flake.lock to converge and reclaim space." +fi` + // `cell build prune` cleanup planner. // // Pure builders compose the ordered plan of commands for each prune mode. @@ -46,6 +134,12 @@ type PruneOpts struct { // LinuxBuilderHost is the SSH target for the macOS linux-builder VM. // Default: "builder@linux-builder". LinuxBuilderHost string + + // LiveClosures are the running containers' resolved store paths + // (CollectLiveClosures). When set, the pure prune plan stamps GC roots + // for them before the safe GC step, so "running implies rooted" holds + // by construction instead of being assumed (CELL-334 preflight gate). + LiveClosures []LiveClosure } // PruneStep is one command in a prune plan. @@ -125,17 +219,40 @@ func BuildNixPruneSteps(opts PruneOpts) []PruneStep { } if !opts.Force { - if opts.GOOS == "darwin" { - return []PruneStep{ - {Argv: []string{"sudo", "ssh", host, "nix-collect-garbage -d && nix-store --optimise"}}, - registryCleanup, - } + // Default --pure targets the devcell-nix-store volume on every OS + // (CELL-333): show root report (drift detection, CELL-334), then run + // safe project-aware GC inside a container with the nix volume + // mounted. On macOS the thin-mode store lives in this Docker volume, + // not the linux-builder VM — GC-ing the VM never reclaims it. + steps := []PruneStep{ + {Argv: []string{ + "docker", "run", "--rm", + "-v", DefaultThinStoreVolume + ":/nix", + NixCoreImage, + "sh", "-c", NixGCRootReportScript, + }, IgnoreError: true}, } - return []PruneStep{ - {Argv: []string{"sudo", "nix-collect-garbage", "-d"}}, - {Argv: []string{"sudo", "nix-store", "--optimise"}}, - registryCleanup, + // Preflight gate (CELL-334): stamp roots for every running + // container before GC, so a running-but-unrooted cell cannot lose + // its closure to the sweep below. + if stamp := StampRootsScript(opts.LiveClosures); stamp != "" { + steps = append(steps, PruneStep{Argv: []string{ + "docker", "run", "--rm", + "-v", DefaultThinStoreVolume + ":/nix", + NixCoreImage, + "sh", "-c", stamp, + }}) } + steps = append(steps, + PruneStep{Argv: []string{ + "docker", "run", "--rm", + "-v", DefaultThinStoreVolume + ":/nix", + NixCoreImage, + "sh", "-c", SafeNixGCScript, + }}, + registryCleanup, + ) + return steps } // Force mode. if opts.GOOS == "darwin" { @@ -306,20 +423,13 @@ func BuildPrunePrompt(opts PruneOpts) string { ) } - // Nix path. + // Nix path. Default --pure targets the devcell-nix-store volume on + // every OS (CELL-333). if !opts.Force { - if opts.GOOS == "darwin" { - return fmt.Sprintf( - "⚠ This will delete ALL unreferenced /nix/store paths and all but the\n"+ - " current profile generation.\n"+ - " Target: ssh://%s (via sudo — needs root to read /etc/nix/builder_ed25519)\n"+ - tail, - host, - ) - } - return "⚠ This will delete ALL unreferenced /nix/store paths and all but the\n" + - " current profile generation.\n" + - " Target: local /nix/store\n" + + return "⚠ This will remove orphaned profile generations not protected by\n" + + " project GC roots in /nix/var/nix/gcroots/devcell/, then GC\n" + + " unreferenced /nix/store paths. Use --force for blanket cleanup.\n" + + " Target: " + DefaultThinStoreVolume + " Docker volume (/nix)\n" + tail } // Nix force. diff --git a/internal/runner/prune_test.go b/internal/runner/prune_test.go index f2f7297..4a25871 100644 --- a/internal/runner/prune_test.go +++ b/internal/runner/prune_test.go @@ -188,67 +188,16 @@ func TestBuildDockerPruneSteps_ForceOnLinux_Rootless(t *testing.T) { } } -// `cell build prune --pure` runs nix garbage collection. On macOS, the target -// is the linux-builder VM via `sudo ssh` — the SSH private key lives at -// `/etc/nix/builder_ed25519` (root-only, mode 0600), so unprivileged ssh -// can't load it and hangs at the password prompt. The outer sudo gives ssh -// root, so it can read the key the nix daemon uses for builds. -func TestBuildNixPruneSteps_Default_DarwinUsesSudoSSHToLinuxBuilder(t *testing.T) { - opts := runner.PruneOpts{ - GOOS: "darwin", - Pure: true, - LinuxBuilderHost: "builder@linux-builder", - } - steps := runner.BuildNixPruneSteps(opts) - - if len(steps) == 0 { - t.Fatalf("want at least 1 step, got 0") - } - - // Every non-cleanup step must be `sudo ssh ''`. - for i, s := range steps { - if s.IgnoreError { - continue // registry cleanup step - } - if len(s.Argv) < 2 || s.Argv[0] != "sudo" || s.Argv[1] != "ssh" { - t.Errorf("step %d not wrapped in `sudo ssh`: %v", i+1, s.Argv) - continue - } - if !contains(s.Argv, "builder@linux-builder") { - t.Errorf("step %d missing ssh host `builder@linux-builder`: %v", i+1, s.Argv) - } - // The remote command (last argv element) must NOT re-invoke sudo. - // We're already root locally; on the builder, the `builder` user is - // in nix's trusted-users and the daemon owns /nix/store, so plain - // nix-collect-garbage / nix-store --optimise suffice. - remote := s.Argv[len(s.Argv)-1] - if strings.Contains(remote, "sudo") { - t.Errorf("step %d remote command should not invoke sudo on the builder VM: %q", i+1, remote) - } - } - - // At least one step must run `nix-collect-garbage -d` and one - // `nix-store --optimise` remotely. - gotGC := false - gotOptimise := false - for _, s := range steps { - joined := strings.Join(s.Argv, " ") - if strings.Contains(joined, "nix-collect-garbage -d") { - gotGC = true - } - if strings.Contains(joined, "nix-store --optimise") { - gotOptimise = true - } - } - if !gotGC { - t.Errorf("nix-collect-garbage -d not found in any step: %+v", steps) - } - if !gotOptimise { - t.Errorf("nix-store --optimise not found in any step: %+v", steps) - } -} - -func TestBuildNixPruneSteps_Default_LinuxRunsLocally(t *testing.T) { +// `cell build prune --pure` on Linux (default, no --force) runs safe +// project-aware GC: remove only orphaned profile generations that aren't +// protected by project-scoped GC roots under /nix/var/nix/gcroots/devcell/, +// then `nix-store --gc`. This is safe for shared Docker volumes where +// multiple containers use the same /nix store. See CELL-320. +// +// Blanket `nix-collect-garbage -d` is unsafe in this context because it +// deletes all non-current generations, including home-manager-files +// derivations that other containers' dotfiles symlink into. +func TestBuildNixPruneSteps_Default_LinuxSafeGC(t *testing.T) { opts := runner.PruneOpts{ GOOS: "linux", Pure: true, @@ -266,23 +215,36 @@ func TestBuildNixPruneSteps_Default_LinuxRunsLocally(t *testing.T) { } } - // Must run nix-collect-garbage -d and nix-store --optimise locally. - gotGC := false - gotOptimise := false + // The safe GC step runs via docker run with the nix volume (CELL-333). + var script string for _, s := range steps { joined := strings.Join(s.Argv, " ") - if strings.Contains(joined, "nix-collect-garbage") && strings.Contains(joined, "-d") { - gotGC = true - } - if strings.Contains(joined, "nix-store") && strings.Contains(joined, "--optimise") { - gotOptimise = true + if strings.Contains(joined, "docker") && strings.Contains(joined, "run") { + for i, a := range s.Argv { + if a == "-c" && i+1 < len(s.Argv) { + script = s.Argv[i+1] + } + } } } - if !gotGC { - t.Errorf("local nix-collect-garbage -d not found: %+v", steps) + if script == "" { + t.Fatalf("no docker run step with sh -c found: %+v", steps) + } + + // Script must reference project GC roots and use safe nix-store --gc. + mustContain := []string{ + "gcroots/devcell", + "nix-store --gc", + } + for _, want := range mustContain { + if !strings.Contains(script, want) { + t.Errorf("safe GC script missing %q", want) + } } - if !gotOptimise { - t.Errorf("local nix-store --optimise not found: %+v", steps) + + // Script must NOT use blanket nix-collect-garbage -d — that's force mode. + if strings.Contains(script, "nix-collect-garbage") { + t.Errorf("safe GC script must not use nix-collect-garbage (use --force for blanket cleanup)") } } @@ -474,15 +436,14 @@ func TestBuildPrunePrompt_AllModesContainWarningAndTarget(t *testing.T) { }, }, { + // CELL-333: darwin default prunes the devcell-nix-store volume, + // same as linux — no ssh, no sudo, no linux-builder mention. name: "nix default darwin", opts: runner.PruneOpts{GOOS: "darwin", Pure: true}, mustHave: []string{ - "This will delete ALL", - "/nix/store", - "linux-builder", - // User must see that a sudo password prompt is incoming — - // unprivileged ssh can't read /etc/nix/builder_ed25519. - "sudo", + "orphaned profile generations", + "project GC roots", + "devcell-nix-store", "Continue? [y/N]", }, }, @@ -490,8 +451,8 @@ func TestBuildPrunePrompt_AllModesContainWarningAndTarget(t *testing.T) { name: "nix default linux", opts: runner.PruneOpts{GOOS: "linux", Pure: true}, mustHave: []string{ - "This will delete ALL", - "/nix/store", + "orphaned profile generations", + "project GC roots", "Continue? [y/N]", }, }, @@ -736,6 +697,155 @@ func TestRunPrune_NonIgnoredErrorAborts(t *testing.T) { } } +// CELL-334: SafeNixGCScript should report stale roots (roots with metadata +// files that have no matching running container). This enables drift detection. +func TestSafeNixGCScript_ReportsStaleRoots(t *testing.T) { + if !strings.Contains(runner.SafeNixGCScript, "-meta") { + t.Error("SafeNixGCScript must read *-meta files to identify stale roots for cleanup (CELL-334)") + } +} + +// CELL-334: SafeNixGCScript must clean up stale roots (roots whose -meta +// file shows a project that no longer has a running container). +func TestSafeNixGCScript_CleansStaleRoots(t *testing.T) { + if !strings.Contains(runner.SafeNixGCScript, "STALE") { + t.Error("SafeNixGCScript must track and report stale root count (CELL-334)") + } +} + +// CELL-334: NixGCRootReportScript must report drift when multiple unique +// hashes exist. +func TestNixGCRootReportScript_ContainsDriftWarning(t *testing.T) { + if !strings.Contains(runner.NixGCRootReportScript, "drift") { + t.Error("NixGCRootReportScript must contain drift detection logic") + } + if !strings.Contains(runner.NixGCRootReportScript, "-meta") { + t.Error("NixGCRootReportScript must read -meta files for root attribution") + } +} + +// CELL-334: Linux default nix prune plan must include a root report step +// before the GC step. +func TestBuildNixPruneSteps_Default_LinuxIncludesReportStep(t *testing.T) { + opts := runner.PruneOpts{ + GOOS: "linux", + Pure: true, + } + steps := runner.BuildNixPruneSteps(opts) + + var hasReport bool + for _, s := range steps { + joined := strings.Join(s.Argv, " ") + if strings.Contains(joined, "GC Root Report") { + hasReport = true + } + } + if !hasReport { + t.Error("Linux nix prune plan must include a GC root report step (CELL-334)") + } +} + +// CELL-333: safe nix GC on Linux must run inside a container with the nix +// volume mounted, not via `sudo sh -c` on the host. The host doesn't have +// /nix or the GC roots — the script would either fail or operate in the +// wrong namespace. +func TestBuildNixPruneSteps_Default_LinuxRunsInContainer(t *testing.T) { + opts := runner.PruneOpts{ + GOOS: "linux", + Pure: true, + } + steps := runner.BuildNixPruneSteps(opts) + + // Must NOT use `sudo sh -c` for the safe GC step. + for _, s := range steps { + if len(s.Argv) >= 3 && s.Argv[0] == "sudo" && s.Argv[1] == "sh" && s.Argv[2] == "-c" { + t.Error("safe GC on Linux must NOT use `sudo sh -c` — " + + "runs in wrong mount namespace (CELL-333)") + } + } + + // Must use `docker run` with the nix volume mounted. + var found bool + for _, s := range steps { + joined := strings.Join(s.Argv, " ") + if strings.Contains(joined, "docker") && strings.Contains(joined, "run") && + strings.Contains(joined, "devcell-nix-store:/nix") { + found = true + } + } + if !found { + t.Error("safe GC step must run via `docker run` with devcell-nix-store:/nix volume") + } +} + +// CELL-330: SafeNixGCScript must NOT touch gcroots/auto/ — those symlinks +// point into per-container paths (/opt/devcell, /tmp/...) that are valid +// inside the originating container but dangle from the host or any other +// container. Deleting "broken" auto roots from the wrong namespace reaps +// live containers' indirect roots. +func TestSafeNixGCScript_DoesNotTouchAutoRoots(t *testing.T) { + if strings.Contains(runner.SafeNixGCScript, "gcroots/auto") { + t.Error("SafeNixGCScript must not reference gcroots/auto/ — " + + "auto roots are namespace-local and deleting them from " + + "a different container is destructive (CELL-330)") + } +} + +// CELL-333: on macOS the thin-mode nix store lives in the devcell-nix-store +// Docker volume, not in the linux-builder VM. The default --pure prune must +// target that volume via a throwaway container (where /nix and the devcell +// GC roots resolve correctly), exactly like the Linux path. GC-ing the +// linux-builder VM never reclaims the store that actually grows. +func TestBuildNixPruneSteps_Default_DarwinRunsInContainerOnNixVolume(t *testing.T) { + opts := runner.PruneOpts{ + GOOS: "darwin", + Pure: true, + } + steps := runner.BuildNixPruneSteps(opts) + + if len(steps) == 0 { + t.Fatalf("want at least 1 step, got 0") + } + + // No ssh, no sudo — the volume is reachable through the local docker + // daemon, and -u 0 inside the container covers root-only operations. + for i, s := range steps { + joined := strings.Join(s.Argv, " ") + if strings.Contains(joined, "ssh") { + t.Errorf("step %d must not ssh to linux-builder (CELL-333): %v", i+1, s.Argv) + } + if len(s.Argv) > 0 && s.Argv[0] == "sudo" { + t.Errorf("step %d must not require sudo on the host: %v", i+1, s.Argv) + } + } + + // The safe GC script must run via docker run with the nix volume mounted. + var script string + sawVolume := false + for _, s := range steps { + joined := strings.Join(s.Argv, " ") + if strings.Contains(joined, "docker run") && + strings.Contains(joined, runner.DefaultThinStoreVolume+":/nix") { + sawVolume = true + for i, a := range s.Argv { + if a == "-c" && i+1 < len(s.Argv) { + script = s.Argv[i+1] + } + } + } + } + if !sawVolume { + t.Fatalf("no docker run step mounting %s:/nix found: %+v", + runner.DefaultThinStoreVolume, steps) + } + if !strings.Contains(script, "gcroots/devcell") || !strings.Contains(script, "nix-store --gc") { + t.Errorf("darwin safe GC must use the project-aware script (gcroots/devcell + nix-store --gc), got: %q", script) + } + if strings.Contains(script, "nix-collect-garbage") { + t.Errorf("darwin default prune must not blanket nix-collect-garbage (that's --force)") + } +} + func contains(haystack []string, needle string) bool { for _, s := range haystack { if s == needle { diff --git a/internal/runner/pure_build.go b/internal/runner/pure_build.go index 93e8168..ff7725a 100644 --- a/internal/runner/pure_build.go +++ b/internal/runner/pure_build.go @@ -28,7 +28,7 @@ import ( // PureBuildSpec describes a pure-image build invocation. type PureBuildSpec struct { // FlakeRef is the full flake reference to build against (e.g. - // "path:/abs/nixhome" or "github:DimmKirr/devcell/main?dir=nixhome"). + // "path:/abs/nixhome" or "github:devcell-sh/community-home/main"). // When set, takes precedence over NixhomePath — the per-stack output // suffix is appended directly. This is the seam that lets the pure // path fall back to a remote flake when no local nixhome exists, diff --git a/internal/runner/pure_nixhome_resolver.go b/internal/runner/pure_nixhome_resolver.go index 99bef35..3e9fd41 100644 --- a/internal/runner/pure_nixhome_resolver.go +++ b/internal/runner/pure_nixhome_resolver.go @@ -7,9 +7,8 @@ import ( // DefaultNixhomeGitRef is the github branch/tag used when no local nixhome is // available and the cell binary doesn't carry a release version (v0.0.0 / dev -// builds). Set to feature/wip while CELL-195 lives off main; flip back to -// "main" when the pure path lands. -const DefaultNixhomeGitRef = "feature/wip" +// builds). Points at community-home's default branch. +const DefaultNixhomeGitRef = "main" // PureNixhomeInputs is the input to ResolvePureNixhomeRef. Mirrors the data // flow already present for the docker path (scaffold.go:130-140), but emits a @@ -57,7 +56,7 @@ type PureNixhomeRef struct { // Precedence: // 1. inputs.TomlNixhome (explicit user setting via .devcell.toml / env) // 2. inputs.BaseDir + "/nixhome" on disk -// 3. github:DimmKirr/devcell/?dir=nixhome (Version coerced to +// 3. github:devcell-sh/community-home/ (Version coerced to // DefaultNixhomeGitRef when empty or "v0.0.0") // // Pure function — fs lookups go through inputs.StatFunc so tests don't diff --git a/internal/runner/pure_nixhome_resolver_test.go b/internal/runner/pure_nixhome_resolver_test.go index b98cbd9..d7c9fbb 100644 --- a/internal/runner/pure_nixhome_resolver_test.go +++ b/internal/runner/pure_nixhome_resolver_test.go @@ -16,7 +16,7 @@ import ( // Precedence: // 1. tomlNixhome (from [cell].nixhome or DEVCELL_NIXHOME_PATH env) // 2. /nixhome on disk -// 3. github:DimmKirr/devcell/?dir=nixhome (with v0.0.0 → main coercion) +// 3. github:devcell-sh/community-home/ (with v0.0.0 → main coercion) func TestResolvePureNixhomeRef_TomlNixhomeWins(t *testing.T) { got := runner.ResolvePureNixhomeRef(runner.PureNixhomeInputs{ @@ -64,7 +64,7 @@ func TestResolvePureNixhomeRef_NoLocal_UsesGithubFallback(t *testing.T) { Version: "v1.2.3", StatFunc: func(string) error { return os.ErrNotExist }, }) - want := "github:DimmKirr/devcell/v1.2.3?dir=nixhome" + want := "github:devcell-sh/community-home/v1.2.3" if got.FlakeRef != want { t.Errorf("github fallback → want %q, got %q", want, got.FlakeRef) } @@ -86,7 +86,7 @@ func TestResolvePureNixhomeRef_V000CoercesToDefaultRef(t *testing.T) { Version: "v0.0.0", StatFunc: func(string) error { return os.ErrNotExist }, }) - want := "github:DimmKirr/devcell/" + runner.DefaultNixhomeGitRef + "?dir=nixhome" + want := "github:devcell-sh/community-home/" + runner.DefaultNixhomeGitRef if got.FlakeRef != want { t.Errorf("v0.0.0 → want %q, got %q", want, got.FlakeRef) } @@ -98,7 +98,7 @@ func TestResolvePureNixhomeRef_EmptyVersionCoercesToDefaultRef(t *testing.T) { Version: "", StatFunc: func(string) error { return os.ErrNotExist }, }) - want := "github:DimmKirr/devcell/" + runner.DefaultNixhomeGitRef + "?dir=nixhome" + want := "github:devcell-sh/community-home/" + runner.DefaultNixhomeGitRef if got.FlakeRef != want { t.Errorf("empty version → want %q, got %q", want, got.FlakeRef) } @@ -106,9 +106,9 @@ func TestResolvePureNixhomeRef_EmptyVersionCoercesToDefaultRef(t *testing.T) { // Pins the default ref value so an accidental rename of the constant gets // caught by CI. Update this when the pure path lands on main. -func TestDefaultNixhomeGitRef_IsFeatureWip(t *testing.T) { - if runner.DefaultNixhomeGitRef != "feature/wip" { - t.Errorf("DefaultNixhomeGitRef = %q; want \"feature/wip\" (flip to \"main\" when CELL-195 lands)", +func TestDefaultNixhomeGitRef_IsMain(t *testing.T) { + if runner.DefaultNixhomeGitRef != "main" { + t.Errorf("DefaultNixhomeGitRef = %q; want \"main\" (community-home's default branch)", runner.DefaultNixhomeGitRef) } } diff --git a/internal/runner/runner.go b/internal/runner/runner.go index ddde8f4..1ea3fa7 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -217,12 +217,16 @@ type RunSpec struct { UserArgs []string Debug bool // pass DEVCELL_DEBUG=true into the container NixDaemon bool // pass DEVCELL_NIX_DAEMON=true into the container + SkipFlake bool // pass DEVCELL_SKIP_FLAKE=1 into the container + TrustFlake bool // pass DEVCELL_FLAKE_TRUST=1 into the container Image string // image ID or tag to run; defaults to UserImageTag ExtraEnv map[string]string // additional env vars injected by the command handler InheritEnv []string // env var names to inherit from host (passed as -e KEY with no value) Getenv func(string) string // env lookup; defaults to os.Getenv when nil ThinImage bool // when true, mount devcell-nix-store volume for /nix BootDir string // CELL-264: host-side boot dir for fsnotify sentinels; empty disables the bind-mount + TTY bool // allocate a pseudo-TTY (-it); set from isatty check on stdin + Detach bool // run container in detached mode (-d); set by `cell start` } func (s RunSpec) getenv(key string) string { @@ -244,11 +248,47 @@ func BuildArgv(spec RunSpec, fs FS, lookPath func(string) (string, error)) []str argv = append(argv, "op", "run", "--") } - dockerRunFlags := []string{"--rm", "-it", "--shm-size=1g", "--device=/dev/fuse"} - for _, cap := range spec.CellCfg.Cell.DockerCapAdd { + dockerRunFlags := []string{"--rm", "--shm-size=" + spec.CellCfg.Docker.ResolvedShmSize(), "--device=/dev/fuse"} + if mem := spec.CellCfg.Docker.ResolvedMemLimit(); mem != "0" { + dockerRunFlags = append(dockerRunFlags, "--memory="+mem) + } + if cpu := spec.CellCfg.Docker.ResolvedCPULimit(); cpu != "0" { + dockerRunFlags = append(dockerRunFlags, "--cpus="+cpu) + } + if spec.Detach { + dockerRunFlags = append(dockerRunFlags, "-d") + } else if spec.TTY { + dockerRunFlags = append(dockerRunFlags, "-it") + } + // KVM passthrough for QEMU guests (Windows cells). Must be --device, not + // a -v bind-mount: the mount creates the node but the cgroup device + // controller still denies open(2) (EPERM). Requires nested virtualization + // on the daemon host — on Colima that is `vmType: vz` + + // `nestedVirtualization: true`; without it docker run fails loudly. + if spec.CellCfg.Cell.ResolvedKVM() { + dockerRunFlags = append(dockerRunFlags, "--device=/dev/kvm") + } + for _, cap := range spec.CellCfg.Docker.CapAdd { dockerRunFlags = append(dockerRunFlags, "--cap-add="+cap) } - if spec.CellCfg.Cell.DockerPrivileged { + wgEnabled := cfg.WireguardEnabled(spec.CellCfg) + if wgEnabled && !spec.CellCfg.Docker.Privileged { + hasNetAdmin := false + for _, cap := range spec.CellCfg.Docker.CapAdd { + if cap == "NET_ADMIN" { + hasNetAdmin = true + break + } + } + if !hasNetAdmin { + dockerRunFlags = append(dockerRunFlags, "--cap-add=NET_ADMIN") + } + } + if wgEnabled { + dockerRunFlags = append(dockerRunFlags, "--device=/dev/net/tun") + dockerRunFlags = append(dockerRunFlags, "--sysctl", "net.ipv4.conf.all.src_valid_mark=1") + } + if spec.CellCfg.Docker.Privileged { dockerRunFlags = append(dockerRunFlags, "--privileged") } argv = append(argv, "docker", "run") @@ -267,6 +307,7 @@ func BuildArgv(spec RunSpec, fs FS, lookPath func(string) (string, error)) []str // Core env vars e := func(k, v string) { argv = append(argv, "-e", k+"="+v) } e("APP_NAME", c.AppName) + e("DEVCELL_CELL_NAME", c.CellName) e("HOST_USER", c.HostUser) e("HOME", "/home/"+c.HostUser) e("IS_SANDBOX", "1") @@ -326,8 +367,12 @@ func BuildArgv(spec RunSpec, fs FS, lookPath func(string) (string, error)) []str } // GUI flag — only publish VNC port when GUI is enabled (default: true) - if spec.CellCfg.Cell.ResolvedGUI() { + if spec.CellCfg.GUI.ResolvedEnabled() { argv = append(argv, "-e", "DEVCELL_GUI_ENABLED=true") + argv = append(argv, "-e", "DEVCELL_WM="+spec.CellCfg.GUI.ResolvedWM()) + argv = append(argv, "-e", "DEVCELL_RESOLUTION="+spec.CellCfg.GUI.ResolvedFramebufferResolution()) + argv = append(argv, "-e", fmt.Sprintf("DEVCELL_DPI=%d", spec.CellCfg.GUI.ResolvedDPI())) + argv = append(argv, "-e", fmt.Sprintf("DEVCELL_SCALE=%d", spec.CellCfg.GUI.ResolvedScale())) argv = append(argv, "-e", "EXT_VNC_PORT="+c.VNCPort) argv = append(argv, "-e", "EXT_RDP_PORT="+c.RDPPort) } @@ -342,6 +387,16 @@ func BuildArgv(spec RunSpec, fs FS, lookPath func(string) (string, error)) []str argv = append(argv, "-e", "DEVCELL_NIX_DAEMON=true") } + // Skip project flake — degrades install failure to warning instead of boot abort + if spec.SkipFlake { + argv = append(argv, "-e", "DEVCELL_SKIP_FLAKE=1") + } + + // Project flake trust — user confirmed host-side that flake.nix packages should be installed + if spec.TrustFlake { + argv = append(argv, "-e", "DEVCELL_FLAKE_TRUST=1") + } + // Pass the image tag/ID into the container for debug logging if spec.Debug && spec.Image != "" { argv = append(argv, "-e", "DEVCELL_IMAGE="+spec.Image) @@ -421,6 +476,8 @@ func BuildArgv(spec RunSpec, fs FS, lookPath func(string) (string, error)) []str v(c.HostHome + "/.claude/commands:/home/" + c.HostUser + "/.claude/commands") v(c.HostHome + "/.claude/agents:/home/" + c.HostUser + "/.claude/agents:ro") v(c.HostHome + "/.claude/skills:/home/" + c.HostUser + "/.claude/skills") + v(c.HostHome + "/.agents:/home/" + c.HostUser + "/.agents:ro") + v(c.HostHome + "/.claude/agents:/home/" + c.HostUser + "/.config/opencode/agents:ro") v(c.ConfigDir + ":/etc/devcell/config") v(c.ConfigDir + ":/home/" + c.HostUser + "/.config/devcell") @@ -439,9 +496,27 @@ func BuildArgv(spec RunSpec, fs FS, lookPath func(string) (string, error)) []str e("DEVCELL_BOOT_DIR", bootContainerPath) } - - // cfg [[volumes]] entries + // cfg [[volumes]] entries — skip any whose container path duplicates + // a standard mount (e.g. BaseDir identity mount) to avoid Docker's + // "Duplicate mount point" error. + stdMounts := map[string]bool{ + c.BaseDir: true, + "/" + c.AppName: true, + "/home/" + c.HostUser: true, + "/var/run/docker.sock": true, + "/home/" + c.HostUser + "/.claude/commands": true, + "/home/" + c.HostUser + "/.claude/agents": true, + "/home/" + c.HostUser + "/.claude/skills": true, + "/home/" + c.HostUser + "/.agents": true, + "/home/" + c.HostUser + "/.config/opencode/agents": true, + "/etc/devcell/config": true, + "/home/" + c.HostUser + "/.config/devcell": true, + } for _, vol := range spec.CellCfg.Volumes { + cp := vol.ContainerPath() + if stdMounts[cp] { + continue + } argv = append(argv, "-v", vol.Resolved()) } @@ -464,11 +539,18 @@ func BuildArgv(spec RunSpec, fs FS, lookPath func(string) (string, error)) []str } // GUI port mapping - if spec.CellCfg.Cell.ResolvedGUI() { + if spec.CellCfg.GUI.ResolvedEnabled() { argv = append(argv, "-p", publishPrefix+c.VNCPort+":5900") argv = append(argv, "-p", publishPrefix+c.RDPPort+":3389") } + // Wireguard env + config mount + if wgEnabled { + argv = append(argv, "-e", "DEVCELL_WG_ENABLED=1") + wgDir := filepath.Join(c.CellHome, ".wg") + argv = append(argv, "-v", wgDir+":/home/"+c.HostUser+"/.devcell/"+c.CellName+"/.wg:ro") + } + // In-memory secrets mount — Playwright MCP reads .secrets-playwright from here argv = append(argv, "--tmpfs", "/run/secrets:mode=700,noexec,nosuid,size=1m") @@ -520,6 +602,15 @@ func RemoveOrphanedContainer(ctx context.Context, name string) error { return nil } +// ContainerRunning checks if a container with the given name is currently running. +func ContainerRunning(ctx context.Context, name string) bool { + out, err := exec.CommandContext(ctx, "docker", "inspect", "--format", "{{.State.Status}}", name).Output() + if err != nil { + return false + } + return strings.TrimSpace(string(out)) == "running" +} + // EnsureNetwork creates the devcell-network docker network if it doesn't exist. func EnsureNetwork(ctx context.Context) error { cmd := exec.CommandContext(ctx, "docker", "network", "create", "devcell-network") @@ -575,8 +666,6 @@ func BuildImage(ctx context.Context, configDir string, noCache bool, verbose boo return nil } - - // DetectArch returns "aarch64" or "x86_64". Respects DEVCELL_ARCH env // override ("amd64"→"x86_64", "arm64"→"aarch64") for cross-architecture builds. func DetectArch() string { @@ -607,10 +696,36 @@ func ImageExists(ctx context.Context, tag string) bool { return exec.CommandContext(ctx, "docker", "image", "inspect", tag).Run() == nil } +// ImageExistsForPlatform returns true if a Docker image with the given tag +// exists locally AND matches the requested platform (e.g. "linux/amd64"). +// Empty platform falls back to ImageExists (host default). +func ImageExistsForPlatform(ctx context.Context, tag, platform string) bool { + if platform == "" { + return ImageExists(ctx, tag) + } + out, err := exec.CommandContext(ctx, "docker", "image", "inspect", + "--format", "{{.Os}}/{{.Architecture}}", tag).Output() + if err != nil { + return false + } + return strings.TrimSpace(string(out)) == platform +} + // PullImage attempts to pull a Docker image. Returns nil on success. // When verbose is true, docker pull output is streamed to os.Stderr. func PullImage(ctx context.Context, tag string, verbose bool) error { - cmd := exec.CommandContext(ctx, "docker", "pull", tag) + return PullImageForPlatform(ctx, tag, "", verbose) +} + +// PullImageForPlatform pulls a Docker image for a specific platform. +// Empty platform uses Docker's default (host architecture). +func PullImageForPlatform(ctx context.Context, tag, platform string, verbose bool) error { + args := []string{"pull"} + if platform != "" { + args = append(args, "--platform", platform) + } + args = append(args, tag) + cmd := exec.CommandContext(ctx, "docker", args...) if verbose { cmd.Stdout = os.Stderr cmd.Stderr = os.Stderr @@ -860,7 +975,7 @@ func ImageMetadataFromContainer(ctx context.Context) ImageMetadata { // output. Pure helper, no I/O. func imageMetadataFromInspect(created string, labels map[string]string, env []string) ImageMetadata { m := ImageMetadata{ - BaseImage: labels["devcell.built-with"], // "nix2container" / "" + BaseImage: labels["devcell.built-with"], // "nix2container" / "" Stack: labels["devcell.stack"], GitCommit: labels["org.opencontainers.image.revision"], BuildDate: labels["org.opencontainers.image.created"], @@ -1046,3 +1161,56 @@ func envOrDefaultFn(getenv func(string) string, key, def string) string { } return def } + +// PrepareWireguard writes WireGuard config files for each enabled entry +// to /.wg/.conf. PrivateKey lines are stripped from the +// config; a PostUp directive loads the key from /run/secrets/wg-private-key +// at runtime. No-op when no entries are enabled. +func PrepareWireguard(cellHome string, cellCfg cfg.CellConfig) error { + if !cfg.WireguardEnabled(cellCfg) { + return nil + } + wgDir := filepath.Join(cellHome, ".wg") + if err := os.MkdirAll(wgDir, 0700); err != nil { + return fmt.Errorf("create wireguard dir: %w", err) + } + for _, entry := range cellCfg.Wireguard { + if !entry.Enabled { + continue + } + conf := rewriteWireguardConfig(entry.Config) + path := filepath.Join(wgDir, entry.Name+".conf") + if err := os.WriteFile(path, []byte(conf), 0600); err != nil { + return fmt.Errorf("write %s: %w", path, err) + } + } + return nil +} + +// rewriteWireguardConfig strips PrivateKey from [Interface] and adds a +// PostUp directive that loads the key from /run/secrets/wg-private-key. +func rewriteWireguardConfig(raw string) string { + var out strings.Builder + inInterface := false + postUpAdded := false + for _, line := range strings.Split(raw, "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { + section := strings.TrimSpace(trimmed[1 : len(trimmed)-1]) + if inInterface && !postUpAdded { + out.WriteString("PostUp = wg set %i private-key /run/secrets/wg-private-key\n") + postUpAdded = true + } + inInterface = section == "Interface" + } + if inInterface && strings.HasPrefix(trimmed, "PrivateKey") { + continue + } + out.WriteString(line) + out.WriteByte('\n') + } + if inInterface && !postUpAdded { + out.WriteString("PostUp = wg set %i private-key /run/secrets/wg-private-key\n") + } + return strings.TrimRight(out.String(), "\n") + "\n" +} diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index 48fcc11..5b1a1eb 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -1,6 +1,7 @@ package runner_test import ( + "context" "os" "path/filepath" "strings" @@ -15,9 +16,9 @@ func baseConfig() config.Config { return config.Load("/home/bob/myproject", func(k string) string { m := map[string]string{ "DEVCELL_BUNK": "3", - "HOME": "/home/bob", - "USER": "bob", - "TERM": "xterm-256color", + "HOME": "/home/bob", + "USER": "bob", + "TERM": "xterm-256color", } return m[k] }) @@ -92,6 +93,89 @@ func findFlag(argv []string, flag string) (string, bool) { return "", false } +// --- Docker resource limits --- + +func TestArgv_DefaultResourceLimits(t *testing.T) { + t.Setenv("DEVCELL_DOCKER_MEM_LIMIT", "") + t.Setenv("DEVCELL_DOCKER_CPU_LIMIT", "") + t.Setenv("DEVCELL_DOCKER_SHM_SIZE", "") + argv := buildArgv(t) + if !hasArg(argv, "--memory=4g") { + t.Error("missing default --memory=4g") + } + if !hasArg(argv, "--cpus=2") { + t.Error("missing default --cpus=2") + } + if !hasArg(argv, "--shm-size=1g") { + t.Error("missing default --shm-size=1g") + } +} + +func TestArgv_ResourceLimitsFromTOML(t *testing.T) { + t.Setenv("DEVCELL_DOCKER_MEM_LIMIT", "") + t.Setenv("DEVCELL_DOCKER_CPU_LIMIT", "") + t.Setenv("DEVCELL_DOCKER_SHM_SIZE", "") + argv := buildArgv(t, func(s *runner.RunSpec) { + s.CellCfg.Docker = cfg.DockerSection{ + MemLimit: "16g", + CPULimit: "8", + ShmSize: "4g", + } + }) + if !hasArg(argv, "--memory=16g") { + t.Errorf("expected --memory=16g from TOML, argv: %v", argv) + } + if !hasArg(argv, "--cpus=8") { + t.Errorf("expected --cpus=8 from TOML, argv: %v", argv) + } + if !hasArg(argv, "--shm-size=4g") { + t.Errorf("expected --shm-size=4g from TOML, argv: %v", argv) + } +} + +func TestArgv_ResourceLimitsZeroOmitsFlag(t *testing.T) { + t.Setenv("DEVCELL_DOCKER_MEM_LIMIT", "") + t.Setenv("DEVCELL_DOCKER_CPU_LIMIT", "") + t.Setenv("DEVCELL_DOCKER_SHM_SIZE", "") + argv := buildArgv(t, func(s *runner.RunSpec) { + s.CellCfg.Docker = cfg.DockerSection{ + MemLimit: "0", + CPULimit: "0", + } + }) + for _, a := range argv { + if strings.HasPrefix(a, "--memory=") { + t.Errorf("--memory should be omitted when set to 0, got %q", a) + } + if strings.HasPrefix(a, "--cpus=") { + t.Errorf("--cpus should be omitted when set to 0, got %q", a) + } + } +} + +// --- Detach mode --- + +func TestArgv_DetachFlag(t *testing.T) { + argv := buildArgv(t, func(s *runner.RunSpec) { + s.Detach = true + }) + if !hasArg(argv, "-d") { + t.Error("detach mode should add -d") + } + if hasArg(argv, "-it") { + t.Error("detach mode should not add -it") + } +} + +func TestArgv_DetachKeepsRm(t *testing.T) { + argv := buildArgv(t, func(s *runner.RunSpec) { + s.Detach = true + }) + if !hasArg(argv, "--rm") { + t.Error("detach mode should keep --rm") + } +} + // --- Structure --- func TestArgv_StartsWithDockerRunFlags(t *testing.T) { @@ -102,8 +186,17 @@ func TestArgv_StartsWithDockerRunFlags(t *testing.T) { if !hasArg(argv, "--rm") { t.Error("missing --rm") } +} + +func TestArgv_TTY(t *testing.T) { + argv := buildArgv(t, func(s *runner.RunSpec) { s.TTY = true }) if !hasArg(argv, "-it") { - t.Error("missing -it") + t.Error("TTY=true should produce -it") + } + + argv = buildArgv(t) + if hasArg(argv, "-it") { + t.Error("TTY=false (default) should not produce -it") } } @@ -182,7 +275,7 @@ func TestArgv_HostnameEnvOverridesTOML(t *testing.T) { func TestArgv_MandatoryEnvVars(t *testing.T) { argv := buildArgv(t, func(s *runner.RunSpec) { - s.CellCfg.Cell.GUI = boolPtr(true) + s.CellCfg.GUI.Enabled = boolPtr(true) }) mustHaveEnv := []string{ "APP_NAME=myproject-3", @@ -199,6 +292,22 @@ func TestArgv_MandatoryEnvVars(t *testing.T) { } } +func TestArgv_CellNameEnvVar(t *testing.T) { + argv := buildArgv(t) + if !hasArg(argv, "DEVCELL_CELL_NAME=main") { + t.Errorf("missing -e DEVCELL_CELL_NAME=main in argv: %v", argv) + } +} + +func TestArgv_CellNameEnvVar_Explicit(t *testing.T) { + argv := buildArgv(t, func(s *runner.RunSpec) { + s.Config.CellName = "bunkhouse" + }) + if !hasArg(argv, "DEVCELL_CELL_NAME=bunkhouse") { + t.Errorf("missing -e DEVCELL_CELL_NAME=bunkhouse in argv: %v", argv) + } +} + func TestArgv_UserAndGroupAdd(t *testing.T) { argv := buildArgv(t) if !hasConsecutive(argv, "--user", "0") { @@ -349,6 +458,66 @@ func TestArgv_CfgVolumes_SinglePathShorthand(t *testing.T) { } } +func TestArgv_CfgVolumes_DedupAgainstBaseDir(t *testing.T) { + argv := buildArgv(t, func(s *runner.RunSpec) { + s.CellCfg.Volumes = []cfg.VolumeMount{ + {Mount: "/home/bob/myproject"}, + {Mount: "/other/path:/other/path"}, + } + }) + count := 0 + for i, a := range argv { + if a == "-v" && i+1 < len(argv) { + if strings.Contains(argv[i+1], "/home/bob/myproject:/home/bob/myproject") { + count++ + } + } + } + if count != 1 { + t.Errorf("BaseDir identity mount should appear exactly once, got %d", count) + } + if !hasConsecutive(argv, "-v", "/other/path:/other/path") { + t.Errorf("non-duplicate volume should still be present") + } +} + +func TestArgv_CfgVolumes_DedupTrailingSlash(t *testing.T) { + argv := buildArgv(t, func(s *runner.RunSpec) { + s.CellCfg.Volumes = []cfg.VolumeMount{ + {Mount: "/home/bob/myproject/"}, + } + }) + // Without dedup this would produce a third "-v /home/bob/myproject/:/home/bob/myproject/" + // that Docker rejects as "Duplicate mount point". + // The two standard mounts (identity + appname alias) should still be present. + withoutDedup := buildArgv(t, func(s *runner.RunSpec) {}) + count := 0 + for i, a := range argv { + if a == "-v" && i+1 < len(argv) && argv[i+1] == "/home/bob/myproject/:/home/bob/myproject/" { + count++ + } + } + if count != 0 { + t.Errorf("trailing-slash user volume should be suppressed, but found %d", count) + } + // Standard mounts must be unchanged + stdCount := 0 + for i, a := range argv { + if a == "-v" && i+1 < len(argv) && strings.HasPrefix(argv[i+1], "/home/bob/myproject:") { + stdCount++ + } + } + baselineStd := 0 + for i, a := range withoutDedup { + if a == "-v" && i+1 < len(withoutDedup) && strings.HasPrefix(withoutDedup[i+1], "/home/bob/myproject:") { + baselineStd++ + } + } + if stdCount != baselineStd { + t.Errorf("standard mounts changed: got %d, want %d", stdCount, baselineStd) + } +} + // --- cfg mise --- func TestArgv_MiseEnvVars(t *testing.T) { @@ -451,7 +620,7 @@ func TestArgv_CfgPortsMappedUDP(t *testing.T) { func TestArgv_CfgPortsEmpty(t *testing.T) { argv := buildArgv(t, func(s *runner.RunSpec) { - s.CellCfg.Cell.GUI = boolPtr(false) + s.CellCfg.GUI.Enabled = boolPtr(false) }) // No -p flags when no ports configured and GUI explicitly off for i, a := range argv { @@ -465,7 +634,7 @@ func TestArgv_CfgPortsEmpty(t *testing.T) { func TestArgv_VNCPort(t *testing.T) { argv := buildArgv(t, func(s *runner.RunSpec) { - s.CellCfg.Cell.GUI = boolPtr(true) + s.CellCfg.GUI.Enabled = boolPtr(true) }) if !hasConsecutive(argv, "-p", "0.0.0.0:350:5900") { t.Errorf("expected -p 0.0.0.0:350:5900 in argv: %v", argv) @@ -532,16 +701,18 @@ func TestArgv_UserArgsAppended(t *testing.T) { func boolPtr(b bool) *bool { return &b } func TestArgv_GUIEnabledByDefault(t *testing.T) { - // GUI defaults to true when not set (nil) argv := buildArgv(t) if !hasArg(argv, "DEVCELL_GUI_ENABLED=true") { t.Errorf("expected DEVCELL_GUI_ENABLED=true by default: %v", argv) } + if !hasArg(argv, "DEVCELL_WM=icewm") { + t.Errorf("expected DEVCELL_WM=icewm by default: %v", argv) + } } func TestArgv_GUIExplicitTrue(t *testing.T) { argv := buildArgv(t, func(s *runner.RunSpec) { - s.CellCfg.Cell.GUI = boolPtr(true) + s.CellCfg.GUI.Enabled = boolPtr(true) }) if !hasArg(argv, "DEVCELL_GUI_ENABLED=true") { t.Errorf("expected DEVCELL_GUI_ENABLED=true in argv: %v", argv) @@ -550,11 +721,25 @@ func TestArgv_GUIExplicitTrue(t *testing.T) { func TestArgv_GUIExplicitFalse(t *testing.T) { argv := buildArgv(t, func(s *runner.RunSpec) { - s.CellCfg.Cell.GUI = boolPtr(false) + s.CellCfg.GUI.Enabled = boolPtr(false) }) if hasArg(argv, "DEVCELL_GUI_ENABLED=true") { t.Error("DEVCELL_GUI_ENABLED should not be present when gui=false") } + for _, a := range argv { + if strings.HasPrefix(a, "DEVCELL_WM=") { + t.Errorf("DEVCELL_WM should not be present when gui=false, got %q", a) + } + } +} + +func TestArgv_GUIWMFluxbox(t *testing.T) { + argv := buildArgv(t, func(s *runner.RunSpec) { + s.CellCfg.GUI.WM = "fluxbox" + }) + if !hasArg(argv, "DEVCELL_WM=fluxbox") { + t.Errorf("expected DEVCELL_WM=fluxbox in argv: %v", argv) + } } // --- Git identity --- @@ -825,10 +1010,10 @@ func TestImageMetadataFromInspect_LabelsPopulated(t *testing.T) { m := runner.ImageMetadataFromInspectExport( "2026-05-16T21:33:48Z", map[string]string{ - "devcell.built-with": "nix2container", - "devcell.stack": "ultimate", - "org.opencontainers.image.created": "2026-05-16T21:33:48Z", - "org.opencontainers.image.revision": "abc123", + "devcell.built-with": "nix2container", + "devcell.stack": "ultimate", + "org.opencontainers.image.created": "2026-05-16T21:33:48Z", + "org.opencontainers.image.revision": "abc123", }, nil, ) @@ -885,13 +1070,13 @@ func TestImageVersions_Format(t *testing.T) { // The format string ImageVersions emits is " built " // when both fields are real, " built " when only date, etc. cases := []struct { - name string - m runner.ImageMetadata + name string + m runner.ImageMetadata wantHas string // substring we expect in the formatted "user" output }{ {"commit+date", runner.ImageMetadata{GitCommit: "abc123", BuildDate: "2026-05-16T21:33:48Z", BaseImage: "nix2container"}, "abc123 built 2026-05-16T21:33:48Z"}, - {"date only", runner.ImageMetadata{GitCommit: "unknown", BuildDate: "2026-05-16T21:33:48Z", BaseImage: "nix2container"}, "built 2026-05-16T21:33:48Z"}, - {"epoch date", runner.ImageMetadata{GitCommit: "abc123", BuildDate: "1970-01-01T00:00:00Z"}, "abc123"}, + {"date only", runner.ImageMetadata{GitCommit: "unknown", BuildDate: "2026-05-16T21:33:48Z", BaseImage: "nix2container"}, "built 2026-05-16T21:33:48Z"}, + {"epoch date", runner.ImageMetadata{GitCommit: "abc123", BuildDate: "1970-01-01T00:00:00Z"}, "abc123"}, {"placeholders only", runner.ImageMetadata{GitCommit: "unknown", BuildDate: "1970-01-01T00:00:00Z"}, ""}, } for _, tc := range cases { @@ -1039,6 +1224,30 @@ func TestDetectArch_IgnoresUnknownValue(t *testing.T) { } } +func TestImageExistsForPlatform_EmptyPlatformDelegatesToImageExists(t *testing.T) { + ctx := context.Background() + got := runner.ImageExistsForPlatform(ctx, "no-such-image:never", "") + if got { + t.Error("should return false for nonexistent image with empty platform") + } +} + +func TestImageExistsForPlatform_WrongPlatformReturnsFalse(t *testing.T) { + ctx := context.Background() + got := runner.ImageExistsForPlatform(ctx, "no-such-image:never", "linux/mips64") + if got { + t.Error("should return false for nonexistent image even with specific platform") + } +} + +func TestPullImageForPlatform_FailsForNonexistentImage(t *testing.T) { + ctx := context.Background() + err := runner.PullImageForPlatform(ctx, "no-such-registry.invalid/no-image:never", "linux/amd64", false) + if err == nil { + t.Error("should fail for nonexistent image") + } +} + func TestDockerPlatform_MatchesArch(t *testing.T) { tests := []struct { arch, want string @@ -1053,3 +1262,291 @@ func TestDockerPlatform_MatchesArch(t *testing.T) { } } } + +// CELL-358: sudo works in cells only because the entrypoint installs a setuid +// wrapper at /run/wrappers/bin/sudo. Docker's --security-opt no-new-privileges +// sets PR_SET_NO_NEW_PRIVS, which makes the kernel ignore the setuid bit — the +// wrapper would install cleanly and then fail at first use, in every cell at +// once. This guards the invariant so nobody adds the flag as a hardening tweak +// without understanding it breaks privilege escalation inside the cell. +func TestArgv_NeverDisablesNewPrivileges(t *testing.T) { + argv := buildArgv(t) + for i, a := range argv { + if strings.Contains(a, "no-new-privileges") { + t.Errorf("argv[%d]=%q sets no-new-privileges — this neutralizes the setuid sudo wrapper and breaks sudo in every cell", i, a) + } + } +} + +// --- Cross-tool agent mounts (CELL-448) --- + +func TestArgv_CrossToolAgentMounts(t *testing.T) { + argv := buildArgv(t) + // ~/.agents is mounted as a single ro bind (host has agents/ symlink → ~/.claude/agents) + if !hasConsecutive(argv, "-v", "/home/bob/.agents:/home/bob/.agents:ro") { + t.Errorf("expected ~/.agents:ro mount in argv: %v", argv) + } + // ~/.claude/agents should also be mounted at ~/.config/opencode/agents (OpenCode fallback) + if !hasConsecutive(argv, "-v", "/home/bob/.claude/agents:/home/bob/.config/opencode/agents:ro") { + t.Errorf("expected opencode fallback mount ~/.claude/agents → ~/.config/opencode/agents:ro in argv: %v", argv) + } +} + +func TestArgv_CrossToolAgentMounts_DedupAgainstCfgVolumes(t *testing.T) { + argv := buildArgv(t, func(s *runner.RunSpec) { + s.CellCfg.Volumes = []cfg.VolumeMount{ + {Mount: "/custom:/home/bob/.config/opencode/agents"}, + } + }) + countOpencode := 0 + for i, a := range argv { + if a == "-v" && i+1 < len(argv) { + if strings.HasSuffix(argv[i+1], "/.config/opencode/agents:ro") || strings.HasSuffix(argv[i+1], "/.config/opencode/agents") { + countOpencode++ + } + } + } + if countOpencode != 1 { + t.Errorf("~/.config/opencode/agents mount should appear exactly once (dedup), got %d", countOpencode) + } +} + +func TestArgv_SkipFlakeEnvVar(t *testing.T) { + argv := buildArgv(t, func(s *runner.RunSpec) { s.SkipFlake = true }) + if !hasConsecutive(argv, "-e", "DEVCELL_SKIP_FLAKE=1") { + t.Fatal("expected DEVCELL_SKIP_FLAKE=1 when SkipFlake is true") + } +} + +func TestArgv_SkipFlakeAbsentByDefault(t *testing.T) { + argv := buildArgv(t) + for _, a := range argv { + if strings.Contains(a, "DEVCELL_SKIP_FLAKE") { + t.Fatalf("DEVCELL_SKIP_FLAKE should not appear by default, got: %s", a) + } + } +} + +func TestArgv_TrustFlakeEnvVar(t *testing.T) { + argv := buildArgv(t, func(s *runner.RunSpec) { s.TrustFlake = true }) + if !hasConsecutive(argv, "-e", "DEVCELL_FLAKE_TRUST=1") { + t.Fatal("expected DEVCELL_FLAKE_TRUST=1 when TrustFlake is true") + } +} + +func TestArgv_TrustFlakeAbsentByDefault(t *testing.T) { + argv := buildArgv(t) + for _, a := range argv { + if strings.Contains(a, "DEVCELL_FLAKE_TRUST") { + t.Fatalf("DEVCELL_FLAKE_TRUST should not appear by default, got: %s", a) + } + } +} + +// --- Wireguard --- + +func TestArgv_WireguardEnabled_AddsNetAdmin(t *testing.T) { + argv := buildArgv(t, func(s *runner.RunSpec) { + s.CellCfg.Wireguard = []cfg.WireguardEntry{ + {Name: "test", Enabled: true, Config: "[Interface]\nAddress = 10.0.0.2/32"}, + } + }) + if !hasArg(argv, "--cap-add=NET_ADMIN") { + t.Fatal("expected --cap-add=NET_ADMIN when wireguard is enabled") + } +} + +func TestArgv_WireguardEnabled_AddsDevNetTun(t *testing.T) { + argv := buildArgv(t, func(s *runner.RunSpec) { + s.CellCfg.Wireguard = []cfg.WireguardEntry{ + {Name: "test", Enabled: true, Config: "[Interface]\nAddress = 10.0.0.2/32"}, + } + }) + if !hasConsecutive(argv, "--device=/dev/net/tun", "") && !hasArg(argv, "--device=/dev/net/tun") { + t.Fatal("expected --device=/dev/net/tun when wireguard is enabled") + } +} + +func TestArgv_WireguardEnabled_SetsSrcValidMark(t *testing.T) { + argv := buildArgv(t, func(s *runner.RunSpec) { + s.CellCfg.Wireguard = []cfg.WireguardEntry{ + {Name: "test", Enabled: true, Config: "[Interface]\nAddress = 10.0.0.2/32"}, + } + }) + if !hasConsecutive(argv, "--sysctl", "net.ipv4.conf.all.src_valid_mark=1") { + t.Fatal("expected --sysctl net.ipv4.conf.all.src_valid_mark=1 when wireguard is enabled") + } +} + +func TestArgv_WireguardEnabled_SetsEnvVar(t *testing.T) { + argv := buildArgv(t, func(s *runner.RunSpec) { + s.CellCfg.Wireguard = []cfg.WireguardEntry{ + {Name: "test", Enabled: true, Config: "[Interface]\nAddress = 10.0.0.2/32"}, + } + }) + if !hasConsecutive(argv, "-e", "DEVCELL_WG_ENABLED=1") { + t.Fatal("expected DEVCELL_WG_ENABLED=1 env var when wireguard is enabled") + } +} + +func TestArgv_WireguardEnabled_MountsWgDir(t *testing.T) { + argv := buildArgv(t, func(s *runner.RunSpec) { + s.CellCfg.Wireguard = []cfg.WireguardEntry{ + {Name: "test", Enabled: true, Config: "[Interface]\nAddress = 10.0.0.2/32"}, + } + }) + found := false + for _, a := range argv { + if strings.Contains(a, ".wg") && strings.Contains(a, ":ro") { + found = true + break + } + } + if !found { + t.Fatal("expected .wg/ directory mount (read-only) when wireguard is enabled") + } +} + +func TestArgv_WireguardDisabled_NoNetAdmin(t *testing.T) { + argv := buildArgv(t, func(s *runner.RunSpec) { + s.CellCfg.Wireguard = []cfg.WireguardEntry{ + {Name: "test", Enabled: false, Config: "some config"}, + } + }) + if hasArg(argv, "--cap-add=NET_ADMIN") { + t.Fatal("--cap-add=NET_ADMIN should not appear when wireguard is disabled") + } +} + +func TestArgv_WireguardDisabled_NoEnvVar(t *testing.T) { + argv := buildArgv(t) + for _, a := range argv { + if strings.Contains(a, "DEVCELL_WG_ENABLED") { + t.Fatalf("DEVCELL_WG_ENABLED should not appear by default, got: %s", a) + } + } +} + +func TestArgv_WireguardEnabled_NoDuplicateNetAdmin(t *testing.T) { + argv := buildArgv(t, func(s *runner.RunSpec) { + s.CellCfg.Docker.CapAdd = []string{"NET_ADMIN"} + s.CellCfg.Wireguard = []cfg.WireguardEntry{ + {Name: "test", Enabled: true, Config: "[Interface]\nAddress = 10.0.0.2/32"}, + } + }) + count := 0 + for _, a := range argv { + if a == "--cap-add=NET_ADMIN" { + count++ + } + } + if count != 1 { + t.Fatalf("expected exactly 1 --cap-add=NET_ADMIN, got %d", count) + } +} + +func TestArgv_WireguardEnabled_Privileged_NoExtraCap(t *testing.T) { + argv := buildArgv(t, func(s *runner.RunSpec) { + s.CellCfg.Docker.Privileged = true + s.CellCfg.Wireguard = []cfg.WireguardEntry{ + {Name: "test", Enabled: true, Config: "[Interface]\nAddress = 10.0.0.2/32"}, + } + }) + if hasArg(argv, "--cap-add=NET_ADMIN") { + t.Fatal("--cap-add=NET_ADMIN should not appear when --privileged is set") + } +} + +// ── PrepareWireguard ───────────────────────────────────────────────────────── + +func TestPrepareWireguard_WritesConfFiles(t *testing.T) { + dir := t.TempDir() + cellCfg := cfg.CellConfig{ + Wireguard: []cfg.WireguardEntry{ + { + Name: "proton-pt", + Enabled: true, + Config: "[Interface]\nAddress = 10.2.0.2/32\nDNS = 10.2.0.1\n\n[Peer]\nPublicKey = abc123\nEndpoint = 1.2.3.4:51820\nAllowedIPs = 0.0.0.0/0\n", + }, + }, + } + err := runner.PrepareWireguard(dir, cellCfg) + if err != nil { + t.Fatalf("PrepareWireguard: %v", err) + } + data, err := os.ReadFile(filepath.Join(dir, ".wg", "proton-pt.conf")) + if err != nil { + t.Fatalf("read conf: %v", err) + } + content := string(data) + if !strings.Contains(content, "Address = 10.2.0.2/32") { + t.Error("conf missing Address") + } + if !strings.Contains(content, "PostUp = wg set %i private-key /run/secrets/wg-private-key") { + t.Error("conf missing PostUp for private key file") + } +} + +func TestPrepareWireguard_StripsPrivateKey(t *testing.T) { + dir := t.TempDir() + cellCfg := cfg.CellConfig{ + Wireguard: []cfg.WireguardEntry{ + { + Name: "test", + Enabled: true, + Config: "[Interface]\nPrivateKey = SECRET\nAddress = 10.0.0.2/32\n\n[Peer]\nPublicKey = abc\nEndpoint = 1.2.3.4:51820\nAllowedIPs = 0.0.0.0/0\n", + }, + }, + } + if err := runner.PrepareWireguard(dir, cellCfg); err != nil { + t.Fatalf("PrepareWireguard: %v", err) + } + data, _ := os.ReadFile(filepath.Join(dir, ".wg", "test.conf")) + if strings.Contains(string(data), "SECRET") { + t.Fatal("conf must not contain the PrivateKey value") + } +} + +func TestPrepareWireguard_SkipsDisabled(t *testing.T) { + dir := t.TempDir() + cellCfg := cfg.CellConfig{ + Wireguard: []cfg.WireguardEntry{ + {Name: "off", Enabled: false, Config: "[Interface]\nAddress = 10.0.0.2/32"}, + }, + } + if err := runner.PrepareWireguard(dir, cellCfg); err != nil { + t.Fatalf("PrepareWireguard: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, ".wg", "off.conf")); err == nil { + t.Fatal("disabled entry should not produce a .conf file") + } +} + +func TestPrepareWireguard_MultipleEntries(t *testing.T) { + dir := t.TempDir() + cellCfg := cfg.CellConfig{ + Wireguard: []cfg.WireguardEntry{ + {Name: "a", Enabled: true, Config: "[Interface]\nAddress = 10.0.0.2/32\n\n[Peer]\nPublicKey = k1\nEndpoint = 1.1.1.1:51820\nAllowedIPs = 0.0.0.0/0\n"}, + {Name: "b", Enabled: true, Config: "[Interface]\nAddress = 10.0.0.3/32\n\n[Peer]\nPublicKey = k2\nEndpoint = 2.2.2.2:51820\nAllowedIPs = 0.0.0.0/0\n"}, + }, + } + if err := runner.PrepareWireguard(dir, cellCfg); err != nil { + t.Fatalf("PrepareWireguard: %v", err) + } + for _, name := range []string{"a", "b"} { + if _, err := os.Stat(filepath.Join(dir, ".wg", name+".conf")); err != nil { + t.Errorf("expected %s.conf to exist", name) + } + } +} + +func TestPrepareWireguard_NoEntries(t *testing.T) { + dir := t.TempDir() + cellCfg := cfg.CellConfig{} + if err := runner.PrepareWireguard(dir, cellCfg); err != nil { + t.Fatalf("PrepareWireguard: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, ".wg")); err == nil { + t.Fatal(".wg dir should not be created when there are no entries") + } +} diff --git a/internal/runner/stale_cell.go b/internal/runner/stale_cell.go new file mode 100644 index 0000000..34a8b88 --- /dev/null +++ b/internal/runner/stale_cell.go @@ -0,0 +1,82 @@ +package runner + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// CELL-391: stale cell warning at start. +// +// Drift policy (decided 2026-08-01): inform, not enforce. Starting a cell +// whose closure is behind the newest rev on the volume is the act that +// keeps a second full closure alive ("a parallel reality") — the user gets +// told at that moment, with `cell build --update` as the remedy, and +// proceeds by default. Detection is read-only and degrades to silence. + +// ProjectNixpkgsRev reads the scaffolded flake.lock under +// /.devcell/flake.lock and returns nodes.nixpkgs.locked.rev. +// A missing or unparsable lock returns "" without error — the warning +// simply won't fire (never fatal, never blocking). +func ProjectNixpkgsRev(baseDir string) (string, error) { + data, err := os.ReadFile(filepath.Join(baseDir, ".devcell", "flake.lock")) + if err != nil { + return "", nil + } + var lock struct { + Nodes map[string]struct { + Locked struct { + Rev string `json:"rev"` + } `json:"locked"` + } `json:"nodes"` + } + if err := json.Unmarshal(data, &lock); err != nil { + return "", nil + } + return lock.Nodes["nixpkgs"].Locked.Rev, nil +} + +func shortRev(rev string) string { + if len(rev) > 7 { + return rev[:7] + } + return rev +} + +// StaleCellWarning decides whether this project's closure is behind the +// newest rev live on the volume, and renders the warning if so. Unknown +// revs on either side mean silence — a nudge must never fire on guesswork. +func StaleCellWarning(projectRev string, h NixStoreHealth) (string, bool) { + if projectRev == "" || h.NewestRev == "" || projectRev == h.NewestRev { + return "", false + } + msg := fmt.Sprintf( + "⚠ This cell is on nixpkgs %s — newest on this volume is %s (%s).\n"+ + " Starting it keeps a second full closure alive on disk (a parallel reality).\n"+ + " Update instead with: cell build --update\n"+ + " Continue anyway? [Y/n]", + shortRev(projectRev), shortRev(h.NewestRev), plural(h.NewestProjects, "project"), + ) + return msg, true +} + +// ConfirmProceed is the default-YES twin of ConfirmDestructive: a nudge, +// not a gate. Prints the warning; bare Enter or anything except n/no +// proceeds. Non-TTY prints the warning and proceeds unconditionally — +// automation is never blocked by a hygiene nudge. +func ConfirmProceed(out io.Writer, in io.Reader, isTTY bool, warning string) bool { + fmt.Fprintln(out, warning) + if !isTTY { + return true + } + scanner := bufio.NewScanner(in) + if !scanner.Scan() { + return true + } + answer := strings.ToLower(strings.TrimSpace(scanner.Text())) + return answer != "n" && answer != "no" +} diff --git a/internal/runner/stale_cell_test.go b/internal/runner/stale_cell_test.go new file mode 100644 index 0000000..11384e1 --- /dev/null +++ b/internal/runner/stale_cell_test.go @@ -0,0 +1,136 @@ +package runner_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DimmKirr/devcell/internal/runner" +) + +// CELL-391: stale cell warning at start. Drift policy (2026-08-01): +// inform, not enforce — a nudge with `cell build --update`, never a gate. + +// The probe (still read-only) must also report lock-drift datapoints from +// the *-meta files CELL-332 stamps: how many distinct nixpkgs revs are +// live, which is newest, and how many projects sit on it. +func TestNixHealthProbeScript_ReadsMetaRevs(t *testing.T) { + for _, want := range []string{"*-meta", "nixpkgs="} { + if !strings.Contains(runner.NixHealthProbeScript, want) { + t.Errorf("probe must scan -meta files for nixpkgs revs, missing %q", want) + } + } +} + +func TestParseNixStoreHealth_ParsesRevDatapoints(t *testing.T) { + h, err := runner.ParseNixStoreHealth( + "total=4 stale=0 hashes=2 generations=3 orphaned=0 revs=2 newest_rev=9f8e7d6abc newest_projects=3\n") + if err != nil { + t.Fatal(err) + } + if h.DistinctRevs != 2 || h.NewestRev != "9f8e7d6abc" || h.NewestProjects != 3 { + t.Errorf("rev datapoints not parsed: %+v", h) + } +} + +// Pre-CELL-332 volumes have no -meta files; the old datapoint line (no rev +// fields) must still parse — missing keys are zero values, not errors. +func TestParseNixStoreHealth_RevFieldsOptional(t *testing.T) { + h, err := runner.ParseNixStoreHealth("total=1 stale=0 hashes=1 generations=1 orphaned=0\n") + if err != nil { + t.Fatal(err) + } + if h.DistinctRevs != 0 || h.NewestRev != "" { + t.Errorf("missing rev fields must be zero values: %+v", h) + } +} + +func TestProjectNixpkgsRev_ReadsScaffoldedLock(t *testing.T) { + dir := t.TempDir() + lockDir := filepath.Join(dir, ".devcell") + if err := os.MkdirAll(lockDir, 0o755); err != nil { + t.Fatal(err) + } + lock := `{"nodes":{"nixpkgs":{"locked":{"rev":"4a1b2c3deadbeef"}}}}` + if err := os.WriteFile(filepath.Join(lockDir, "flake.lock"), []byte(lock), 0o644); err != nil { + t.Fatal(err) + } + rev, err := runner.ProjectNixpkgsRev(dir) + if err != nil { + t.Fatal(err) + } + if rev != "4a1b2c3deadbeef" { + t.Errorf("want 4a1b2c3deadbeef, got %q", rev) + } +} + +func TestProjectNixpkgsRev_MissingLockIsEmptyNotError(t *testing.T) { + rev, err := runner.ProjectNixpkgsRev(t.TempDir()) + if err != nil { + t.Fatalf("missing lock must degrade silently, got %v", err) + } + if rev != "" { + t.Errorf("want empty rev, got %q", rev) + } +} + +func TestStaleCellWarning_BehindNewestWarns(t *testing.T) { + h := runner.NixStoreHealth{DistinctRevs: 2, NewestRev: "9f8e7d6abcdef", NewestProjects: 3} + msg, stale := runner.StaleCellWarning("4a1b2c3deadbeef", h) + if !stale { + t.Fatal("project behind newest volume rev must warn") + } + for _, want := range []string{ + "4a1b2c3", "9f8e7d6", "3 project", "parallel reality", + "cell build --update", "Continue anyway? [Y/n]", + } { + if !strings.Contains(msg, want) { + t.Errorf("warning missing %q:\n%s", want, msg) + } + } +} + +func TestStaleCellWarning_OnNewestIsSilent(t *testing.T) { + h := runner.NixStoreHealth{DistinctRevs: 1, NewestRev: "9f8e7d6abcdef"} + if _, stale := runner.StaleCellWarning("9f8e7d6abcdef", h); stale { + t.Error("project on the newest rev must not warn") + } +} + +// Detection failure degrades to silence — never a prompt, never fatal. +func TestStaleCellWarning_UnknownRevsAreSilent(t *testing.T) { + if _, stale := runner.StaleCellWarning("", runner.NixStoreHealth{NewestRev: "abc"}); stale { + t.Error("unknown project rev must not warn") + } + if _, stale := runner.StaleCellWarning("abc", runner.NixStoreHealth{}); stale { + t.Error("no volume rev data must not warn") + } +} + +// ConfirmProceed is the default-YES twin of ConfirmDestructive: a nudge, +// not a gate. Enter / y / anything-but-n proceeds; only n/no aborts. +// Non-TTY always proceeds after printing the warning — never blocks CI. +func TestConfirmProceed_EnterProceeds(t *testing.T) { + var out strings.Builder + if !runner.ConfirmProceed(&out, strings.NewReader("\n"), true, "WARN") { + t.Error("bare Enter must proceed (default yes)") + } +} + +func TestConfirmProceed_NoAborts(t *testing.T) { + var out strings.Builder + if runner.ConfirmProceed(&out, strings.NewReader("n\n"), true, "WARN") { + t.Error("answering n must abort") + } +} + +func TestConfirmProceed_NonTTYProceedsWithWarning(t *testing.T) { + var out strings.Builder + if !runner.ConfirmProceed(&out, strings.NewReader(""), false, "WARN") { + t.Error("non-TTY must proceed unconditionally") + } + if !strings.Contains(out.String(), "WARN") { + t.Error("non-TTY must still print the warning") + } +} diff --git a/internal/runner/systemprompt.go b/internal/runner/systemprompt.go index 6f9afda..77b30fb 100644 --- a/internal/runner/systemprompt.go +++ b/internal/runner/systemprompt.go @@ -9,15 +9,39 @@ package runner import ( + "bytes" + _ "embed" "fmt" "os" "path/filepath" "strings" + "text/template" "github.com/DimmKirr/devcell/internal/cfg" "github.com/DimmKirr/devcell/internal/config" ) +//go:embed container_context.tmpl.md +var containerContextRaw string + +var containerContextTmpl = template.Must(template.New("context").Parse(containerContextRaw)) + +type volumeMount struct { + Container string + Host string + Mode string +} + +type contextData struct { + AppName string + AppDir string + HostDir string + HomeDir string + HostHome string + ConfigDir string + Volumes []volumeMount +} + // ContainerContext returns the auto-generated filesystem/runtime preamble // — bind mounts, host path mappings, hard constraints — describing the // devcell container the agent is running inside. Pure container facts; @@ -28,26 +52,7 @@ import ( // surface that ships a system prompt (cell claude, cell serve) prepends // this so the agent reasons correctly about its filesystem. func ContainerContext(c config.Config, cellCfg cfg.CellConfig) string { - var b strings.Builder - - appDir := "/" + c.AppName // e.g. /devcell-85 - hostDir := c.BaseDir // e.g. /Users/dmitry/dev/dimmkirr/devcell - homeDir := "/home/" + c.HostUser - - fmt.Fprintf(&b, "Environment: Docker container (cell-%s)\n", c.AppName) - fmt.Fprintf(&b, "Project: %s (alias for %s on host)\n", appDir, hostDir) - fmt.Fprintf(&b, "Both paths are bind-mounted from the same host directory and resolve to the same filesystem.\n") - fmt.Fprintf(&b, "Working directory is %s. If the user mentions host paths like %s/..., they map to %s/...\n", appDir, hostDir, appDir) - b.WriteString("\n") - - b.WriteString("Bind mounts:\n") - fmt.Fprintf(&b, " %s = %s (project source, read-write)\n", appDir, hostDir) - fmt.Fprintf(&b, " %s (persistent home, survives container restarts)\n", homeDir) - fmt.Fprintf(&b, " %s/.claude/skills (read-write)\n", homeDir) - fmt.Fprintf(&b, " %s/.claude/commands (read-only, from host)\n", homeDir) - fmt.Fprintf(&b, " %s/.claude/agents (read-only, from host)\n", homeDir) - fmt.Fprintf(&b, " /etc/devcell/config = %s (user build config)\n", c.ConfigDir) - + var vols []volumeMount for _, vol := range cellCfg.Volumes { parts := strings.SplitN(vol.Resolved(), ":", 3) if len(parts) >= 2 { @@ -55,27 +60,29 @@ func ContainerContext(c config.Config, cellCfg cfg.CellConfig) string { if len(parts) == 3 && parts[2] == "ro" { mode = "read-only" } - fmt.Fprintf(&b, " %s = %s (%s, from devcell.toml)\n", parts[1], parts[0], mode) + vols = append(vols, volumeMount{ + Container: parts[1], + Host: parts[0], + Mode: mode, + }) } } - b.WriteString("\n") - b.WriteString("Host path mapping (use these to translate paths the user mentions):\n") - fmt.Fprintf(&b, " host: %s → container: %s\n", hostDir, hostDir) - fmt.Fprintf(&b, " host: %s → container: %s\n", c.HostHome, homeDir) - for _, vol := range cellCfg.Volumes { - parts := strings.SplitN(vol.Resolved(), ":", 3) - if len(parts) >= 2 { - fmt.Fprintf(&b, " host: %s → container: %s\n", parts[0], parts[1]) - } + data := contextData{ + AppName: c.AppName, + AppDir: "/" + c.AppName, + HostDir: c.BaseDir, + HomeDir: "/home/" + c.HostUser, + HostHome: c.HostHome, + ConfigDir: c.ConfigDir, + Volumes: vols, } - b.WriteString("\n") - - b.WriteString("Constraints:\n") - b.WriteString(" - /opt/devcell is the nix environment — do not modify at runtime\n") - b.WriteString(" - Nix profile: /opt/devcell/.local/state/nix/profiles/profile\n") - return b.String() + var buf bytes.Buffer + if err := containerContextTmpl.Execute(&buf, data); err != nil { + return fmt.Sprintf("(error rendering container context: %v)\n", err) + } + return buf.String() } // ResolveOpts bundles every input source the system-prompt resolver looks @@ -88,8 +95,15 @@ type ResolveOpts struct { // EnvFile / EnvInline are the DEVCELL_SYSTEM_PROMPT_FILE / // DEVCELL_SYSTEM_PROMPT env vars. Read by every surface. EnvFile, EnvInline string - // CellCfg supplies [llm].system_prompt and [llm].system_prompt_file - // from the merged devcell.toml. + // AppendFlagFile / AppendFlagInline are the --append-system-prompt-file / + // --append-system-prompt CLI flags. Currently exposed only on `cell serve`. + AppendFlagFile, AppendFlagInline string + // AppendEnvFile / AppendEnvInline are DEVCELL_APPEND_SYSTEM_PROMPT_FILE / + // DEVCELL_APPEND_SYSTEM_PROMPT. Read by every surface. + AppendEnvFile, AppendEnvInline string + // CellCfg supplies [llm].system_prompt / system_prompt_file (base) and + // [llm].append_system_prompt / append_system_prompt_file (overlay) from + // the merged devcell.toml. CellCfg cfg.CellConfig // CfgBaseDir is the project base dir, used to resolve a relative // `[llm].system_prompt_file` path. Empty disables relative resolution @@ -103,8 +117,8 @@ type ResolveOpts struct { // to guess which one won. Across tiers, higher silently shadows lower: // the layering is the whole point of having multiple sources. // -// Returns ("", nil) when no source is set — callers concatenate this -// with ContainerContext via AssembleSystemPrompt. +// Returns ("", nil) when no source is set, which is the signal that no base +// is configured and Claude Code's built-in prompt must stay in effect. // // Resolution order (first match wins): // @@ -116,50 +130,113 @@ type ResolveOpts struct { // 6. CellCfg.LLM.SystemPrompt ([llm].system_prompt) // 7. "" func ResolveSystemPrompt(opts ResolveOpts) (string, error) { - if opts.FlagFile != "" && opts.FlagInline != "" { - return "", fmt.Errorf("--system-prompt and --system-prompt-file are mutually exclusive") + return resolveTiers(promptSources{ + flagFile: opts.FlagFile, + flagInline: opts.FlagInline, + flagLabels: "--system-prompt and --system-prompt-file", + envFile: opts.EnvFile, + envInline: opts.EnvInline, + envLabels: "DEVCELL_SYSTEM_PROMPT and DEVCELL_SYSTEM_PROMPT_FILE", + tomlFile: opts.CellCfg.LLM.SystemPromptFile, + tomlInline: opts.CellCfg.LLM.SystemPrompt, + tomlLabels: "[llm].system_prompt and [llm].system_prompt_file", + fileSource: map[string]string{ + "flag": "--system-prompt-file", + "env": "DEVCELL_SYSTEM_PROMPT_FILE", + "toml": "[llm].system_prompt_file", + }, + }, opts.CfgBaseDir) +} + +// ResolveAppendPrompt walks the same tier chain over the *append* sources. +// It never reads the base sources: after the split, [llm].system_prompt +// replaces Claude Code's built-in prompt while the append layer stacks on +// top of whichever base ends up in effect. +// +// Resolution order (first match wins): +// +// 1. opts.AppendFlagFile (--append-system-prompt-file) +// 2. opts.AppendFlagInline (--append-system-prompt) +// 3. opts.AppendEnvFile (DEVCELL_APPEND_SYSTEM_PROMPT_FILE) +// 4. opts.AppendEnvInline (DEVCELL_APPEND_SYSTEM_PROMPT) +// 5. CellCfg.LLM.AppendSystemPromptFile ([llm].append_system_prompt_file) +// 6. CellCfg.LLM.AppendSystemPrompt ([llm].append_system_prompt) +// 7. "" +func ResolveAppendPrompt(opts ResolveOpts) (string, error) { + return resolveTiers(promptSources{ + flagFile: opts.AppendFlagFile, + flagInline: opts.AppendFlagInline, + flagLabels: "--append-system-prompt and --append-system-prompt-file", + envFile: opts.AppendEnvFile, + envInline: opts.AppendEnvInline, + envLabels: "DEVCELL_APPEND_SYSTEM_PROMPT and DEVCELL_APPEND_SYSTEM_PROMPT_FILE", + tomlFile: opts.CellCfg.LLM.AppendSystemPromptFile, + tomlInline: opts.CellCfg.LLM.AppendSystemPrompt, + tomlLabels: "[llm].append_system_prompt and [llm].append_system_prompt_file", + fileSource: map[string]string{ + "flag": "--append-system-prompt-file", + "env": "DEVCELL_APPEND_SYSTEM_PROMPT_FILE", + "toml": "[llm].append_system_prompt_file", + }, + }, opts.CfgBaseDir) +} + +// promptSources is one layer's worth of inputs for the tier walk. Both the +// base and the append layer have identical precedence rules, so the walk is +// written once and parameterised by source names for error messages. +type promptSources struct { + flagFile, flagInline, flagLabels string + envFile, envInline, envLabels string + tomlFile, tomlInline, tomlLabels string + fileSource map[string]string +} + +func resolveTiers(src promptSources, cfgBaseDir string) (string, error) { + if src.flagFile != "" && src.flagInline != "" { + return "", fmt.Errorf("%s are mutually exclusive", src.flagLabels) } - if opts.FlagFile != "" { - return readPromptFile(opts.FlagFile, "--system-prompt-file") + if src.flagFile != "" { + return readPromptFile(src.flagFile, src.fileSource["flag"]) } - if opts.FlagInline != "" { - return opts.FlagInline, nil + if src.flagInline != "" { + return src.flagInline, nil } - if opts.EnvFile != "" && opts.EnvInline != "" { - return "", fmt.Errorf("DEVCELL_SYSTEM_PROMPT and DEVCELL_SYSTEM_PROMPT_FILE are mutually exclusive") + if src.envFile != "" && src.envInline != "" { + return "", fmt.Errorf("%s are mutually exclusive", src.envLabels) } - if opts.EnvFile != "" { - return readPromptFile(opts.EnvFile, "DEVCELL_SYSTEM_PROMPT_FILE") + if src.envFile != "" { + return readPromptFile(src.envFile, src.fileSource["env"]) } - if opts.EnvInline != "" { - return opts.EnvInline, nil + if src.envInline != "" { + return src.envInline, nil } - tomlFile := opts.CellCfg.LLM.SystemPromptFile - tomlInline := opts.CellCfg.LLM.SystemPrompt - if tomlFile != "" && tomlInline != "" { - return "", fmt.Errorf("[llm].system_prompt and [llm].system_prompt_file are mutually exclusive") + if src.tomlFile != "" && src.tomlInline != "" { + return "", fmt.Errorf("%s are mutually exclusive", src.tomlLabels) } - if tomlFile != "" { - // Resolve relative paths against the project base dir, matching - // the convention `[[volumes]]` already uses. - path := tomlFile - if !filepath.IsAbs(path) && opts.CfgBaseDir != "" { - path = filepath.Join(opts.CfgBaseDir, path) + if src.tomlFile != "" { + path := src.tomlFile + if !filepath.IsAbs(path) && cfgBaseDir != "" { + path = filepath.Join(cfgBaseDir, path) } - return readPromptFile(path, "[llm].system_prompt_file") + return readPromptFile(path, src.fileSource["toml"]) } - return tomlInline, nil + return src.tomlInline, nil } -// AssembleSystemPrompt is the single entry point callers should use to -// build the string passed to claude's --append-system-prompt (or any -// future agent's equivalent). It prepends ContainerContext to the -// resolved prompt with a blank-line separator. When the resolved prompt -// is empty, returns just ContainerContext. -func AssembleSystemPrompt(c config.Config, cellCfg cfg.CellConfig, opts ResolveOpts) (string, error) { - resolved, err := ResolveSystemPrompt(opts) +// AssembleOverlayPrompt builds the overlay: the auto-generated container +// context followed by the resolved append prompt. This is what reaches claude +// via --append-system-prompt-file. +// +// Container context lives on the overlay rather than the base deliberately — +// it is regenerated per run from live container facts, so a user-supplied +// base prompt must not be able to displace it. +// +// When the append layer resolves empty, the container context alone is +// returned; it is never empty, so the overlay file is always written. +func AssembleOverlayPrompt(c config.Config, cellCfg cfg.CellConfig, opts ResolveOpts) (string, error) { + resolved, err := ResolveAppendPrompt(opts) if err != nil { return "", err } diff --git a/internal/runner/systemprompt_test.go b/internal/runner/systemprompt_test.go index a69db07..91e0885 100644 --- a/internal/runner/systemprompt_test.go +++ b/internal/runner/systemprompt_test.go @@ -29,16 +29,20 @@ func TestContainerContext_DescribesMountsAndConstraints(t *testing.T) { ctx := ContainerContext(sampleConfig(), cellCfg) checks := map[string]string{ - "container identity": "Docker container", - "project alias": "/devcell-85", - "host base dir": "/Users/dmitry/dev/dimmkirr/devcell", - "same filesystem": "same filesystem", - "persistent home": "/home/dmitry", - "skills mount": ".claude/skills", - "user volume": "/run/secrets", - "user volume ro": "read-only", - "host mapping prefix": "host: /Users/dmitry/dev/dimmkirr/devcell", - "nix constraint": "/opt/devcell", + "container identity": "Docker container", + "project alias": "/devcell-85", + "host base dir": "/Users/dmitry/dev/dimmkirr/devcell", + "same filesystem": "same filesystem", + "persistent home": "/home/dmitry", + "skills mount": ".claude/skills", + "user volume": "/run/secrets", + "user volume ro": "read-only", + "host path mapping": "Host path mapping", + "nix constraint": "/opt/devcell", + "nix ad-hoc install": "nix profile install", + "nix PATH layout": "PATH layout", + "nix ld libs": "NIX_LD_LIBRARY_PATH", + "nix cgo": "CGO_CFLAGS", } for name, want := range checks { @@ -224,9 +228,9 @@ func TestResolveSystemPrompt_FileNotFound(t *testing.T) { } } -func TestAssembleSystemPrompt_PrependsContainerContext(t *testing.T) { - out, err := AssembleSystemPrompt(sampleConfig(), cfg.CellConfig{}, ResolveOpts{ - FlagInline: "be terse", +func TestAssembleOverlayPrompt_PrependsContainerContext(t *testing.T) { + out, err := AssembleOverlayPrompt(sampleConfig(), cfg.CellConfig{}, ResolveOpts{ + AppendFlagInline: "be terse", }) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -244,8 +248,8 @@ func TestAssembleSystemPrompt_PrependsContainerContext(t *testing.T) { } } -func TestAssembleSystemPrompt_EmptyResolverReturnsContextOnly(t *testing.T) { - out, err := AssembleSystemPrompt(sampleConfig(), cfg.CellConfig{}, ResolveOpts{}) +func TestAssembleOverlayPrompt_EmptyResolverReturnsContextOnly(t *testing.T) { + out, err := AssembleOverlayPrompt(sampleConfig(), cfg.CellConfig{}, ResolveOpts{}) if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/internal/runner/thin_build.go b/internal/runner/thin_build.go index eb18dc6..6d23645 100644 --- a/internal/runner/thin_build.go +++ b/internal/runner/thin_build.go @@ -1,11 +1,16 @@ package runner import ( + "context" "fmt" "os" + "os/exec" "regexp" "runtime" + "strconv" "strings" + "sync" + "time" ) // NixCoreImage is the default nix base image for thin builds. @@ -13,6 +18,239 @@ import ( // for call sites inside runner that don't take a config parameter. const NixCoreImage = "nixos/nix:2.34.7" +// Resource ceiling for the thin-build container (CELL-359). +// +// The builder used to run uncapped, so a `nix build` spike was arbitrated by +// the VM-wide OOM killer only after the whole VM was already starved. Capping +// trades that for a deterministic cgroup kill confined to the builder: +// sibling cells survive and the failure names its own cause. +// +// These are ceilings, NOT reservations, and they are only emitted when they +// actually constrain the daemon — see clampBuildLimits. A ceiling at or above +// what the daemon has protects nothing, and dockerd rejects a --cpus larger +// than its CPU count outright ("range of CPUs is from 0.01 to 2.00", exit +// 125), which is fatal on a stock 2-CPU Colima VM. +// +// Two properties make a ceiling safe to emit, and the first cut of CELL-359 +// shipped with neither: +// +// - nix's own concurrency must be derived from the ceiling. `--cpus` is a CFS +// bandwidth quota, not a core count: nproc inside a `--cpus 4` container +// still reports every host CPU, so `max-jobs = auto` kept scheduling one +// job per host CPU. Eight concurrent derivations under an 8 GiB ceiling is +// ~1 GiB each, and `npm ci` over a large dependency tree needs several — +// the cgroup OOM killer took it out mid-install ("Killed", exit 137). +// +// - The ceiling cannot lean on swap as a cushion. Leaving --memory-swap unset +// gives Docker's 2x default, but Lima and Docker Desktop VMs both run +// swapless in practice (SwapTotal: 0), so the ceiling is hard at --memory +// and the concurrency derived from it is the only thing keeping the build +// inside it. +const ( + // DefaultBuildCPUs is "0" — no CFS quota by default. The memory ceiling + // plus the max-jobs derived from it are the OOM guard; a CPU quota only + // starves per-job cores (nproc ignores it anyway) and slows the build. + DefaultBuildCPUs = "0" + + // memGiBPerBuildJob is the ceiling budgeted per parallel nix job, and is + // what max-jobs is derived from. Sized for the real single-job peak: the + // mise Rust link step alone needs 5-6 GiB (its OOM at a 4 GiB budget is + // what killed CELL-359's first cut), and chromium/texlive/npm ci over the + // AWS SDK tree are in the same range. + memGiBPerBuildJob = 8 +) + +// defaultBuildMemory sizes the default --memory ceiling as ¾ of the daemon's +// own memory, rounded down to whole GiB — the builder gets most of the VM, +// the rest stays for the VM itself and sibling cells. Always below the +// daemon total, so it always survives clamping. "0" (opt-out) on daemons +// too small for even a 1 GiB ceiling. +func defaultBuildMemory(memBytes int64) string { + gib := (memBytes / 4 * 3) >> 30 + if gib < 1 { + return "0" + } + return fmt.Sprintf("%dg", gib) +} + +// BuildLimits is what the builder may use: the docker ceilings plus the nix +// concurrency derived from them. A zero field means "emit nothing". +type BuildLimits struct { + Memory string // --memory value, "" to omit + CPUs string // --cpus value, "" to omit + MaxJobs int // nix max-jobs, 0 to leave at "auto" + Cores int // nix cores, 0 to leave at nix's own default +} + +// ResolveBuildLimits returns the build resource limits that will be applied. +// Exported for debug logging in cmd/build.go. +func ResolveBuildLimits() BuildLimits { + return clampBuildLimits() +} + +// nixConcurrency derives max-jobs and cores from the ceilings actually in +// force, so nix cannot schedule more parallel work than the cgroup can feed. +// cpuQuota is the --cpus value, or 0 when no CPU ceiling is emitted. +func nixConcurrency(memBytes int64, cpuQuota float64, ncpu int) (maxJobs, cores int) { + cpuBudget := ncpu + if cpuQuota > 0 && int(cpuQuota) < cpuBudget { + cpuBudget = int(cpuQuota) + } + if cpuBudget < 1 { + cpuBudget = 1 + } + + // One job per memGiBPerBuildJob of ceiling. This is the fix for the OOM: + // without it `max-jobs = auto` schedules one job per *host* CPU inside a + // ceiling sized for far fewer. + maxJobs = int(memBytes>>30) / memGiBPerBuildJob + if maxJobs > cpuBudget { + maxJobs = cpuBudget + } + if maxJobs < 1 { + maxJobs = 1 + } + + // Spread the whole CPU budget across the jobs — maxJobs × cores uses + // every CPU the budget allows, never more. + cores = cpuBudget / maxJobs + if cores < 1 { + cores = 1 + } + return maxJobs, cores +} + +// DockerCapacity is what the build daemon advertises for itself. Note this is +// the daemon's own ceiling, which on macOS is the Colima/Docker Desktop VM — +// not the host Mac's specs, and not necessarily the daemon this process runs +// under if contexts differ. +type DockerCapacity struct { + NCPU int + MemBytes int64 +} + +// dockerCapacityFn is swapped in tests. Memoised because argv construction can +// be called more than once per build and `docker info` is a round trip. +var dockerCapacityFn = memoisedDockerCapacity() + +func memoisedDockerCapacity() func() (DockerCapacity, bool) { + var ( + once sync.Once + capacity DockerCapacity + ok bool + ) + return func() (DockerCapacity, bool) { + once.Do(func() { capacity, ok = probeDockerCapacity() }) + return capacity, ok + } +} + +func probeDockerCapacity() (DockerCapacity, bool) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, "docker", "info", "--format", "{{.NCPU}} {{.MemTotal}}").Output() + if err != nil { + return DockerCapacity{}, false + } + var c DockerCapacity + if _, err := fmt.Sscanf(strings.TrimSpace(string(out)), "%d %d", &c.NCPU, &c.MemBytes); err != nil { + return DockerCapacity{}, false + } + if c.NCPU <= 0 || c.MemBytes <= 0 { + return DockerCapacity{}, false + } + return c, true +} + +// buildResourceLimit resolves a docker resource ceiling from env, falling back +// to def. "0" (or "unlimited") opts out entirely. +func buildResourceLimit(envVar, def string) string { + v := strings.TrimSpace(os.Getenv(envVar)) + if v == "" { + v = def + } + if v == "0" || v == "unlimited" { + return "" + } + return v +} + +// nixConcurrencyEnv resolves a nix concurrency setting: an explicit env value +// wins, otherwise the value derived from the ceiling in force. "" means leave +// nix at its own default. +func nixConcurrencyEnv(envVar string, derived int) string { + if v := strings.TrimSpace(os.Getenv(envVar)); v != "" { + return v + } + if derived > 0 { + return strconv.Itoa(derived) + } + return "" +} + +// clampBuildLimits drops any ceiling the daemon cannot honour or that would not +// constrain it, then derives nix's concurrency from what survives. +func clampBuildLimits() BuildLimits { + capacity, ok := dockerCapacityFn() + if !ok { + // Unknown daemon — emit nothing rather than risk an argument it + // rejects. This is the pre-CELL-359 behaviour and always runs. + return BuildLimits{} + } + + lim := BuildLimits{ + Memory: buildResourceLimit("DEVCELL_BUILD_MEMORY", defaultBuildMemory(capacity.MemBytes)), + CPUs: buildResourceLimit("DEVCELL_BUILD_CPUS", DefaultBuildCPUs), + } + + memBytes, memOK := parseMemoryLimit(lim.Memory) + if !memOK || memBytes >= capacity.MemBytes { + lim.Memory, memBytes = "", 0 + } + cpuQuota, err := strconv.ParseFloat(lim.CPUs, 64) + if err != nil || cpuQuota >= float64(capacity.NCPU) { + lim.CPUs, cpuQuota = "", 0 + } + + // The ceilings ship as a pair. A CPU quota on its own cannot prevent the + // OOM the cap exists to prevent — it only slows the build down — and on a + // small VM a lone `--cpus 1` is pure loss. + if lim.Memory == "" { + lim.CPUs, cpuQuota = "", 0 + return lim + } + + lim.MaxJobs, lim.Cores = nixConcurrency(memBytes, cpuQuota, capacity.NCPU) + return lim +} + +var memoryLimitRe = regexp.MustCompile(`^(\d+)\s*([kmgt]?)b?$`) + +// parseMemoryLimit converts a docker-style size ("8g", "512m", "2048") to +// bytes. Reports false for anything it cannot read, which callers treat as +// "no usable ceiling" rather than substituting a guess. +func parseMemoryLimit(s string) (int64, bool) { + m := memoryLimitRe.FindStringSubmatch(strings.ToLower(strings.TrimSpace(s))) + if m == nil { + return 0, false + } + n, err := strconv.ParseInt(m[1], 10, 64) + if err != nil { + return 0, false + } + switch m[2] { + case "k": + return n << 10, true + case "m": + return n << 20, true + case "g": + return n << 30, true + case "t": + return n << 40, true + } + return n, true +} + var devcellDirRe = regexp.MustCompile(`^/devcell-\d+`) // DockerHostPath translates container-local paths (e.g. /devcell-256/nixhome) @@ -35,10 +273,10 @@ func DockerHostPath(p string) string { // to produce the thin image (nix-core + config, no /nix/store baked in). // // nixhomeRef accepts EITHER: -// - a filesystem path (e.g. /home/bob/nixhome) — mounted at /opt/nixhome -// and home-manager runs against `/opt/nixhome#devcell-` -// - a flake reference (e.g. github:DimmKirr/devcell/main?dir=nixhome) — no -// mount; home-manager runs against `#devcell-` directly, +// - a filesystem path (e.g. /home/bob/nixhome) — the caller streams a tar +// archive to stdin, which is extracted at /opt/nixhome before home-manager +// - a flake reference (e.g. github:devcell-sh/community-home/main) — no +// archive; home-manager runs against `#devcell-` directly, // letting nix fetch and cache under /nix/store. This is the // clean-machine path (CELL-38) — no local nixhome required. // @@ -79,6 +317,20 @@ func ThinBuildArgvFull(coreImage, containerName, volumeName, nixhomeRef, thinTag } script := fmt.Sprintf(`set -e +# Local nixhome is streamed through the Docker API instead of bind-mounted. +# A nested cell may be connected to Docker Desktop, Colima, or another remote +# daemon whose host filesystem namespace differs from the Docker CLI process. +if [ "${DEVCELL_NIXHOME_TRANSPORT:-}" = "tar-stdin" ]; then + mkdir -p /opt/nixhome + tar -xf - -C /opt/nixhome + if [ ! -f /opt/nixhome/flake.nix ]; then + echo "ERROR: streamed nixhome overlay has no /opt/nixhome/flake.nix" >&2 + find /opt/nixhome -maxdepth 2 -mindepth 1 -print >&2 2>/dev/null || true + exit 66 + fi + echo "Nixhome overlay received via Docker API." +fi + # Newer nixos/nix images symlink /etc/{passwd,group,shadow,nix/nix.conf} into # /nix/store. When we mount the shared nix-store volume over /nix these symlinks # dangle. Materialise them from the image's base-system store path BEFORE @@ -123,6 +375,9 @@ experimental-features = nix-command flakes max-substitution-jobs = 16 http-connections = 16 max-jobs = ${DEVCELL_NIX_MAX_JOBS:-auto} +# cores bounds make -j inside each job. nix defaults to 0 ("use every CPU"), +# which ignores the container's --cpus quota — nproc reports the host count. +cores = ${DEVCELL_NIX_CORES:-0} sandbox = %s filter-syscalls = %s %s @@ -254,13 +509,21 @@ HM_GENERATION=$(readlink -f /opt/devcell/.local/state/nix/profiles/home-manager) # Pin the resolved store paths as persistent GC roots on the shared volume # so nix-collect-garbage from another container cannot reap the targets our -# baked-in symlinks depend on (CELL-320). Named per-project + hmTarget + arch -# so different projects preserve their own derivations independently. +# baked-in symlinks depend on (CELL-320). Keyed by the nix store path hash +# (CELL-331) — encodes stack+modules+arch+nixpkgs, dedupes identical configs. +HM_PROFILE_HASH=$(basename "$HM_PROFILE" | cut -d- -f1) mkdir -p /nix/var/nix/gcroots/devcell -ln -sfT "$HM_PROFILE" /nix/var/nix/gcroots/devcell/%s-%s%s-profile +ln -sfT "$HM_PROFILE" /nix/var/nix/gcroots/devcell/${HM_PROFILE_HASH}-profile if [ -n "$HM_GENERATION" ] && [ -d "$HM_GENERATION" ]; then - ln -sfT "$HM_GENERATION" /nix/var/nix/gcroots/devcell/%s-%s%s-generation + ln -sfT "$HM_GENERATION" /nix/var/nix/gcroots/devcell/${HM_PROFILE_HASH}-generation fi +cat > /nix/var/nix/gcroots/devcell/${HM_PROFILE_HASH}-meta <--profile - projectName, hmTarget, archSuffix, // /nix/var/nix/gcroots/devcell/--generation - coreImage, // FROM (inner Dockerfile) - hmTarget, // ENV DEVCELL_PROFILE=devcell- - stack, // ENV DEVCELL_STACK - modules, // ENV DEVCELL_MODULES - stack, // LABEL devcell.stack= + projectName, // ${HM_PROFILE_HASH}-meta: project= + coreImage, // FROM (inner Dockerfile) + hmTarget, // ENV DEVCELL_PROFILE=devcell- + stack, // ENV DEVCELL_STACK + modules, // ENV DEVCELL_MODULES + stack, // LABEL devcell.stack= platform, thinTag, thinTag) args := []string{ @@ -381,12 +643,33 @@ echo "Done — thin image: %s"`, "--user", "0", "-v", volumeName + ":/nix", } + // Resource ceiling — see buildCapacityFraction. --memory-swap is left unset + // so Docker keeps its 2x default: pinning it equal to --memory would + // *disable* spill on the hosts that do have swap. It is not headroom + // though — Lima and Docker Desktop VMs run swapless, so the ceiling is hard + // and nix's concurrency below is what keeps the build inside it. + lim := clampBuildLimits() + if lim.Memory != "" { + args = append(args, "--memory", lim.Memory) + } + if lim.CPUs != "" { + args = append(args, "--cpus", lim.CPUs) + } if !remote { - args = append(args, "-v", nixhomeRef+":/opt/nixhome") + // Keep stdin attached so the caller can stream the overlay. This avoids + // Docker's legacy -v behaviour, which silently creates an empty daemon- + // side directory when a VM-backed daemon cannot resolve the host path. + args = append(args, "-i", "-e", "DEVCELL_NIXHOME_TRANSPORT=tar-stdin") } - if v := os.Getenv("DEVCELL_NIX_MAX_JOBS"); v != "" { + // nix's concurrency must track the cgroup ceiling, or `max-jobs = auto` + // schedules one job per host CPU inside it and the builder OOMs. An explicit + // env setting always wins over the derived value. + if v := nixConcurrencyEnv("DEVCELL_NIX_MAX_JOBS", lim.MaxJobs); v != "" { args = append(args, "-e", "DEVCELL_NIX_MAX_JOBS="+v) } + if v := nixConcurrencyEnv("DEVCELL_NIX_CORES", lim.Cores); v != "" { + args = append(args, "-e", "DEVCELL_NIX_CORES="+v) + } args = append(args, "-v", "/var/run/docker.sock:/var/run/docker.sock", "--entrypoint", "sh", diff --git a/internal/runner/thin_build_test.go b/internal/runner/thin_build_test.go index ff222ba..bda59f7 100644 --- a/internal/runner/thin_build_test.go +++ b/internal/runner/thin_build_test.go @@ -2,19 +2,38 @@ package runner import ( "runtime" + "strconv" "strings" "testing" ) const ( - testCoreImage = "ghcr.io/test/devcell:v0.0.0-core" - testContainer = "devcell-thin-builder" - testVolume = "devcell-nix-store" - testNixhome = "/home/bob/nixhome" - testThinTag = "devcell-user:base-thin" - testStack = "base" + testCoreImage = "ghcr.io/test/devcell:v0.0.0-core" + testContainer = "devcell-thin-builder" + testVolume = "devcell-nix-store" + testNixhome = "/home/bob/nixhome" + testThinTag = "devcell-user:base-thin" + testStack = "base" ) +func containsArg(argv []string, want string) bool { + for _, arg := range argv { + if arg == want { + return true + } + } + return false +} + +func containsConsecutive(argv []string, first, second string) bool { + for i := 0; i+1 < len(argv); i++ { + if argv[i] == first && argv[i+1] == second { + return true + } + } + return false +} + func TestThinBuildArgv_DockerRunStructure(t *testing.T) { argv := ThinBuildArgv(testCoreImage, testContainer, testVolume, testNixhome, testThinTag, testStack, "aarch64") if argv[0] != "docker" || argv[1] != "run" || argv[2] != "--rm" { @@ -58,17 +77,16 @@ func TestThinBuildArgv_MountsDockerSocket(t *testing.T) { } } -func TestThinBuildArgv_MountsNixhome(t *testing.T) { +func TestThinBuildArgv_StreamsNixhome(t *testing.T) { argv := ThinBuildArgv(testCoreImage, testContainer, testVolume, testNixhome, testThinTag, testStack, "x86_64") - found := false - for i, a := range argv { - if a == "-v" && i+1 < len(argv) && argv[i+1] == "/home/bob/nixhome:/opt/nixhome" { - found = true - break - } + if !containsArg(argv, "-i") { + t.Errorf("local nixhome transport must attach stdin: %v", argv) } - if !found { - t.Errorf("expected nixhome mount in argv: %v", argv) + if !containsConsecutive(argv, "-e", "DEVCELL_NIXHOME_TRANSPORT=tar-stdin") { + t.Errorf("local nixhome transport env missing: %v", argv) + } + if !strings.Contains(argv[len(argv)-1], "tar -xf - -C /opt/nixhome") { + t.Error("builder must extract the streamed overlay at /opt/nixhome") } } @@ -396,7 +414,7 @@ func TestThinBuildArgv_ProfileSymlinkResolvesToStorePath(t *testing.T) { // A store path only reachable through a symlink baked inside an image is not // a GC root — `nix-collect-garbage` on the shared volume can't see through -// image layers. Without a per-stack GC root, a later cleanup wipes the +// image layers. Without a GC root, a later cleanup wipes the // home-manager-path our container depends on, breaking every already-built // image the next time it's started fresh. // @@ -409,62 +427,67 @@ func TestThinBuildArgv_ProfilePinnedAsGCRoot(t *testing.T) { if !strings.Contains(script, "/nix/var/nix/gcroots/devcell/") { t.Error("builder must register the resolved home-manager profile as a GC root under /nix/var/nix/gcroots/devcell/ so the shared-volume GC does not reap it") } - - // Per-project + per-stack GC root name — different projects and stacks must - // not clobber each other's roots (CELL-320). - if !strings.Contains(script, "/nix/var/nix/gcroots/devcell/testproj-local-profile") { - t.Error("GC root name must be project-scoped: --profile") - } } -// CELL-320: GC roots must be scoped by project name so containers from -// different projects don't overwrite each other's roots. Without project -// scoping, the last project to build wins — its ln -sfT overwrites the -// previous project's root, and the next nix-collect-garbage reaps the -// now-unrooted derivations. -func TestThinBuildArgv_ProjectScopedGCRoots(t *testing.T) { +// CELL-331: GC roots are keyed by the nix store path hash — the first +// component of basename($HM_PROFILE). This encodes stack + modules + arch + +// nixpkgs revision, so identical configs naturally dedupe while different +// configs never clobber each other. Project name is NOT in the root name. +func TestThinBuildArgv_HashKeyedGCRoots(t *testing.T) { argv := ThinBuildArgvFull(testCoreImage, testContainer, testVolume, testNixhome, testThinTag, "local", "x86_64", testStack, "", "myproject") script := argv[len(argv)-1] - // Profile root must include project name. - if !strings.Contains(script, "/nix/var/nix/gcroots/devcell/myproject-local-profile") { - t.Error("GC root for profile must be project-scoped: /nix/var/nix/gcroots/devcell/--profile") + // Must derive hash from store path. + if !strings.Contains(script, `HM_PROFILE_HASH=$(basename "$HM_PROFILE" | cut -d- -f1)`) { + t.Error("builder must extract store path hash from HM_PROFILE basename") + } + + // Root name must use the hash, not the project name. + if !strings.Contains(script, `gcroots/devcell/${HM_PROFILE_HASH}-profile`) { + t.Error("GC root for profile must be hash-keyed: ${HM_PROFILE_HASH}-profile") + } + if !strings.Contains(script, `gcroots/devcell/${HM_PROFILE_HASH}-generation`) { + t.Error("GC root for generation must be hash-keyed: ${HM_PROFILE_HASH}-generation") } - // Generation root must include project name. - if !strings.Contains(script, "/nix/var/nix/gcroots/devcell/myproject-local-generation") { - t.Error("GC root for generation must be project-scoped: /nix/var/nix/gcroots/devcell/--generation") + // Must NOT contain project name in root path. + if strings.Contains(script, "gcroots/devcell/myproject-") { + t.Error("GC root name must NOT include project name (CELL-331 — hash-keyed)") } } -// Two different projects must produce different GC root paths. -func TestThinBuildArgv_DifferentProjectsDifferentRoots(t *testing.T) { +// CELL-331: identical configs from different projects produce the same +// store path hash → same GC root symlinks (only metadata differs). +func TestThinBuildArgv_IdenticalConfigsDedupeRoots(t *testing.T) { argvA := ThinBuildArgvFull(testCoreImage, testContainer, testVolume, testNixhome, testThinTag, "local", "x86_64", testStack, "", "alpha") argvB := ThinBuildArgvFull(testCoreImage, testContainer, testVolume, testNixhome, testThinTag, "local", "x86_64", testStack, "", "beta") scriptA := argvA[len(argvA)-1] scriptB := argvB[len(argvB)-1] - if !strings.Contains(scriptA, "alpha-local-profile") { - t.Error("project alpha must have its own GC root") + // Both scripts must use the same hash-based root naming (ln -sfT lines). + // The metadata file contains the project name so it differs, but the + // root symlinks themselves are identical. + rootLineA := extractBetween(scriptA, "HM_PROFILE_HASH=", "cat >") + rootLineB := extractBetween(scriptB, "HM_PROFILE_HASH=", "cat >") + if rootLineA == "" { + t.Fatal("could not extract GC root section from script A") } - if !strings.Contains(scriptB, "beta-local-profile") { - t.Error("project beta must have its own GC root") - } - if strings.Contains(scriptA, "beta-local") { - t.Error("project alpha must not reference project beta's root") + if rootLineA != rootLineB { + t.Error("identical configs must produce identical GC root symlinks (hash-keyed, not project-keyed)") } } -// Arch suffix must appear in project-scoped GC root names. -func TestThinBuildArgv_ProjectScopedGCRootsWithArch(t *testing.T) { - argv := ThinBuildArgvFull(testCoreImage, testContainer, testVolume, testNixhome, testThinTag, "local", "aarch64", testStack, "", "myproject") +// CELL-331: metadata file is stamped alongside the GC root so the reaper +// can attribute roots to projects and detect lock drift. +func TestThinBuildArgv_StampsMetadataFile(t *testing.T) { + argv := ThinBuildArgvFull(testCoreImage, testContainer, testVolume, testNixhome, testThinTag, "local", "x86_64", testStack, "", "myproject") script := argv[len(argv)-1] - if !strings.Contains(script, "/nix/var/nix/gcroots/devcell/myproject-local-aarch64-profile") { - t.Error("GC root for profile must include arch suffix for aarch64") + if !strings.Contains(script, "${HM_PROFILE_HASH}-meta") { + t.Error("builder must stamp a ${HM_PROFILE_HASH}-meta file alongside the GC root") } - if !strings.Contains(script, "/nix/var/nix/gcroots/devcell/myproject-local-aarch64-generation") { - t.Error("GC root for generation must include arch suffix for aarch64") + if !strings.Contains(script, "myproject") { + t.Error("metadata file must contain the project name for attribution") } } @@ -473,6 +496,18 @@ func TestThinBuildArgv_ProjectScopedGCRootsWithArch(t *testing.T) { // by every `home-manager switch` from any container — leaving it on PATH // defeats the whole store-path-symlink fix because PATH lookup would still // resolve tools through the mutable slot before the immutable one. +func extractBetween(s, start, end string) string { + i := strings.Index(s, start) + if i < 0 { + return "" + } + j := strings.Index(s[i:], end) + if j < 0 { + return s[i:] + } + return s[i : i+j] +} + func TestThinBuildArgv_RuntimePathExcludesSharedProfileSlot(t *testing.T) { argv := ThinBuildArgv(testCoreImage, testContainer, testVolume, testNixhome, testThinTag, testStack, "x86_64") script := argv[len(argv)-1] @@ -542,7 +577,7 @@ func TestThinBuildArgv_BakesNixLdInterpreter(t *testing.T) { // -v :/opt/nixhome mount, --flake points at the github URL. func TestThinBuildArgv_RemoteRefSkipsNixhomeMount(t *testing.T) { - const remoteRef = "github:DimmKirr/devcell/main?dir=nixhome" + const remoteRef = "github:devcell-sh/community-home/main" argv := ThinBuildArgv(testCoreImage, testContainer, testVolume, remoteRef, testThinTag, testStack, "x86_64") for i, a := range argv { if a == "-v" && i+1 < len(argv) && strings.HasSuffix(argv[i+1], ":/opt/nixhome") { @@ -552,7 +587,7 @@ func TestThinBuildArgv_RemoteRefSkipsNixhomeMount(t *testing.T) { } func TestThinBuildArgv_RemoteRefUsedInHomeManagerSwitch(t *testing.T) { - const remoteRef = "github:DimmKirr/devcell/main?dir=nixhome" + const remoteRef = "github:devcell-sh/community-home/main" argv := ThinBuildArgv(testCoreImage, testContainer, testVolume, remoteRef, testThinTag, testStack, "x86_64") script := argv[len(argv)-1] want := "home-manager switch --flake " + remoteRef + "#devcell-" + testStack @@ -561,18 +596,13 @@ func TestThinBuildArgv_RemoteRefUsedInHomeManagerSwitch(t *testing.T) { } } -func TestThinBuildArgv_LocalPathStillMounts(t *testing.T) { +func TestThinBuildArgv_LocalPathDoesNotUseDaemonBind(t *testing.T) { argv := ThinBuildArgv(testCoreImage, testContainer, testVolume, testNixhome, testThinTag, testStack, "x86_64") - found := false for i, a := range argv { if a == "-v" && i+1 < len(argv) && argv[i+1] == testNixhome+":/opt/nixhome" { - found = true - break + t.Fatalf("local nixhome must be streamed, not daemon bind-mounted: %v", argv) } } - if !found { - t.Errorf("local path must still mount -v %s:/opt/nixhome", testNixhome) - } } // CELL-41: thin build must thread the user-facing stack name and modules @@ -768,3 +798,391 @@ func TestThinBuildArgv_BakesNixLdEnv(t *testing.T) { } } +// CELL-358: sudo lives in the nix store at 0555 and the store is a shared, +// immutable volume — it can never carry a setuid bit. The entrypoint installs +// a setuid copy at /run/wrappers/bin/sudo (NixOS security-wrappers pattern), +// so that dir must precede the devcell-tools profile on PATH or the +// non-setuid profile sudo shadows the wrapper and every `sudo` call fails. +func TestThinBuildArgv_WrapperDirPrecedesProfileOnPath(t *testing.T) { + argv := ThinBuildArgv(testCoreImage, testContainer, testVolume, testNixhome, testThinTag, testStack, "x86_64") + script := argv[len(argv)-1] + var envPath string + for _, line := range strings.Split(script, "\n") { + if strings.HasPrefix(strings.TrimSpace(line), "ENV PATH=") { + envPath = line + break + } + } + if envPath == "" { + t.Fatal("inner Dockerfile must set ENV PATH") + } + wrapper := strings.Index(envPath, "/run/wrappers/bin") + if wrapper == -1 { + t.Fatal("inner Dockerfile must put /run/wrappers/bin on PATH for the setuid sudo wrapper") + } + profile := strings.Index(envPath, "/nix/var/nix/profiles/devcell-tools/bin") + if profile != -1 && wrapper > profile { + t.Error("/run/wrappers/bin must come BEFORE /nix/var/nix/profiles/devcell-tools/bin on PATH — otherwise the non-setuid profile sudo wins and sudo is broken") + } +} + +// argvFlagValue returns the value following the named flag in argv, or "" if +// the flag is absent. Only handles the separate-token form (`--memory 8g`), +// which is what ThinBuildArgvFull emits. +func argvFlagValue(argv []string, flag string) string { + for i, a := range argv { + if a == flag && i+1 < len(argv) { + return argv[i+1] + } + } + return "" +} + +// argvEnvValue returns the value of a `-e KEY=VALUE` pair in argv, or "" if the +// key is absent. +func argvEnvValue(argv []string, key string) string { + for i, a := range argv { + if a != "-e" || i+1 >= len(argv) { + continue + } + if v, ok := strings.CutPrefix(argv[i+1], key+"="); ok { + return v + } + } + return "" +} + +// withCapacity stubs the build daemon's advertised capacity for one test. +func withCapacity(t *testing.T, ncpu int, memBytes int64, ok bool) { + t.Helper() + prev := dockerCapacityFn + dockerCapacityFn = func() (DockerCapacity, bool) { + return DockerCapacity{NCPU: ncpu, MemBytes: memBytes}, ok + } + t.Cleanup(func() { dockerCapacityFn = prev }) +} + +const ( + bigHostCPU = 8 + bigHostMem = 25 << 30 // 25 GiB — a Docker Desktop-sized daemon +) + +func TestThinBuildArgv_CapsMemoryOnRoomyHost(t *testing.T) { + withCapacity(t, bigHostCPU, bigHostMem, true) + argv := ThinBuildArgv(testCoreImage, testContainer, testVolume, testNixhome, testThinTag, testStack, "aarch64") + // ¾ of 25 GiB, rounded down to whole GiB. + if got := argvFlagValue(argv, "--memory"); got != "18g" { + t.Errorf("thin build must cap memory at ¾ of the daemon so a nix build spike cannot starve sibling cells; --memory = %q, want 18g", got) + } +} + +func TestThinBuildArgv_NoCPUQuotaByDefault(t *testing.T) { + withCapacity(t, bigHostCPU, bigHostMem, true) + argv := ThinBuildArgv(testCoreImage, testContainer, testVolume, testNixhome, testThinTag, testStack, "aarch64") + if got := argvFlagValue(argv, "--cpus"); got != "" { + t.Errorf("no CFS quota by default — the memory ceiling is the OOM guard; --cpus = %q, want none", got) + } +} + +// The default ceiling is ¾ of the daemon's memory, and nix's concurrency is +// derived so maxJobs × cores saturates the CPU budget. +func TestThinBuildArgv_DefaultCeilingDerivesConcurrency(t *testing.T) { + t.Setenv("DEVCELL_NIX_MAX_JOBS", "") + t.Setenv("DEVCELL_NIX_CORES", "") + + t.Run("8 GiB colima — one job with every core", func(t *testing.T) { + withCapacity(t, 8, 8<<30, true) + argv := ThinBuildArgv(testCoreImage, testContainer, testVolume, testNixhome, testThinTag, testStack, "aarch64") + if got := argvFlagValue(argv, "--memory"); got != "6g" { + t.Errorf("--memory = %q, want 6g (¾ of 8 GiB)", got) + } + if got := argvEnvValue(argv, "DEVCELL_NIX_MAX_JOBS"); got != "1" { + t.Errorf("6 GiB is under one %d GiB job budget; max-jobs = %q, want 1", memGiBPerBuildJob, got) + } + if got := argvEnvValue(argv, "DEVCELL_NIX_CORES"); got != "8" { + t.Errorf("cores = %q, want 8 (single job gets every CPU)", got) + } + }) + + t.Run("24 GiB docker desktop — two jobs splitting the CPUs", func(t *testing.T) { + withCapacity(t, 8, 24<<30, true) + argv := ThinBuildArgv(testCoreImage, testContainer, testVolume, testNixhome, testThinTag, testStack, "aarch64") + if got := argvFlagValue(argv, "--memory"); got != "18g" { + t.Errorf("--memory = %q, want 18g (¾ of 24 GiB)", got) + } + if got := argvEnvValue(argv, "DEVCELL_NIX_MAX_JOBS"); got != "2" { + t.Errorf("an 18 GiB ceiling budgets 2 x %d GiB jobs; max-jobs = %q, want 2", memGiBPerBuildJob, got) + } + if got := argvEnvValue(argv, "DEVCELL_NIX_CORES"); got != "4" { + t.Errorf("cores = %q, want 4 (8 CPUs / 2 jobs)", got) + } + }) +} + +// Regression for the CELL-359 OOM: the builder died with +// +// setup-hook: line 3: 33 Killed npm ci --ignore-scripts ... +// +// which is a cgroup SIGKILL (exit 137), not an npm error. --cpus is a CFS +// bandwidth quota, NOT a core count: nproc inside a `--cpus 4` container still +// reports every host CPU, so `max-jobs = auto` kept scheduling one job per host +// CPU — 8 concurrent derivations sharing an 8 GiB ceiling. A memory ceiling is +// only safe if nix's job count is derived from it. +func TestThinBuildArgv_PinsNixMaxJobsToMemoryCeiling(t *testing.T) { + t.Setenv("DEVCELL_NIX_MAX_JOBS", "") + withCapacity(t, bigHostCPU, bigHostMem, true) + argv := ThinBuildArgv(testCoreImage, testContainer, testVolume, testNixhome, testThinTag, testStack, "aarch64") + + mem := argvFlagValue(argv, "--memory") + memBytes, ok := parseMemoryLimit(mem) + if !ok { + t.Fatalf("expected a memory ceiling to be emitted, got %q", mem) + } + jobs := argvEnvValue(argv, "DEVCELL_NIX_MAX_JOBS") + if jobs == "" { + t.Fatal("a --memory ceiling without a matching max-jobs lets nix schedule one job per host CPU inside it — this is the OOM that killed npm ci") + } + n, err := strconv.Atoi(jobs) + if err != nil || n < 1 { + t.Fatalf("DEVCELL_NIX_MAX_JOBS = %q, want a positive integer", jobs) + } + if n > bigHostCPU { + t.Errorf("max-jobs %d exceeds the daemon's %d CPUs", n, bigHostCPU) + } + // Every parallel job must have a real memory budget under the ceiling. + if perJob := memBytes / int64(n); perJob < memGiBPerBuildJob<<30 { + t.Errorf("max-jobs=%d under a %s ceiling budgets %.2f GiB/job; heavy derivations (chromium, texlive, npm ci over the AWS SDK tree) need >= %d GiB", + n, mem, float64(perJob)/(1<<30), memGiBPerBuildJob) + } +} + +// Bounding max-jobs alone is not enough: nix's `cores` defaults to 0, meaning +// "use every CPU", so each of N jobs forks make -j and nproc ignores the +// --cpus quota. The CPU budget has to be spread across the jobs. +func TestThinBuildArgv_PinsNixCoresToCPUCeiling(t *testing.T) { + t.Setenv("DEVCELL_NIX_CORES", "") + withCapacity(t, bigHostCPU, bigHostMem, true) + argv := ThinBuildArgv(testCoreImage, testContainer, testVolume, testNixhome, testThinTag, testStack, "aarch64") + + cores := argvEnvValue(argv, "DEVCELL_NIX_CORES") + if cores == "" { + t.Fatal("a --cpus ceiling without a matching nix cores lets every job fork make -j, which ignores the CFS quota") + } + c, err := strconv.Atoi(cores) + if err != nil || c < 1 { + t.Fatalf("DEVCELL_NIX_CORES = %q, want a positive integer", cores) + } + jobs, err := strconv.Atoi(argvEnvValue(argv, "DEVCELL_NIX_MAX_JOBS")) + if err != nil { + t.Fatalf("max-jobs not emitted alongside cores: %v", err) + } + if jobs*c > bigHostCPU { + t.Errorf("max-jobs=%d x cores=%d = %d exceeds the daemon's %d CPUs", jobs, c, jobs*c, bigHostCPU) + } +} + +// nix.conf must actually read the value we pass, or pinning it does nothing. +func TestThinBuildArgv_NixConfDeclaresCores(t *testing.T) { + argv := ThinBuildArgv(testCoreImage, testContainer, testVolume, testNixhome, testThinTag, testStack, "aarch64") + script := argv[len(argv)-1] + if !strings.Contains(script, "cores = ${DEVCELL_NIX_CORES:-0}") { + t.Error("nix.conf must set cores from DEVCELL_NIX_CORES (default 0 = nix's own default)") + } +} + +// No ceiling means the VM total is the budget, which is what `auto` was always +// sized against. Pinning concurrency there would only slow builds down. +func TestThinBuildArgv_LeavesNixConcurrencyAutoWhenUncapped(t *testing.T) { + t.Setenv("DEVCELL_NIX_MAX_JOBS", "") + t.Setenv("DEVCELL_NIX_CORES", "") + withCapacity(t, 0, 0, false) + argv := ThinBuildArgv(testCoreImage, testContainer, testVolume, testNixhome, testThinTag, testStack, "aarch64") + if got := argvEnvValue(argv, "DEVCELL_NIX_MAX_JOBS"); got != "" { + t.Errorf("max-jobs must stay auto when no ceiling is in force, got %q", got) + } + if got := argvEnvValue(argv, "DEVCELL_NIX_CORES"); got != "" { + t.Errorf("cores must stay at nix's default when no ceiling is in force, got %q", got) + } +} + +func TestThinBuildArgv_ExplicitNixConcurrencyWins(t *testing.T) { + t.Setenv("DEVCELL_NIX_MAX_JOBS", "1") + t.Setenv("DEVCELL_NIX_CORES", "3") + withCapacity(t, bigHostCPU, bigHostMem, true) + argv := ThinBuildArgv(testCoreImage, testContainer, testVolume, testNixhome, testThinTag, testStack, "aarch64") + if got := argvEnvValue(argv, "DEVCELL_NIX_MAX_JOBS"); got != "1" { + t.Errorf("an explicit DEVCELL_NIX_MAX_JOBS must win over the derived value; got %q", got) + } + if got := argvEnvValue(argv, "DEVCELL_NIX_CORES"); got != "3" { + t.Errorf("an explicit DEVCELL_NIX_CORES must win over the derived value; got %q", got) + } +} + +// On a daemon with no more RAM than the (explicit) ceiling, the VM itself is +// the binding constraint: capping protects nothing and would only OOM the +// build sooner. The CPU quota goes with it — a quota alone cannot prevent an +// OOM, it only slows the build down, so a lone --cpus is pure loss. +func TestThinBuildArgv_DropsCeilingsAsAPairOnASmallDaemon(t *testing.T) { + t.Setenv("DEVCELL_NIX_MAX_JOBS", "") + t.Setenv("DEVCELL_NIX_CORES", "") + t.Setenv("DEVCELL_BUILD_MEMORY", "4g") + t.Setenv("DEVCELL_BUILD_CPUS", "4") + withCapacity(t, bigHostCPU, 4<<30, true) // 4g ceiling >= 4 GiB daemon total + argv := ThinBuildArgv(testCoreImage, testContainer, testVolume, testNixhome, testThinTag, testStack, "aarch64") + if got := argvFlagValue(argv, "--memory"); got != "" { + t.Errorf("a 4 GiB daemon cannot honour a 4g ceiling; --memory must be omitted, got %q", got) + } + if got := argvFlagValue(argv, "--cpus"); got != "" { + t.Errorf("a CPU quota alone cannot prevent an OOM, it only slows the build; --cpus must be omitted too, got %q", got) + } + if got := argvEnvValue(argv, "DEVCELL_NIX_MAX_JOBS"); got != "" { + t.Errorf("with no ceiling in force max-jobs must stay auto, got %q", got) + } +} + +func TestNixConcurrency(t *testing.T) { + cases := []struct { + name string + memBytes int64 + cpuQuota float64 + ncpu int + wantJobs int + wantCores int + }{ + // A small ceiling buys one job, which gets the whole CPU quota. + {"small ceiling", 4 << 30, 4, 8, 1, 4}, + // A raised ceiling buys more jobs; the CPU budget is split between them. + {"raised ceiling", 16 << 30, 4, 8, 2, 2}, + {"raised ceiling, no cpu quota", 16 << 30, 0, 8, 2, 4}, + // Memory is the binding constraint even when CPUs are plentiful — + // the single job then gets every CPU. + {"memory-bound", 6 << 30, 8, 8, 1, 8}, + // ...and vice versa on a stock 2-CPU Colima VM. + {"cpu-bound", 64 << 30, 2, 2, 2, 1}, + // A ceiling under one job's budget still has to run one job. + {"never zero jobs", 1 << 30, 1, 1, 1, 1}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + jobs, cores := nixConcurrency(c.memBytes, c.cpuQuota, c.ncpu) + if jobs != c.wantJobs || cores != c.wantCores { + t.Errorf("nixConcurrency(%d, %v, %d) = (%d, %d), want (%d, %d)", + c.memBytes, c.cpuQuota, c.ncpu, jobs, cores, c.wantJobs, c.wantCores) + } + }) + } +} + +// Regression: a stock Colima VM advertises 2 CPUs. Emitting the 4-CPU default +// there makes dockerd hard-fail with "range of CPUs is from 0.01 to 2.00" and +// exit 125 — the build never starts. A ceiling at or above what the daemon +// has constrains nothing, so it must not be emitted at all. +func TestThinBuildArgv_OmitsCPUCapExceedingDaemonCPUs(t *testing.T) { + withCapacity(t, 2, 2<<30, true) + argv := ThinBuildArgv(testCoreImage, testContainer, testVolume, testNixhome, testThinTag, testStack, "aarch64") + if got := argvFlagValue(argv, "--cpus"); got != "" { + t.Errorf("a 2-CPU daemon cannot honour a 4-CPU ceiling; --cpus must be omitted, got %q", got) + } +} + +// Same reasoning for memory: an explicit cap at or above the VM's own total +// cannot protect anything, and a cap below it would only OOM the build sooner. +func TestThinBuildArgv_OmitsMemoryCapExceedingDaemonMemory(t *testing.T) { + t.Setenv("DEVCELL_BUILD_MEMORY", "2g") + withCapacity(t, 2, 2<<30, true) + argv := ThinBuildArgv(testCoreImage, testContainer, testVolume, testNixhome, testThinTag, testStack, "aarch64") + if got := argvFlagValue(argv, "--memory"); got != "" { + t.Errorf("a 2 GiB daemon cannot honour a 2g ceiling; --memory must be omitted, got %q", got) + } +} + +// An explicit override is still clamped — the point is that dockerd never +// receives an argument it will reject. +func TestThinBuildArgv_ClampsExplicitCPUOverride(t *testing.T) { + t.Setenv("DEVCELL_BUILD_CPUS", "16") + withCapacity(t, bigHostCPU, bigHostMem, true) + argv := ThinBuildArgv(testCoreImage, testContainer, testVolume, testNixhome, testThinTag, testStack, "aarch64") + if got := argvFlagValue(argv, "--cpus"); got != "" { + t.Errorf("16 CPUs on an 8-CPU daemon must be omitted, got %q", got) + } +} + +// If the daemon cannot be probed, emit nothing rather than guess — an absent +// cap is the long-standing behaviour and never fails the run. +func TestThinBuildArgv_OmitsCapsWhenProbeFails(t *testing.T) { + withCapacity(t, 0, 0, false) + argv := ThinBuildArgv(testCoreImage, testContainer, testVolume, testNixhome, testThinTag, testStack, "aarch64") + if got := argvFlagValue(argv, "--memory"); got != "" { + t.Errorf("--memory must be omitted when capacity is unknown, got %q", got) + } + if got := argvFlagValue(argv, "--cpus"); got != "" { + t.Errorf("--cpus must be omitted when capacity is unknown, got %q", got) + } +} + +func TestThinBuildArgv_MemoryCapOverridableByEnv(t *testing.T) { + t.Setenv("DEVCELL_BUILD_MEMORY", "12g") + withCapacity(t, bigHostCPU, bigHostMem, true) + argv := ThinBuildArgv(testCoreImage, testContainer, testVolume, testNixhome, testThinTag, testStack, "aarch64") + if got := argvFlagValue(argv, "--memory"); got != "12g" { + t.Errorf("DEVCELL_BUILD_MEMORY must override the default; --memory = %q, want 12g", got) + } +} + +func TestThinBuildArgv_CPUCapOverridableByEnv(t *testing.T) { + t.Setenv("DEVCELL_BUILD_CPUS", "2") + withCapacity(t, bigHostCPU, bigHostMem, true) + argv := ThinBuildArgv(testCoreImage, testContainer, testVolume, testNixhome, testThinTag, testStack, "aarch64") + if got := argvFlagValue(argv, "--cpus"); got != "2" { + t.Errorf("DEVCELL_BUILD_CPUS must override the default; --cpus = %q, want 2", got) + } +} + +func TestThinBuildArgv_ZeroDisablesCaps(t *testing.T) { + t.Setenv("DEVCELL_BUILD_MEMORY", "0") + t.Setenv("DEVCELL_BUILD_CPUS", "0") + withCapacity(t, bigHostCPU, bigHostMem, true) + argv := ThinBuildArgv(testCoreImage, testContainer, testVolume, testNixhome, testThinTag, testStack, "aarch64") + if got := argvFlagValue(argv, "--memory"); got != "" { + t.Errorf("DEVCELL_BUILD_MEMORY=0 must emit no --memory flag, got %q", got) + } + if got := argvFlagValue(argv, "--cpus"); got != "" { + t.Errorf("DEVCELL_BUILD_CPUS=0 must emit no --cpus flag, got %q", got) + } +} + +// --memory-swap stays unset so Docker keeps its 2x default: pinning it equal to +// --memory would *disable* spill on the hosts that do have swap. It must not be +// mistaken for headroom though — Lima and Docker Desktop VMs both run swapless +// (SwapTotal: 0), so the ceiling is hard and has to be generous on its own. +func TestThinBuildArgv_LeavesMemorySwapUnset(t *testing.T) { + withCapacity(t, bigHostCPU, bigHostMem, true) + argv := ThinBuildArgv(testCoreImage, testContainer, testVolume, testNixhome, testThinTag, testStack, "aarch64") + if got := argvFlagValue(argv, "--memory-swap"); got != "" { + t.Errorf("--memory-swap must stay unset to keep Docker's 2x default, got %q", got) + } +} + +func TestParseMemoryLimit(t *testing.T) { + cases := []struct { + in string + want int64 + ok bool + }{ + {"8g", 8 << 30, true}, + {"8G", 8 << 30, true}, + {"8gb", 8 << 30, true}, + {"512m", 512 << 20, true}, + {"1024k", 1024 << 10, true}, + {"2048", 2048, true}, + {"", 0, false}, + {"lots", 0, false}, + {"8x", 0, false}, + } + for _, c := range cases { + got, ok := parseMemoryLimit(c.in) + if ok != c.ok || got != c.want { + t.Errorf("parseMemoryLimit(%q) = (%d, %v), want (%d, %v)", c.in, got, ok, c.want, c.ok) + } + } +} diff --git a/internal/runner/thin_context.go b/internal/runner/thin_context.go new file mode 100644 index 0000000..5cb0226 --- /dev/null +++ b/internal/runner/thin_context.go @@ -0,0 +1,79 @@ +package runner + +import ( + "archive/tar" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" +) + +// WriteThinBuildContext writes the local nixhome overlay needed by the thin +// builder. Streaming this through the Docker API avoids asking a VM-backed +// daemon to resolve a host path for a second-generation container. +func WriteThinBuildContext(w io.Writer, buildDir string) error { + tw := tar.NewWriter(w) + + for _, root := range []string{"flake.nix", "entrypoint.sh", "nixhome"} { + if err := writeThinContextPath(tw, buildDir, root); err != nil { + _ = tw.Close() + return err + } + } + return tw.Close() +} + +func writeThinContextPath(tw *tar.Writer, buildDir, root string) error { + fullRoot := filepath.Join(buildDir, root) + if _, err := os.Lstat(fullRoot); err != nil { + return fmt.Errorf("thin build context %s: %w", fullRoot, err) + } + + return filepath.WalkDir(fullRoot, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + info, err := entry.Info() + if err != nil { + return err + } + + link := "" + if info.Mode()&os.ModeSymlink != 0 { + link, err = os.Readlink(path) + if err != nil { + return err + } + } + header, err := tar.FileInfoHeader(info, link) + if err != nil { + return err + } + rel, err := filepath.Rel(buildDir, path) + if err != nil { + return err + } + header.Name = filepath.ToSlash(rel) + if info.IsDir() { + header.Name += "/" + } + if err := tw.WriteHeader(header); err != nil { + return err + } + if !info.Mode().IsRegular() { + return nil + } + + f, err := os.Open(path) + if err != nil { + return err + } + _, copyErr := io.Copy(tw, f) + closeErr := f.Close() + if copyErr != nil { + return copyErr + } + return closeErr + }) +} diff --git a/internal/runner/thin_context_test.go b/internal/runner/thin_context_test.go new file mode 100644 index 0000000..bc45a43 --- /dev/null +++ b/internal/runner/thin_context_test.go @@ -0,0 +1,73 @@ +package runner + +import ( + "archive/tar" + "bytes" + "io" + "os" + "path/filepath" + "testing" +) + +func TestWriteThinBuildContextIncludesOnlyOverlay(t *testing.T) { + dir := t.TempDir() + mustWriteThinTestFile(t, filepath.Join(dir, "flake.nix"), "outer") + mustWriteThinTestFile(t, filepath.Join(dir, "nixhome", "flake.nix"), "inner") + mustWriteThinTestFile(t, filepath.Join(dir, "nixhome", "entrypoint.sh"), "#!/bin/sh") + mustWriteThinTestFile(t, filepath.Join(dir, "debug", "large.log"), "exclude") + if err := os.Symlink("nixhome/entrypoint.sh", filepath.Join(dir, "entrypoint.sh")); err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + if err := WriteThinBuildContext(&buf, dir); err != nil { + t.Fatal(err) + } + + got := make(map[string]byte) + tr := tar.NewReader(&buf) + for { + h, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + t.Fatal(err) + } + got[h.Name] = h.Typeflag + } + for _, want := range []string{ + "flake.nix", + "entrypoint.sh", + "nixhome/", + "nixhome/flake.nix", + "nixhome/entrypoint.sh", + } { + if _, ok := got[want]; !ok { + t.Errorf("archive missing %q; entries: %v", want, got) + } + } + if _, ok := got["debug/large.log"]; ok { + t.Error("archive must not include unrelated .devcell/debug content") + } + if got["entrypoint.sh"] != tar.TypeSymlink { + t.Errorf("entrypoint.sh type = %d, want symlink", got["entrypoint.sh"]) + } +} + +func TestWriteThinBuildContextRequiresFlake(t *testing.T) { + dir := t.TempDir() + if err := WriteThinBuildContext(io.Discard, dir); err == nil { + t.Fatal("missing outer flake.nix must fail before starting the builder") + } +} + +func mustWriteThinTestFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/internal/runner/thin_nixhome_test.go b/internal/runner/thin_nixhome_test.go index 96e9185..1541ee8 100644 --- a/internal/runner/thin_nixhome_test.go +++ b/internal/runner/thin_nixhome_test.go @@ -9,7 +9,7 @@ import ( // Thin path mirrors pure path's 3-tier nixhome resolution (CELL-38): // 1. TOML/env explicit // 2. /nixhome on disk -// 3. github:DimmKirr/devcell/?dir=nixhome fallback +// 3. github:devcell-sh/community-home/ fallback // // These tests pin the contract from the THIN caller's perspective — // pure_nixhome_resolver_test.go covers the resolver itself, this file @@ -24,7 +24,7 @@ func TestResolveThinNixhome_NoLocalFallsBackToGithub(t *testing.T) { if !got.Remote { t.Errorf("clean machine: want remote github fallback, got local: %+v", got) } - want := "github:DimmKirr/devcell/v1.2.3?dir=nixhome" + want := "github:devcell-sh/community-home/v1.2.3" if got.FlakeRef != want { t.Errorf("FlakeRef: want %q, got %q", want, got.FlakeRef) } @@ -52,7 +52,7 @@ func TestResolveThinNixhome_DevBuildCoercesToDefaultRef(t *testing.T) { if !got.Remote { t.Errorf("dev build clean machine: want remote, got local: %+v", got) } - want := "github:DimmKirr/devcell/" + runner.DefaultNixhomeGitRef + "?dir=nixhome" + want := "github:devcell-sh/community-home/" + runner.DefaultNixhomeGitRef if got.FlakeRef != want { t.Errorf("FlakeRef (v0.0.0 coerced): want %q, got %q", want, got.FlakeRef) } diff --git a/internal/runner/upstream.go b/internal/runner/upstream.go index b704a5b..974bf21 100644 --- a/internal/runner/upstream.go +++ b/internal/runner/upstream.go @@ -1,32 +1,65 @@ package runner -import "fmt" +import ( + "fmt" + "os" + "strings" +) // Canonical upstream nixhome source — single source of truth across the CLI. // Previously these constants were re-encoded in 4 separate fmt.Sprintf calls // (pure_nixhome_resolver, cmd/modules, scaffold templates). Centralised here // so a fork/rename is a one-line change. const ( - UpstreamOwner = "DimmKirr" - UpstreamRepo = "devcell" - UpstreamSubdir = "nixhome" + UpstreamOwner = "devcell-sh" + UpstreamRepo = "community-home" + // UpstreamSubdir is empty since the nixhome moved to its own repo + // (community-home); the flake now lives at the repo root. + UpstreamSubdir = "" ) // UpstreamFlakeRef returns the canonical github flake reference for the -// devcell nixhome, pinned to `ref`. Empty / "v0.0.0" (dev build) coerces to -// DefaultNixhomeGitRef so dev builds always point at a real branch. +// devcell nixhome, pinned to `ref`. Empty / "v0.0.0" / dev-version coerces +// to DefaultNixhomeGitRef so dev builds always point at a real branch. // -// Example: UpstreamFlakeRef("v1.0.0") → "github:DimmKirr/devcell/v1.0.0?dir=nixhome" +// Example: UpstreamFlakeRef("v1.0.0") → "github:devcell-sh/community-home/v1.0.0" func UpstreamFlakeRef(ref string) string { - if ref == "" || ref == "v0.0.0" { + if ref == "" || ref == "v0.0.0" || isDevVersion(ref) { ref = DefaultNixhomeGitRef } - return fmt.Sprintf("github:%s/%s/%s?dir=%s", UpstreamOwner, UpstreamRepo, ref, UpstreamSubdir) + s := fmt.Sprintf("github:%s/%s/%s", UpstreamOwner, UpstreamRepo, ref) + if UpstreamSubdir != "" { + s += "?dir=" + UpstreamSubdir + } + return s +} + +// ResolveNixhomeRef returns the nixhome source to use for builds. +// Precedence: DEVCELL_NIXHOME > DEVCELL_NIXHOME_PATH (legacy) > default upstream flake ref. +// Accepts local paths, github: flake refs, or https:// git URLs. +func ResolveNixhomeRef(ver string) string { + if v := os.Getenv("DEVCELL_NIXHOME"); v != "" { + return v + } + if v := os.Getenv("DEVCELL_NIXHOME_PATH"); v != "" { + return v + } + return UpstreamFlakeRef(ver) +} + +// isDevVersion returns true for git-describe versions that don't correspond +// to a real remote tag/branch (e.g. "v0.8.2-94-g0ac6be1-dirty"). +func isDevVersion(v string) bool { + return strings.Contains(v, "-g") || strings.Contains(v, "-dirty") } // UpstreamFlakeRefNoVersion returns the unpinned ref — used by introspection // commands (`cell modules list`) that want the catalog as it exists upstream // right now, not pinned to the CLI binary's compile-time version. func UpstreamFlakeRefNoVersion() string { - return fmt.Sprintf("github:%s/%s?dir=%s", UpstreamOwner, UpstreamRepo, UpstreamSubdir) + s := fmt.Sprintf("github:%s/%s", UpstreamOwner, UpstreamRepo) + if UpstreamSubdir != "" { + s += "?dir=" + UpstreamSubdir + } + return s } diff --git a/internal/runner/upstream_test.go b/internal/runner/upstream_test.go index c7485b5..e92e6b9 100644 --- a/internal/runner/upstream_test.go +++ b/internal/runner/upstream_test.go @@ -10,30 +10,75 @@ import ( // Replaces 4 scattered fmt.Sprintf calls that all encoded the same template. func TestUpstreamFlakeRef_ExplicitVersion(t *testing.T) { - if got := runner.UpstreamFlakeRef("v1.2.3"); got != "github:DimmKirr/devcell/v1.2.3?dir=nixhome" { + if got := runner.UpstreamFlakeRef("v1.2.3"); got != "github:devcell-sh/community-home/v1.2.3" { t.Errorf("got %q", got) } } func TestUpstreamFlakeRef_EmptyCoercesToDefault(t *testing.T) { - want := "github:DimmKirr/devcell/" + runner.DefaultNixhomeGitRef + "?dir=nixhome" + want := "github:devcell-sh/community-home/" + runner.DefaultNixhomeGitRef if got := runner.UpstreamFlakeRef(""); got != want { t.Errorf("got %q, want %q", got, want) } } func TestUpstreamFlakeRef_V000CoercesToDefault(t *testing.T) { - want := "github:DimmKirr/devcell/" + runner.DefaultNixhomeGitRef + "?dir=nixhome" + want := "github:devcell-sh/community-home/" + runner.DefaultNixhomeGitRef if got := runner.UpstreamFlakeRef("v0.0.0"); got != want { t.Errorf("got %q, want %q", got, want) } } +func TestUpstreamFlakeRef_DevVersionCoercesToDefault(t *testing.T) { + want := "github:devcell-sh/community-home/" + runner.DefaultNixhomeGitRef + for _, v := range []string{ + "v0.8.2-94-g0ac6be1-dirty", + "v1.0.0-3-gabcdef0", + "v2.0.0-dirty", + } { + if got := runner.UpstreamFlakeRef(v); got != want { + t.Errorf("UpstreamFlakeRef(%q) = %q, want %q", v, got, want) + } + } +} + +func TestResolveNixhomeRef_EnvOverride(t *testing.T) { + t.Setenv("DEVCELL_NIXHOME", "/home/user/my-nixhome") + if got := runner.ResolveNixhomeRef("v1.0.0"); got != "/home/user/my-nixhome" { + t.Errorf("got %q, want env override", got) + } +} + +func TestResolveNixhomeRef_LegacyPathFallback(t *testing.T) { + t.Setenv("DEVCELL_NIXHOME", "") + t.Setenv("DEVCELL_NIXHOME_PATH", "/Users/me/dev/community-home") + if got := runner.ResolveNixhomeRef("v1.0.0"); got != "/Users/me/dev/community-home" { + t.Errorf("got %q, want legacy DEVCELL_NIXHOME_PATH fallback", got) + } +} + +func TestResolveNixhomeRef_NewOverridesLegacy(t *testing.T) { + t.Setenv("DEVCELL_NIXHOME", "github:myuser/my-nixhome/dev") + t.Setenv("DEVCELL_NIXHOME_PATH", "/Users/me/dev/community-home") + if got := runner.ResolveNixhomeRef("v1.0.0"); got != "github:myuser/my-nixhome/dev" { + t.Errorf("got %q, want DEVCELL_NIXHOME to take precedence", got) + } +} + +func TestResolveNixhomeRef_DefaultsToUpstream(t *testing.T) { + t.Setenv("DEVCELL_NIXHOME", "") + t.Setenv("DEVCELL_NIXHOME_PATH", "") + want := runner.UpstreamFlakeRef("v1.0.0") + if got := runner.ResolveNixhomeRef("v1.0.0"); got != want { + t.Errorf("got %q, want %q", got, want) + } +} + func TestUpstreamFlakeRefNoVersion(t *testing.T) { // The "no specific ref" variant — for callers that want the catalog as it // exists upstream today (not pinned to a version). Used by `cell modules // list` and similar introspection. - if got := runner.UpstreamFlakeRefNoVersion(); got != "github:DimmKirr/devcell?dir=nixhome" { + if got := runner.UpstreamFlakeRefNoVersion(); got != "github:devcell-sh/community-home" { t.Errorf("got %q", got) } } diff --git a/internal/runner/vagrant.go b/internal/runner/vagrant.go index a0e9a94..6e9c94f 100644 --- a/internal/runner/vagrant.go +++ b/internal/runner/vagrant.go @@ -93,7 +93,7 @@ func shellJoinTokens(tokens []string) string { } // shellQuoteToken wraps a token in single quotes, escaping any embedded -// single quotes as '\''. Values that are already safe (no special chars) +// single quotes as '\”. Values that are already safe (no special chars) // are returned as-is for readability. func shellQuoteToken(s string) string { safe := true @@ -427,4 +427,3 @@ func vagrantRun(ctx context.Context, vagrantDir string, args ...string) error { } return nil } - diff --git a/internal/scaffold/generate_testdata_test.go b/internal/scaffold/generate_testdata_test.go index 6224305..f06cbf2 100644 --- a/internal/scaffold/generate_testdata_test.go +++ b/internal/scaffold/generate_testdata_test.go @@ -1,35 +1,19 @@ package scaffold_test import ( - "fmt" "os" - osexec "os/exec" "path/filepath" - "strings" "testing" - "time" "github.com/DimmKirr/devcell/internal/scaffold" + "github.com/DimmKirr/devcell/internal/testutil" ) -// shortSHA returns the abbreviated commit hash of HEAD. -func shortSHA() string { - cmd := osexec.Command("git", "rev-parse", "--short", "HEAD") - cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1") - out, err := cmd.Output() - if err != nil { - return fmt.Sprintf("dev%s", time.Now().Format("150405")) - } - return strings.TrimSpace(string(out)) -} - // TestGenerateTestdata writes generated flake.nix and Dockerfile variants to -// test/results/-/generate-testdata/ for manual and LLM-assisted review. +// test/results/-TestGenerateTestdata/ for manual and LLM-assisted review. // Run with: go test ./internal/scaffold/ -run TestGenerateTestdata -v func TestGenerateTestdata(t *testing.T) { - ts := time.Now().Format("20060102-150405") - runDir := filepath.Join("..", "..", "test", "results", fmt.Sprintf("%s-%s", ts, shortSHA())) - baseDir := filepath.Join(runDir, "generate-testdata") + baseDir := testutil.TestResultsDir(t, nil) cases := []struct { name string diff --git a/internal/scaffold/scaffold.go b/internal/scaffold/scaffold.go index e8d92c4..1dddefc 100644 --- a/internal/scaffold/scaffold.go +++ b/internal/scaffold/scaffold.go @@ -128,7 +128,8 @@ func generatePyprojectTOML(pkgs map[string]string) []byte { // and modules from the upstream devcell nixhome flake. // stack is a stack name (e.g. "go"), modules is a list of module names, // ver is the version tag, nixhomePath overrides the input URL to path:./nixhome. -func GenerateFlakeNix(stack string, modules []string, ver string, withNixhome bool) string { +// nixPkgs adds arbitrary nixpkgs packages with lib.hiPri (user override semantics). +func GenerateFlakeNix(stack string, modules []string, ver string, withNixhome bool, nixPkgs ...cfg.NixPackages) string { if stack == "" { stack = "base" } @@ -156,11 +157,30 @@ func GenerateFlakeNix(stack string, modules []string, ver string, withNixhome bo moduleExpr += fmt.Sprintf(" ++ [ { %s } ]", strings.Join(enableLines, " ")) } + // CELL-445: [packages.nix] — arbitrary user packages with lib.hiPri override. + var np cfg.NixPackages + if len(nixPkgs) > 0 { + np = nixPkgs[0] + } + if len(np.Stable) > 0 || len(np.Unstable) > 0 || len(np.Edge) > 0 { + var parts []string + if len(np.Stable) > 0 { + parts = append(parts, fmt.Sprintf("(map lib.hiPri (with pkgs; [ %s ]))", strings.Join(np.Stable, " "))) + } + if len(np.Unstable) > 0 { + parts = append(parts, fmt.Sprintf("(map lib.hiPri (with pkgsUnstable; [ %s ]))", strings.Join(np.Unstable, " "))) + } + if len(np.Edge) > 0 { + parts = append(parts, fmt.Sprintf("(map lib.hiPri (with pkgsEdge; [ %s ]))", strings.Join(np.Edge, " "))) + } + moduleExpr += fmt.Sprintf(" ++ [ { home.packages = %s; } ]", strings.Join(parts, " ++ ")) + } + return fmt.Sprintf(`{ description = "DevCell user stack — customise and run 'cell build'"; # Follows main branch by default. To pin a specific release: - # inputs.devcell.url = "github:DimmKirr/devcell/v1.0.0?dir=nixhome"; + # inputs.devcell.url = "github:devcell-sh/community-home/v1.0.0"; # To use your own nixhome fork: # inputs.devcell.url = "github:yourusername/nixhome"; inputs.devcell.url = %s; @@ -255,7 +275,7 @@ ENV PATH="/opt/python-tools/.venv/bin:${PATH}" // modelsSnippet is an optional commented-out [models] section for devcell.toml; // pass "" to use the default generic example. -const defaultNixhomeRepo = "https://github.com/DimmKirr/devcell.git" +const defaultNixhomeRepo = "https://github.com/devcell-sh/community-home.git" // IsGitURL returns true if source looks like a git URL or GitHub shorthand. func IsGitURL(source string) bool { @@ -282,8 +302,8 @@ func ResolveNixhome(source, buildDir, ver string, force bool) error { // Git source — always fetch latest. gs := parseGitSource(source) if gs.RepoURL == "" { - // No source provided — use upstream default with nixhome subdir. - gs = gitSource{RepoURL: defaultNixhomeRepo, Subdir: "nixhome"} + // No source provided — use upstream default (flake at repo root). + gs = gitSource{RepoURL: defaultNixhomeRepo} } ref := gs.Ref @@ -358,7 +378,7 @@ func ResolveNixhome(source, buildDir, ver string, force bool) error { // gitSource holds the parsed components of a git nixhome source. type gitSource struct { - RepoURL string // e.g. https://github.com/DimmKirr/devcell.git + RepoURL string // e.g. https://github.com/devcell-sh/community-home.git Ref string // branch/tag override (empty = use version default) Subdir string // subdirectory within repo (empty = repo root) } @@ -637,7 +657,7 @@ func RegenerateBuildContext(configDir string, cellCfg cfg.CellConfig) error { stack := cellCfg.Cell.ResolvedStack() // Regenerate flake.nix from stack + modules. - flake := GenerateFlakeNix(stack, cellCfg.Cell.Modules, version.Version, withNixhome) + flake := GenerateFlakeNix(stack, cellCfg.Cell.Modules, version.Version, withNixhome, cellCfg.Packages.Nix) if err := os.WriteFile(filepath.Join(configDir, "flake.nix"), []byte(flake), 0644); err != nil { return fmt.Errorf("write flake.nix: %w", err) } diff --git a/internal/scaffold/scaffold_test.go b/internal/scaffold/scaffold_test.go index a05e043..f9ac472 100644 --- a/internal/scaffold/scaffold_test.go +++ b/internal/scaffold/scaffold_test.go @@ -152,8 +152,8 @@ func TestScaffold_FlakeNixContainsUpstreamURL(t *testing.T) { t.Fatal(err) } data, _ := os.ReadFile(filepath.Join(dir, ".devcell", "flake.nix")) - if !strings.Contains(string(data), "DimmKirr/devcell") { - t.Errorf("flake.nix should reference DimmKirr/devcell, got:\n%s", string(data)) + if !strings.Contains(string(data), runner.UpstreamOwner+"/"+runner.UpstreamRepo) { + t.Errorf("flake.nix should reference %s/%s, got:\n%s", runner.UpstreamOwner, runner.UpstreamRepo, string(data)) } } @@ -169,8 +169,9 @@ func TestScaffold_FlakeNixVersionSubstituted(t *testing.T) { } // v0.0.0 (dev build) coerces to DefaultNixhomeGitRef via runner.UpstreamFlakeRef // — literal v0.0.0 would 404 against github (no such tag). - if !strings.Contains(s, "DimmKirr/devcell/"+runner.DefaultNixhomeGitRef+"?dir=nixhome") { - t.Errorf("flake.nix should contain coerced upstream URL, got:\n%s", s) + want := runner.UpstreamOwner + "/" + runner.UpstreamRepo + "/" + runner.DefaultNixhomeGitRef + if !strings.Contains(s, want) { + t.Errorf("flake.nix should contain coerced upstream URL %q, got:\n%s", want, s) } } @@ -572,8 +573,9 @@ func TestGenerateFlakeNix_VersionSubstituted(t *testing.T) { if strings.Contains(content, "{{VERSION}}") { t.Errorf("unreplaced {{VERSION}} placeholder:\n%s", content) } - if !strings.Contains(content, "DimmKirr/devcell/v2.3.4?dir=nixhome") { - t.Errorf("expected versioned URL with v2.3.4:\n%s", content) + want := runner.UpstreamOwner + "/" + runner.UpstreamRepo + "/v2.3.4" + if !strings.Contains(content, want) { + t.Errorf("expected versioned URL containing %q:\n%s", want, content) } } @@ -639,6 +641,52 @@ func TestGenerateFlakeNix_AllStacks(t *testing.T) { } } +// ── CELL-445: NixPackages in GenerateFlakeNix ─────────────────────────────── + +func TestGenerateFlakeNix_NixPackagesStable(t *testing.T) { + pkgs := cfg.NixPackages{Stable: []string{"tmux", "htop"}} + content := scaffold.GenerateFlakeNix("go", nil, "v1.0.0", false, pkgs) + if !strings.Contains(content, "map lib.hiPri (with pkgs; [ tmux htop ])") { + t.Errorf("expected hiPri stable packages:\n%s", content) + } +} + +func TestGenerateFlakeNix_NixPackagesAllTiers(t *testing.T) { + pkgs := cfg.NixPackages{ + Stable: []string{"tmux"}, + Unstable: []string{"tool-a"}, + Edge: []string{"edge-pkg"}, + } + content := scaffold.GenerateFlakeNix("base", nil, "v1.0.0", false, pkgs) + if !strings.Contains(content, "map lib.hiPri (with pkgs; [ tmux ])") { + t.Errorf("expected hiPri stable:\n%s", content) + } + if !strings.Contains(content, "map lib.hiPri (with pkgsUnstable; [ tool-a ])") { + t.Errorf("expected hiPri unstable:\n%s", content) + } + if !strings.Contains(content, "map lib.hiPri (with pkgsEdge; [ edge-pkg ])") { + t.Errorf("expected hiPri edge:\n%s", content) + } +} + +func TestGenerateFlakeNix_NixPackagesEmpty(t *testing.T) { + content := scaffold.GenerateFlakeNix("go", nil, "v1.0.0", false, cfg.NixPackages{}) + if strings.Contains(content, "lib.hiPri") { + t.Errorf("no hiPri expected when all tiers empty:\n%s", content) + } +} + +func TestGenerateFlakeNix_NixPackagesWithModules(t *testing.T) { + pkgs := cfg.NixPackages{Stable: []string{"cowsay"}} + content := scaffold.GenerateFlakeNix("go", []string{"electronics"}, "v1.0.0", false, pkgs) + if !strings.Contains(content, "devcell.modules.electronics") { + t.Errorf("expected modules still present:\n%s", content) + } + if !strings.Contains(content, "map lib.hiPri (with pkgs; [ cowsay ])") { + t.Errorf("expected hiPri stable packages alongside modules:\n%s", content) + } +} + // --- GenerateDockerfile --- // TestGenerateDockerfile_UsesLocalProfile — must reference devcell-local, not devcell-ultimate. diff --git a/internal/scaffold/templates/Vagrantfile.linux.tmpl b/internal/scaffold/templates/Vagrantfile.linux.tmpl index 232ad52..8b20852 100644 --- a/internal/scaffold/templates/Vagrantfile.linux.tmpl +++ b/internal/scaffold/templates/Vagrantfile.linux.tmpl @@ -176,7 +176,7 @@ Vagrant.configure("2") do |config| cat > /usr/local/bin/devcell-init << 'INITSCRIPT' #!/bin/bash # DevCell init — sources entrypoint fragments at VM boot. -# Mirrors images/entrypoint.sh with vagrant-appropriate env vars. +# Sets up vagrant-appropriate env vars for the devcell entrypoint. export HOST_USER=vagrant export HOME=/home/vagrant export USER=vagrant @@ -262,7 +262,7 @@ UNIT elif [ -d /opt/nixhome ]; then NIXHOME_FLAKE="/opt/nixhome" else - NIXHOME_FLAKE="github:DimmKirr/devcell?dir=nixhome" + NIXHOME_FLAKE="github:devcell-sh/community-home" echo "devcell: nixhome not found locally — fetching from GitHub" fi diff --git a/internal/scaffold/templates/devcell.project.toml.tmpl b/internal/scaffold/templates/devcell.project.toml.tmpl index b7b14ff..e5680fc 100644 --- a/internal/scaffold/templates/devcell.project.toml.tmpl +++ b/internal/scaffold/templates/devcell.project.toml.tmpl @@ -11,9 +11,13 @@ # Modules merge UNION with global ~/.config/devcell/devcell.toml — set explicit empty (modules = []) to clear global. # modules = ["electronics"] # -# gui = true # timezone = "Europe/Prague" +# GUI desktop (Xvfb + VNC + window manager). +# [gui] +# enabled = true +# wm = "icewm" # or "fluxbox" + # Project context injected into all AI agents (Claude, Codex, OpenCode). # Sits on top of CLAUDE.md — use for personal/local instructions not checked into the repo. # [llm] diff --git a/internal/scaffold/templates/devcell.toml.tmpl b/internal/scaffold/templates/devcell.toml.tmpl index cc13c5d..9090117 100644 --- a/internal/scaffold/templates/devcell.toml.tmpl +++ b/internal/scaffold/templates/devcell.toml.tmpl @@ -9,8 +9,6 @@ # graphics, infra, news, nixos, qa-tools, scraping, travel, go, node, python # modules = ["electronics", "desktop"] # -# Disable GUI (Xvfb + VNC + browser). GUI is enabled by default. -# gui = false # Timezone (IANA format). If omitted, inherits host $TZ. # timezone = "Europe/Prague" @@ -47,6 +45,11 @@ # [aws] # read_only = true +# GUI desktop (Xvfb + VNC + window manager). Enabled by default. +# [gui] +# enabled = true +# wm = "icewm" # or "fluxbox" + # Port forwarding from container to host. Bare port = same on both sides. # [ports] # forward = ["3000", "8080:3000"] diff --git a/internal/serve/claude_stream.go b/internal/serve/claude_stream.go index bba1b9b..2677ba7 100644 --- a/internal/serve/claude_stream.go +++ b/internal/serve/claude_stream.go @@ -37,11 +37,11 @@ type streamEnvelope struct { // streamInnerEvent is the Anthropic Messages-API event nested inside a // stream_event wrapper. type streamInnerEvent struct { - Type string `json:"type"` - Index int `json:"index,omitempty"` - Message *streamMessage `json:"message,omitempty"` // message_start - ContentBlock *streamContentBlock `json:"content_block,omitempty"` // content_block_start - Delta *streamDelta `json:"delta,omitempty"` // content_block_delta + message_delta share this field name with different shapes + Type string `json:"type"` + Index int `json:"index,omitempty"` + Message *streamMessage `json:"message,omitempty"` // message_start + ContentBlock *streamContentBlock `json:"content_block,omitempty"` // content_block_start + Delta *streamDelta `json:"delta,omitempty"` // content_block_delta + message_delta share this field name with different shapes } type streamMessage struct { diff --git a/internal/serve/exec.go b/internal/serve/exec.go index 68d4bab..0c4ead9 100644 --- a/internal/serve/exec.go +++ b/internal/serve/exec.go @@ -43,8 +43,11 @@ func claudeArgs(opts ExecOpts, format string) []string { if opts.Effort != "" { args = append(args, "--effort", opts.Effort) } - if opts.SystemPrompt != "" { - args = append(args, "--append-system-prompt", opts.SystemPrompt) + if opts.BasePromptFile != "" { + args = append(args, "--system-prompt-file", opts.BasePromptFile) + } + if opts.SystemPromptFile != "" { + args = append(args, "--append-system-prompt-file", opts.SystemPromptFile) } return args } diff --git a/internal/serve/exec_test.go b/internal/serve/exec_test.go index 339fc57..957e42c 100644 --- a/internal/serve/exec_test.go +++ b/internal/serve/exec_test.go @@ -135,22 +135,27 @@ func TestShellExecutor_OpenCodeIgnoresEffort(t *testing.T) { } } -func TestShellExecutor_ClaudeAppendsSystemPromptFlag(t *testing.T) { +func TestShellExecutor_ClaudeAppendsSystemPromptFileFlag(t *testing.T) { dir := makeStubAgent(t, "claude", "ok") withPath(t, dir) e := &ShellExecutor{} res := e.Run(ExecOpts{ - Agent: "claude", - Prompt: "hi", - SystemPrompt: "you are concise", + Agent: "claude", + Prompt: "hi", + SystemPromptFile: "/devcell-85/.devcell/prompts/main/additional-systemprompt.md", }) if res.ExitCode != 0 { t.Fatalf("exit = %d, stderr=%q", res.ExitCode, res.Stderr) } args := readArgs(t, dir, "claude") - if !strings.Contains(args, "--append-system-prompt you are concise") { - t.Errorf("expected --append-system-prompt flag in argv, got %q", args) + if !strings.Contains(args, "--append-system-prompt-file /devcell-85/.devcell/prompts/main/additional-systemprompt.md") { + t.Errorf("expected --append-system-prompt-file flag in argv, got %q", args) + } + // The inline form is mutually exclusive with the file form — claude + // rejects both together, so it must not appear. + if strings.Contains(args, "--append-system-prompt ") { + t.Errorf("inline --append-system-prompt must not be emitted alongside the file form, got %q", args) } } @@ -165,7 +170,7 @@ func TestShellExecutor_ClaudeNoSystemPromptNoFlag(t *testing.T) { } args := readArgs(t, dir, "claude") if strings.Contains(args, "--append-system-prompt") { - t.Errorf("expected no --append-system-prompt flag when SystemPrompt empty, got %q", args) + t.Errorf("expected no --append-system-prompt-file flag when SystemPromptFile empty, got %q", args) } } @@ -174,7 +179,7 @@ func TestShellExecutor_OpenCodeIgnoresSystemPrompt(t *testing.T) { withPath(t, dir) e := &ShellExecutor{} - res := e.Run(ExecOpts{Agent: "opencode", Prompt: "hi", SystemPrompt: "you are concise"}) + res := e.Run(ExecOpts{Agent: "opencode", Prompt: "hi", SystemPromptFile: "/tmp/x.md"}) if res.ExitCode != 0 { t.Fatalf("exit = %d", res.ExitCode) } @@ -280,3 +285,58 @@ func TestShellExecutor_NonZeroExitPropagated(t *testing.T) { t.Errorf("stderr = %q, want contains oops", res.Stderr) } } + +// A configured base replaces Claude Code's built-in prompt; it travels as a +// file alongside the overlay, and both flags may appear together. +func TestShellExecutor_ClaudeEmitsBaseAndOverlayFlags(t *testing.T) { + dir := makeStubAgent(t, "claude", "ok") + withPath(t, dir) + + e := &ShellExecutor{} + res := e.Run(ExecOpts{ + Agent: "claude", + Prompt: "hi", + BasePromptFile: "/devcell-85/.devcell/prompts/main/system-prompt.md", + SystemPromptFile: "/devcell-85/.devcell/prompts/main/additional-systemprompt.md", + }) + if res.ExitCode != 0 { + t.Fatalf("exit = %d, stderr=%q", res.ExitCode, res.Stderr) + } + args := readArgs(t, dir, "claude") + if !strings.Contains(args, "--system-prompt-file /devcell-85/.devcell/prompts/main/system-prompt.md") { + t.Errorf("expected --system-prompt-file in argv, got %q", args) + } + if !strings.Contains(args, "--append-system-prompt-file /devcell-85/.devcell/prompts/main/additional-systemprompt.md") { + t.Errorf("expected --append-system-prompt-file in argv, got %q", args) + } +} + +func TestShellExecutor_ClaudeNoBasePromptNoFlag(t *testing.T) { + dir := makeStubAgent(t, "claude", "ok") + withPath(t, dir) + + e := &ShellExecutor{} + res := e.Run(ExecOpts{Agent: "claude", Prompt: "hi"}) + if res.ExitCode != 0 { + t.Fatalf("exit = %d", res.ExitCode) + } + args := readArgs(t, dir, "claude") + if strings.Contains(args, "--system-prompt-file") { + t.Errorf("base flag must not appear when unconfigured — stock prompt must survive, got %q", args) + } +} + +func TestShellExecutor_OpenCodeIgnoresBasePrompt(t *testing.T) { + dir := makeStubAgent(t, "opencode", "ok") + withPath(t, dir) + + e := &ShellExecutor{} + res := e.Run(ExecOpts{Agent: "opencode", Prompt: "hi", BasePromptFile: "/tmp/base.md"}) + if res.ExitCode != 0 { + t.Fatalf("exit = %d", res.ExitCode) + } + args := readArgs(t, dir, "opencode") + if strings.Contains(args, "--system-prompt-file") { + t.Errorf("opencode should not receive --system-prompt-file, got %q", args) + } +} diff --git a/internal/serve/handler.go b/internal/serve/handler.go index 6b4bf3c..52a0ea5 100644 --- a/internal/serve/handler.go +++ b/internal/serve/handler.go @@ -40,11 +40,21 @@ type ExecOpts struct { // Effort, when set, is passed as --effort to the claude CLI. // Valid values: "low", "medium", "high". Empty = CLI default. Effort string - // SystemPrompt, when set, is passed as --append-system-prompt to claude. - // Operator-level baseline (set on `cell serve` startup), composes with - // any per-request `instructions` / `system` role from the OpenAI body — - // it does NOT override them. Ignored for opencode (no equivalent flag). - SystemPrompt string + // SystemPromptFile is the path to the generated overlay prompt file, + // passed as --append-system-prompt-file to claude. Operator-level + // baseline (materialized on `cell serve` startup), composes with any + // per-request `instructions` / `system` role from the OpenAI body — it + // does NOT override them. Ignored for opencode (no equivalent flag). + // + // A path rather than the text itself: the prompt used to travel as one + // argv element, which capped it at MAX_ARG_STRLEN and published it to + // `ps aux`. + SystemPromptFile string + // BasePromptFile is the path to the generated base prompt, passed as + // --system-prompt-file to claude. Empty leaves Claude Code's built-in + // prompt in effect — setting it discards that prompt entirely, including + // its tool guidance and safety instructions. Ignored for opencode. + BasePromptFile string } // ExecResult holds the output of an agent execution. @@ -218,7 +228,7 @@ func chatcmplID() string { // @Failure 405 {string} string "Only POST is allowed" // @Security BearerAuth // @Router /v1/chat/completions [post] -func NewChatHandler(exec Executor, logPrompts bool, systemPrompt string) http.Handler { +func NewChatHandler(exec Executor, logPrompts bool, systemPromptFile, basePromptFile string) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) @@ -276,11 +286,12 @@ func NewChatHandler(exec Executor, logPrompts bool, systemPrompt string) http.Ha } opts := ExecOpts{ - Agent: agent, - Prompt: prompt, - Model: submodel, - Effort: effort, - SystemPrompt: systemPrompt, + Agent: agent, + Prompt: prompt, + Model: submodel, + Effort: effort, + SystemPromptFile: systemPromptFile, + BasePromptFile: basePromptFile, } // Streaming path: only claude has a token-level streaming surface diff --git a/internal/serve/handler_test.go b/internal/serve/handler_test.go index 8e9f022..f21e148 100644 --- a/internal/serve/handler_test.go +++ b/internal/serve/handler_test.go @@ -11,12 +11,12 @@ import ( // fakeExec records what was called and returns canned output. type fakeExec struct { - called bool - agent string - prompt string - model string - effort string - systemPrompt string + called bool + agent string + prompt string + model string + effort string + systemPromptFile string stdout string stderr string @@ -29,7 +29,7 @@ func (f *fakeExec) Run(opts ExecOpts) ExecResult { f.prompt = opts.Prompt f.model = opts.Model f.effort = opts.Effort - f.systemPrompt = opts.SystemPrompt + f.systemPromptFile = opts.SystemPromptFile return ExecResult{ Stdout: f.stdout, Stderr: f.stderr, @@ -48,7 +48,7 @@ func postChat(t *testing.T, handler http.Handler, body string) *httptest.Respons func TestHandler_ValidClaude(t *testing.T) { fe := &fakeExec{stdout: "hello back", exitCode: 0} - h := NewChatHandler(fe, false, "") + h := NewChatHandler(fe, false, "", "") rec := postChat(t, h, `{"model":"anthropic/sonnet","messages":[{"role":"user","content":"hello"}]}`) @@ -87,7 +87,7 @@ func TestHandler_ValidClaude(t *testing.T) { func TestHandler_ValidOpencode(t *testing.T) { fe := &fakeExec{stdout: "opencode result"} - h := NewChatHandler(fe, false, "") + h := NewChatHandler(fe, false, "", "") rec := postChat(t, h, `{"model":"opencode","messages":[{"role":"user","content":"hello"}]}`) @@ -101,7 +101,7 @@ func TestHandler_ValidOpencode(t *testing.T) { func TestHandler_ModelWithSubmodel(t *testing.T) { fe := &fakeExec{stdout: "ok"} - h := NewChatHandler(fe, false, "") + h := NewChatHandler(fe, false, "", "") rec := postChat(t, h, `{"model":"anthropic/opus","messages":[{"role":"user","content":"hello"}]}`) @@ -118,7 +118,7 @@ func TestHandler_ModelWithSubmodel(t *testing.T) { func TestHandler_MissingModel(t *testing.T) { fe := &fakeExec{} - h := NewChatHandler(fe, false, "") + h := NewChatHandler(fe, false, "", "") rec := postChat(t, h, `{"messages":[{"role":"user","content":"hello"}]}`) @@ -135,7 +135,7 @@ func TestHandler_MissingModel(t *testing.T) { func TestHandler_MissingMessages(t *testing.T) { fe := &fakeExec{} - h := NewChatHandler(fe, false, "") + h := NewChatHandler(fe, false, "", "") rec := postChat(t, h, `{"model":"anthropic/sonnet"}`) @@ -149,7 +149,7 @@ func TestHandler_MissingMessages(t *testing.T) { func TestHandler_EmptyMessages(t *testing.T) { fe := &fakeExec{} - h := NewChatHandler(fe, false, "") + h := NewChatHandler(fe, false, "", "") rec := postChat(t, h, `{"model":"anthropic/sonnet","messages":[]}`) @@ -163,7 +163,7 @@ func TestHandler_EmptyMessages(t *testing.T) { func TestHandler_UnknownAgent(t *testing.T) { fe := &fakeExec{} - h := NewChatHandler(fe, false, "") + h := NewChatHandler(fe, false, "", "") rec := postChat(t, h, `{"model":"foo","messages":[{"role":"user","content":"hello"}]}`) @@ -178,7 +178,7 @@ func TestHandler_UnknownAgent(t *testing.T) { func TestHandler_EmptyBody(t *testing.T) { fe := &fakeExec{} - h := NewChatHandler(fe, false, "") + h := NewChatHandler(fe, false, "", "") req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", &bytes.Buffer{}) req.Header.Set("Content-Type", "application/json") @@ -192,7 +192,7 @@ func TestHandler_EmptyBody(t *testing.T) { func TestHandler_InvalidJSON(t *testing.T) { fe := &fakeExec{} - h := NewChatHandler(fe, false, "") + h := NewChatHandler(fe, false, "", "") rec := postChat(t, h, `{broken`) @@ -203,7 +203,7 @@ func TestHandler_InvalidJSON(t *testing.T) { func TestHandler_MethodNotAllowed(t *testing.T) { fe := &fakeExec{} - h := NewChatHandler(fe, false, "") + h := NewChatHandler(fe, false, "", "") req := httptest.NewRequest(http.MethodGet, "/v1/chat/completions", nil) rec := httptest.NewRecorder() @@ -216,7 +216,7 @@ func TestHandler_MethodNotAllowed(t *testing.T) { func TestHandler_MultipleMessages_UsesLast(t *testing.T) { fe := &fakeExec{stdout: "ok"} - h := NewChatHandler(fe, false, "") + h := NewChatHandler(fe, false, "", "") rec := postChat(t, h, `{"model":"anthropic/sonnet","messages":[{"role":"user","content":"first"},{"role":"user","content":"second"}]}`) @@ -230,7 +230,7 @@ func TestHandler_MultipleMessages_UsesLast(t *testing.T) { func TestHandler_ExecFailure(t *testing.T) { fe := &fakeExec{stderr: "something broke", exitCode: 1} - h := NewChatHandler(fe, false, "") + h := NewChatHandler(fe, false, "", "") rec := postChat(t, h, `{"model":"anthropic/sonnet","messages":[{"role":"user","content":"hello"}]}`) @@ -250,7 +250,7 @@ func TestHandler_ExecFailure(t *testing.T) { func TestHandler_ResponseHasID(t *testing.T) { fe := &fakeExec{stdout: "ok"} - h := NewChatHandler(fe, false, "") + h := NewChatHandler(fe, false, "", "") rec := postChat(t, h, `{"model":"anthropic/sonnet","messages":[{"role":"user","content":"hello"}]}`) @@ -274,7 +274,7 @@ func TestHandler_Effort_OpenAISpecValuesPassThrough(t *testing.T) { for _, v := range []string{"low", "medium", "high"} { t.Run(v, func(t *testing.T) { fe := &fakeExec{stdout: "ok"} - h := NewChatHandler(fe, false, "") + h := NewChatHandler(fe, false, "", "") body := `{"model":"anthropic/sonnet","reasoning_effort":"` + v + `","messages":[{"role":"user","content":"hi"}]}` rec := postChat(t, h, body) @@ -293,7 +293,7 @@ func TestHandler_Effort_ClaudeOnlyValuesDropped(t *testing.T) { for _, v := range []string{"xhigh", "max"} { t.Run(v, func(t *testing.T) { fe := &fakeExec{stdout: "ok"} - h := NewChatHandler(fe, false, "") + h := NewChatHandler(fe, false, "", "") body := `{"model":"anthropic/sonnet","reasoning_effort":"` + v + `","messages":[{"role":"user","content":"hi"}]}` rec := postChat(t, h, body) @@ -311,7 +311,7 @@ func TestHandler_Effort_UnknownValuesDropped(t *testing.T) { for _, v := range []string{"extreme", "minimal", "LOW", "High", "auto"} { t.Run(v, func(t *testing.T) { fe := &fakeExec{stdout: "ok"} - h := NewChatHandler(fe, false, "") + h := NewChatHandler(fe, false, "", "") body := `{"model":"anthropic/sonnet","reasoning_effort":"` + v + `","messages":[{"role":"user","content":"hi"}]}` rec := postChat(t, h, body) @@ -327,7 +327,7 @@ func TestHandler_Effort_UnknownValuesDropped(t *testing.T) { func TestHandler_Effort_AbsentNoFlag(t *testing.T) { fe := &fakeExec{stdout: "ok"} - h := NewChatHandler(fe, false, "") + h := NewChatHandler(fe, false, "", "") rec := postChat(t, h, `{"model":"anthropic/sonnet","messages":[{"role":"user","content":"hi"}]}`) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rec.Code) diff --git a/internal/serve/responses.go b/internal/serve/responses.go index 823e47d..07c5067 100644 --- a/internal/serve/responses.go +++ b/internal/serve/responses.go @@ -107,11 +107,11 @@ type ResponsesOutputItem struct { // input_tokens_details.cached_tokens — that's claude's // cache_read_input_tokens and is real money saved, so we surface it. type ResponsesUsage struct { - InputTokens int `json:"input_tokens" example:"42"` - InputTokensDetails *ResponsesInputTokensDetails `json:"input_tokens_details,omitempty"` - OutputTokens int `json:"output_tokens" example:"7"` + InputTokens int `json:"input_tokens" example:"42"` + InputTokensDetails *ResponsesInputTokensDetails `json:"input_tokens_details,omitempty"` + OutputTokens int `json:"output_tokens" example:"7"` OutputTokensDetails *ResponsesOutputTokensDetails `json:"output_tokens_details,omitempty"` - TotalTokens int `json:"total_tokens" example:"49"` + TotalTokens int `json:"total_tokens" example:"49"` } // ResponsesInputTokensDetails carries the cached-input breakdown. @@ -401,7 +401,7 @@ func buildPrompt(instructions string, input json.RawMessage) (string, error) { // @Failure 405 {object} APIError "Only POST is allowed" // @Security BearerAuth // @Router /v1/responses [post] -func NewResponsesHandler(exec Executor, store *JobStore, logPrompts bool, systemPrompt string) http.Handler { +func NewResponsesHandler(exec Executor, store *JobStore, logPrompts bool, systemPromptFile, basePromptFile string) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeAPIError(w, http.StatusMethodNotAllowed, @@ -473,11 +473,12 @@ func NewResponsesHandler(exec Executor, store *JobStore, logPrompts bool, system } opts := ExecOpts{ - Agent: agent, - Prompt: prompt, - Model: submodel, - Effort: effort, - SystemPrompt: systemPrompt, + Agent: agent, + Prompt: prompt, + Model: submodel, + Effort: effort, + SystemPromptFile: systemPromptFile, + BasePromptFile: basePromptFile, } // stream + background together is unsupported in the first pass: diff --git a/internal/serve/responses_background_test.go b/internal/serve/responses_background_test.go index 8d5e31b..64b82ba 100644 --- a/internal/serve/responses_background_test.go +++ b/internal/serve/responses_background_test.go @@ -19,7 +19,7 @@ import ( func TestResponses_Background_Returns202(t *testing.T) { fe := &fakeExec{stdout: "should not appear in immediate response"} store := NewJobStore() - h := NewResponsesHandler(fe, store, false, "") + h := NewResponsesHandler(fe, store, false, "", "") rec := postResponses(t, h, `{"model":"anthropic/sonnet","input":"hello","background":true}`) @@ -126,7 +126,7 @@ func pollGet(t *testing.T, h http.Handler, id string) (ResponsesObject, int) { func TestResponses_Background_InProgressThenCompleted(t *testing.T) { be := &blockExec{release: make(chan struct{}), stdout: "ASYNC OK"} store := NewJobStore() - postH := NewResponsesHandler(be, store, false, "") + postH := NewResponsesHandler(be, store, false, "", "") getH := NewResponseGetHandler(store) rec := postResponses(t, postH, `{"model":"anthropic/sonnet","input":"go","background":true}`) @@ -181,7 +181,7 @@ func TestResponses_Background_InProgressThenCompleted(t *testing.T) { func TestResponses_Background_Failed(t *testing.T) { fe := &fakeExec{stderr: "agent blew up", exitCode: 1} store := NewJobStore() - postH := NewResponsesHandler(fe, store, false, "") + postH := NewResponsesHandler(fe, store, false, "", "") getH := NewResponseGetHandler(store) rec := postResponses(t, postH, `{"model":"anthropic/sonnet","input":"x","background":true}`) @@ -224,7 +224,7 @@ func TestResponses_Background_Failed(t *testing.T) { func TestResponseCancel_InProgress(t *testing.T) { be := &blockExec{release: make(chan struct{}), stdout: "never delivered"} store := NewJobStore() - postH := NewResponsesHandler(be, store, false, "") + postH := NewResponsesHandler(be, store, false, "", "") cancelH := NewResponseCancelHandler(store) getH := NewResponseGetHandler(store) @@ -279,7 +279,7 @@ func TestResponseCancel_InProgress(t *testing.T) { func TestResponses_Background_WithStream_Returns400(t *testing.T) { fe := &fakeExec{stdout: "x"} store := NewJobStore() - h := NewResponsesHandler(fe, store, false, "") + h := NewResponsesHandler(fe, store, false, "", "") rec := postResponses(t, h, `{"model":"anthropic/sonnet","input":"hi","background":true,"stream":true}`) @@ -311,7 +311,7 @@ func TestResponses_Background_WithStream_Returns400(t *testing.T) { func TestResponses_Background_SurvivesRequestCtxCancel(t *testing.T) { be := &blockExec{release: make(chan struct{}), stdout: "SURVIVED"} store := NewJobStore() - postH := NewResponsesHandler(be, store, false, "") + postH := NewResponsesHandler(be, store, false, "", "") getH := NewResponseGetHandler(store) ctx, cancel := context.WithCancel(context.Background()) diff --git a/internal/serve/responses_test.go b/internal/serve/responses_test.go index ff1a0df..db1e185 100644 --- a/internal/serve/responses_test.go +++ b/internal/serve/responses_test.go @@ -37,7 +37,7 @@ func decodeAPIError(t *testing.T, rec *httptest.ResponseRecorder) APIError { func TestResponses_StringInput(t *testing.T) { fe := &fakeExec{stdout: "world"} - h := NewResponsesHandler(fe, nil, false, "") + h := NewResponsesHandler(fe, nil, false, "", "") rec := postResponses(t, h, `{"model":"anthropic/sonnet","input":"hello"}`) if rec.Code != http.StatusOK { @@ -85,7 +85,7 @@ func TestResponses_StringInput(t *testing.T) { func TestResponses_ArrayInput(t *testing.T) { fe := &fakeExec{stdout: "ok"} - h := NewResponsesHandler(fe, nil, false, "") + h := NewResponsesHandler(fe, nil, false, "", "") body := `{ "model": "anthropic/sonnet", @@ -108,7 +108,7 @@ func TestResponses_ArrayInput(t *testing.T) { func TestResponses_ContentParts(t *testing.T) { fe := &fakeExec{stdout: "ok"} - h := NewResponsesHandler(fe, nil, false, "") + h := NewResponsesHandler(fe, nil, false, "", "") body := `{ "model": "anthropic/sonnet", @@ -127,7 +127,7 @@ func TestResponses_ContentParts(t *testing.T) { func TestResponses_Instructions(t *testing.T) { fe := &fakeExec{stdout: "ok"} - h := NewResponsesHandler(fe, nil, false, "") + h := NewResponsesHandler(fe, nil, false, "", "") body := `{"model":"anthropic/sonnet","instructions":"be brief","input":"hi"}` rec := postResponses(t, h, body) @@ -149,7 +149,7 @@ func TestResponses_Instructions(t *testing.T) { func TestResponses_SystemRoleInArray(t *testing.T) { fe := &fakeExec{stdout: "ok"} - h := NewResponsesHandler(fe, nil, false, "") + h := NewResponsesHandler(fe, nil, false, "", "") // system role inside input[] flattens to [system]: line. body := `{ @@ -171,7 +171,7 @@ func TestResponses_SystemRoleInArray(t *testing.T) { func TestResponses_DeveloperRoleAliasesToSystem(t *testing.T) { fe := &fakeExec{stdout: "ok"} - h := NewResponsesHandler(fe, nil, false, "") + h := NewResponsesHandler(fe, nil, false, "", "") body := `{ "model": "anthropic/sonnet", @@ -197,7 +197,7 @@ func TestResponses_DeveloperRoleAliasesToSystem(t *testing.T) { // avoid spawning the real binary in this unit test file. func TestResponses_StreamFallsBackForOpencode(t *testing.T) { fe := &fakeExec{stdout: "ok"} - h := NewResponsesHandler(fe, nil, false, "") + h := NewResponsesHandler(fe, nil, false, "", "") rec := postResponses(t, h, `{"model":"opencode","input":"hi","stream":true}`) if rec.Code != http.StatusOK { @@ -213,7 +213,7 @@ func TestResponses_StreamFallsBackForOpencode(t *testing.T) { func TestResponses_BadJSON(t *testing.T) { fe := &fakeExec{stdout: "ok"} - h := NewResponsesHandler(fe, nil, false, "") + h := NewResponsesHandler(fe, nil, false, "", "") rec := postResponses(t, h, `{not json}`) if rec.Code != http.StatusBadRequest { @@ -226,7 +226,7 @@ func TestResponses_BadJSON(t *testing.T) { } func TestResponses_EmptyModel(t *testing.T) { - h := NewResponsesHandler(&fakeExec{}, nil, false, "") + h := NewResponsesHandler(&fakeExec{}, nil, false, "", "") rec := postResponses(t, h, `{"input":"hi"}`) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", rec.Code) @@ -238,7 +238,7 @@ func TestResponses_EmptyModel(t *testing.T) { } func TestResponses_UnknownModel(t *testing.T) { - h := NewResponsesHandler(&fakeExec{}, nil, false, "") + h := NewResponsesHandler(&fakeExec{}, nil, false, "", "") rec := postResponses(t, h, `{"model":"gpt-4","input":"hi"}`) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", rec.Code) @@ -250,7 +250,7 @@ func TestResponses_UnknownModel(t *testing.T) { } func TestResponses_EmptyInputString(t *testing.T) { - h := NewResponsesHandler(&fakeExec{}, nil, false, "") + h := NewResponsesHandler(&fakeExec{}, nil, false, "", "") rec := postResponses(t, h, `{"model":"anthropic/sonnet","input":""}`) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", rec.Code) @@ -262,7 +262,7 @@ func TestResponses_EmptyInputString(t *testing.T) { } func TestResponses_EmptyInputArray(t *testing.T) { - h := NewResponsesHandler(&fakeExec{}, nil, false, "") + h := NewResponsesHandler(&fakeExec{}, nil, false, "", "") rec := postResponses(t, h, `{"model":"anthropic/sonnet","input":[]}`) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", rec.Code) @@ -274,7 +274,7 @@ func TestResponses_EmptyInputArray(t *testing.T) { } func TestResponses_MissingInput(t *testing.T) { - h := NewResponsesHandler(&fakeExec{}, nil, false, "") + h := NewResponsesHandler(&fakeExec{}, nil, false, "", "") rec := postResponses(t, h, `{"model":"anthropic/sonnet"}`) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", rec.Code) @@ -286,7 +286,7 @@ func TestResponses_MissingInput(t *testing.T) { } func TestResponses_NonPOST(t *testing.T) { - h := NewResponsesHandler(&fakeExec{}, nil, false, "") + h := NewResponsesHandler(&fakeExec{}, nil, false, "", "") req := httptest.NewRequest(http.MethodGet, "/v1/responses", nil) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) @@ -301,7 +301,7 @@ func TestResponses_NonPOST(t *testing.T) { func TestResponses_ExitCodeFailure(t *testing.T) { fe := &fakeExec{stderr: "boom", exitCode: 1} - h := NewResponsesHandler(fe, nil, false, "") + h := NewResponsesHandler(fe, nil, false, "", "") rec := postResponses(t, h, `{"model":"anthropic/sonnet","input":"hi"}`) // Failure is a 200 with status: "failed" and error populated — matches OpenAI. @@ -325,7 +325,7 @@ func TestResponses_ExitCodeFailure(t *testing.T) { func TestResponses_IgnoredFieldsTolerated(t *testing.T) { fe := &fakeExec{stdout: "ok"} - h := NewResponsesHandler(fe, nil, false, "") + h := NewResponsesHandler(fe, nil, false, "", "") // All these fields should decode cleanly and have no effect. body := `{ @@ -387,7 +387,7 @@ func TestResponses_Effort_OpenAISpecValuesPassThrough(t *testing.T) { for _, v := range []string{"low", "medium", "high"} { t.Run(v, func(t *testing.T) { fe := &fakeExec{stdout: "ok"} - h := NewResponsesHandler(fe, nil, false, "") + h := NewResponsesHandler(fe, nil, false, "", "") body := `{"model":"anthropic/sonnet","input":"hi","reasoning":{"effort":"` + v + `"}}` rec := postResponses(t, h, body) if rec.Code != http.StatusOK { @@ -407,7 +407,7 @@ func TestResponses_Effort_ClaudeOnlyValuesDropped(t *testing.T) { for _, v := range []string{"xhigh", "max"} { t.Run(v, func(t *testing.T) { fe := &fakeExec{stdout: "ok"} - h := NewResponsesHandler(fe, nil, false, "") + h := NewResponsesHandler(fe, nil, false, "", "") body := `{"model":"anthropic/sonnet","input":"hi","reasoning":{"effort":"` + v + `"}}` rec := postResponses(t, h, body) if rec.Code != http.StatusOK { @@ -425,7 +425,7 @@ func TestResponses_Effort_UnknownValuesDropped(t *testing.T) { for _, v := range []string{"extreme", "minimal", "LOW", "High", "auto", "none"} { t.Run(v, func(t *testing.T) { fe := &fakeExec{stdout: "ok"} - h := NewResponsesHandler(fe, nil, false, "") + h := NewResponsesHandler(fe, nil, false, "", "") body := `{"model":"anthropic/sonnet","input":"hi","reasoning":{"effort":"` + v + `"}}` rec := postResponses(t, h, body) if rec.Code != http.StatusOK { @@ -440,7 +440,7 @@ func TestResponses_Effort_UnknownValuesDropped(t *testing.T) { func TestResponses_Effort_AbsentNoFlag(t *testing.T) { fe := &fakeExec{stdout: "ok"} - h := NewResponsesHandler(fe, nil, false, "") + h := NewResponsesHandler(fe, nil, false, "", "") rec := postResponses(t, h, `{"model":"anthropic/sonnet","input":"hi"}`) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rec.Code) @@ -453,7 +453,7 @@ func TestResponses_Effort_AbsentNoFlag(t *testing.T) { func TestResponses_Effort_OtherReasoningFieldsIgnored(t *testing.T) { // `summary` and `generate_summary` decode but have no effect. fe := &fakeExec{stdout: "ok"} - h := NewResponsesHandler(fe, nil, false, "") + h := NewResponsesHandler(fe, nil, false, "", "") body := `{ "model":"anthropic/sonnet","input":"hi", "reasoning":{"effort":"medium","summary":"detailed","generate_summary":"auto"} @@ -471,7 +471,7 @@ func TestResponses_Effort_EchoedInResponse(t *testing.T) { // The reasoning object should be echoed back verbatim (with whatever // fields the client sent) so SDK round-trips don't drop information. fe := &fakeExec{stdout: "ok"} - h := NewResponsesHandler(fe, nil, false, "") + h := NewResponsesHandler(fe, nil, false, "", "") rec := postResponses(t, h, `{"model":"anthropic/sonnet","input":"hi","reasoning":{"effort":"high","summary":"auto"}}`) if rec.Code != http.StatusOK { @@ -491,7 +491,7 @@ func TestResponses_Effort_EchoedInResponse(t *testing.T) { func TestResponses_OpenCodeRouting(t *testing.T) { fe := &fakeExec{stdout: "ok"} - h := NewResponsesHandler(fe, nil, false, "") + h := NewResponsesHandler(fe, nil, false, "", "") rec := postResponses(t, h, `{"model":"opencode","input":"hi"}`) if rec.Code != http.StatusOK { diff --git a/internal/serve/server.go b/internal/serve/server.go index 78e67cb..168b131 100644 --- a/internal/serve/server.go +++ b/internal/serve/server.go @@ -14,8 +14,8 @@ import ( "time" "github.com/DimmKirr/devcell/internal/version" - "github.com/swaggo/swag" httpSwagger "github.com/swaggo/http-swagger/v2" + "github.com/swaggo/swag" ) // DefaultPort is the default listen port for devcell serve. @@ -23,14 +23,15 @@ const DefaultPort = 8484 // Server is the devcell HTTP API server. type Server struct { - exec Executor - port int - lookPath LookPathFunc - anthropic AnthropicClient - apiKey string // empty = no auth - logPrompts bool // when true, handlers log full prompt + response bodies - systemPrompt string // when non-empty, passed as --append-system-prompt to claude - jobStore *JobStore + exec Executor + port int + lookPath LookPathFunc + anthropic AnthropicClient + apiKey string // empty = no auth + logPrompts bool // when true, handlers log full prompt + response bodies + systemPromptFile string // when non-empty, passed as --append-system-prompt-file to claude + basePromptFile string // when non-empty, passed as --system-prompt-file (replaces the stock prompt) + jobStore *JobStore workspaceEnabled bool workspaceMock bool @@ -84,12 +85,20 @@ func (s *Server) SetLogPrompts(v bool) { s.logPrompts = v } -// SetSystemPrompt sets the operator-level system prompt passed to claude -// as --append-system-prompt on every /v1/chat/completions and /v1/responses -// request. Empty disables the flag (default). Composes with — does not -// override — any per-request `instructions` / `system` role from the body. -func (s *Server) SetSystemPrompt(p string) { - s.systemPrompt = p +// SetSystemPromptFile sets the path to the operator-level overlay prompt, +// passed to claude as --append-system-prompt-file on every +// /v1/chat/completions and /v1/responses request. Empty disables the flag +// (default). Composes with — does not override — any per-request +// `instructions` / `system` role from the body. +func (s *Server) SetSystemPromptFile(p string) { + s.systemPromptFile = p +} + +// SetBasePromptFile sets the path to the base prompt, passed to claude as +// --system-prompt-file. Empty (the default) leaves Claude Code's built-in +// prompt in effect. +func (s *Server) SetBasePromptFile(p string) { + s.basePromptFile = p } func (s *Server) SetWorkspace(enabled, mock bool, host string) { @@ -118,8 +127,8 @@ func execLookPath(name string) (string, error) { // The server shuts down when ctx is cancelled. func (s *Server) Start(ctx context.Context) (addr string, errCh chan error) { mux := http.NewServeMux() - mux.Handle("/v1/chat/completions", AuthMiddleware(s.apiKey, NewChatHandler(s.exec, s.logPrompts, s.systemPrompt))) - mux.Handle("/v1/responses", AuthMiddleware(s.apiKey, NewResponsesHandler(s.exec, s.jobStore, s.logPrompts, s.systemPrompt))) + mux.Handle("/v1/chat/completions", AuthMiddleware(s.apiKey, NewChatHandler(s.exec, s.logPrompts, s.systemPromptFile, s.basePromptFile))) + mux.Handle("/v1/responses", AuthMiddleware(s.apiKey, NewResponsesHandler(s.exec, s.jobStore, s.logPrompts, s.systemPromptFile, s.basePromptFile))) mux.Handle("GET /v1/responses/{id}", AuthMiddleware(s.apiKey, NewResponseGetHandler(s.jobStore))) mux.Handle("POST /v1/responses/{id}/cancel", AuthMiddleware(s.apiKey, NewResponseCancelHandler(s.jobStore))) mux.Handle("/v1/models", AuthMiddleware(s.apiKey, NewModelsHandler(s.lookPath, s.anthropic))) diff --git a/internal/serve/server_test.go b/internal/serve/server_test.go index 7df75b1..c675e50 100644 --- a/internal/serve/server_test.go +++ b/internal/serve/server_test.go @@ -140,21 +140,21 @@ func TestServer_LogPromptsToggle(t *testing.T) { } } -// TestServer_SystemPromptThreadedToExec proves SetSystemPrompt on the Server -// reaches ExecOpts.SystemPrompt on every chat-completions request — the -// contract that lets `cell serve --system-prompt` actually take effect. +// TestServer_SystemPromptThreadedToExec proves SetSystemPromptFile on the +// Server reaches ExecOpts.SystemPromptFile on every chat-completions request +// — the contract that lets `cell serve --system-prompt` actually take effect. func TestServer_SystemPromptThreadedToExec(t *testing.T) { fe := &fakeExec{stdout: "ok"} srv := NewServer(fe, 0) - srv.SetSystemPrompt("you are a backend assistant") + srv.SetSystemPromptFile("/devcell-85/.devcell/prompts/main/additional-systemprompt.md") - h := NewChatHandler(fe, false, srv.systemPrompt) + h := NewChatHandler(fe, false, srv.systemPromptFile, "") rec := postChat(t, h, `{"model":"claude","messages":[{"role":"user","content":"hi"}]}`) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } - if fe.systemPrompt != "you are a backend assistant" { - t.Errorf("ExecOpts.SystemPrompt = %q, want operator-level value", fe.systemPrompt) + if fe.systemPromptFile != "/devcell-85/.devcell/prompts/main/additional-systemprompt.md" { + t.Errorf("ExecOpts.SystemPromptFile = %q, want operator-level value", fe.systemPromptFile) } } diff --git a/internal/serve/sse_responses.go b/internal/serve/sse_responses.go index 6da1a94..98fa8c4 100644 --- a/internal/serve/sse_responses.go +++ b/internal/serve/sse_responses.go @@ -30,7 +30,7 @@ import ( // No `[DONE]` sentinel — response.completed is the terminator. type responseSSEPayload struct { - Type string `json:"type"` + Type string `json:"type"` Response *ResponsesObject `json:"response,omitempty"` // item / content_part / delta envelopes OutputIndex *int `json:"output_index,omitempty"` @@ -191,11 +191,11 @@ func pumpResponsesSSE( }) } obj := &ResponsesObject{ - ID: respID, - Object: "response", - CreatedAt: created, - Status: "completed", - Model: model, + ID: respID, + Object: "response", + CreatedAt: created, + Status: "completed", + Model: model, Output: []ResponsesOutputItem{{ Type: "message", ID: itemID, diff --git a/internal/serve/workspace_handler_test.go b/internal/serve/workspace_handler_test.go index 6610cca..59bc03c 100644 --- a/internal/serve/workspace_handler_test.go +++ b/internal/serve/workspace_handler_test.go @@ -20,7 +20,6 @@ func authedRequest(method, path string) *http.Request { return req } - func TestWorkspaceHandler_GET_returns200_with_XML(t *testing.T) { mux := workspaceMux("localhost") rec := httptest.NewRecorder() diff --git a/internal/telemetry/config.go b/internal/telemetry/config.go new file mode 100644 index 0000000..1f354a4 --- /dev/null +++ b/internal/telemetry/config.go @@ -0,0 +1,80 @@ +package telemetry + +import ( + "crypto/rand" + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +const configFile = "telemetry.json" + +type Config struct { + Enabled bool `json:"enabled"` + AnonymousID string `json:"anonymous_id"` +} + +func LoadConfig(configDir string) Config { + data, err := os.ReadFile(filepath.Join(configDir, configFile)) + if err != nil { + return Config{} + } + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + return Config{} + } + return cfg +} + +func SaveConfig(configDir string, cfg Config) error { + if err := os.MkdirAll(configDir, 0755); err != nil { + return err + } + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + tmp := filepath.Join(configDir, configFile+".tmp") + if err := os.WriteFile(tmp, data, 0644); err != nil { + return err + } + return os.Rename(tmp, filepath.Join(configDir, configFile)) +} + +func Enable(configDir string) (Config, error) { + cfg := LoadConfig(configDir) + cfg.Enabled = true + if cfg.AnonymousID == "" { + id, err := generateUUID() + if err != nil { + return cfg, err + } + cfg.AnonymousID = id + } + return cfg, SaveConfig(configDir, cfg) +} + +func Disable(configDir string) (Config, error) { + cfg := LoadConfig(configDir) + cfg.Enabled = false + return cfg, SaveConfig(configDir, cfg) +} + +func IsAllowed(cfg Config) bool { + if os.Getenv("DO_NOT_TRACK") == "1" { + return false + } + return cfg.Enabled +} + +func generateUUID() (string, error) { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + b[6] = (b[6] & 0x0f) | 0x40 // version 4 + b[8] = (b[8] & 0x3f) | 0x80 // variant 10 + return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", + b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil +} diff --git a/internal/telemetry/config_test.go b/internal/telemetry/config_test.go new file mode 100644 index 0000000..086bcba --- /dev/null +++ b/internal/telemetry/config_test.go @@ -0,0 +1,117 @@ +package telemetry + +import ( + "encoding/json" + "os" + "path/filepath" + "regexp" + "testing" +) + +var uuidRe = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`) + +func TestLoad_MissingFile(t *testing.T) { + dir := t.TempDir() + cfg := LoadConfig(dir) + if cfg.Enabled { + t.Error("expected Enabled=false for missing file") + } + if cfg.AnonymousID != "" { + t.Error("expected empty AnonymousID for missing file") + } +} + +func TestLoad_ValidFile(t *testing.T) { + dir := t.TempDir() + want := Config{Enabled: true, AnonymousID: "test-uuid-1234"} + data, _ := json.Marshal(want) + if err := os.WriteFile(filepath.Join(dir, "telemetry.json"), data, 0644); err != nil { + t.Fatal(err) + } + got := LoadConfig(dir) + if got.Enabled != want.Enabled || got.AnonymousID != want.AnonymousID { + t.Errorf("got %+v, want %+v", got, want) + } +} + +func TestLoad_CorruptFile(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "telemetry.json"), []byte("{invalid"), 0644); err != nil { + t.Fatal(err) + } + cfg := LoadConfig(dir) + if cfg.Enabled { + t.Error("expected Enabled=false for corrupt file") + } +} + +func TestEnable_GeneratesUUID(t *testing.T) { + dir := t.TempDir() + cfg, err := Enable(dir) + if err != nil { + t.Fatal(err) + } + if !cfg.Enabled { + t.Error("expected Enabled=true") + } + if !uuidRe.MatchString(cfg.AnonymousID) { + t.Errorf("AnonymousID %q is not a valid UUID v4", cfg.AnonymousID) + } +} + +func TestEnable_PreservesExistingUUID(t *testing.T) { + dir := t.TempDir() + cfg1, err := Enable(dir) + if err != nil { + t.Fatal(err) + } + cfg2, err := Enable(dir) + if err != nil { + t.Fatal(err) + } + if cfg1.AnonymousID != cfg2.AnonymousID { + t.Errorf("UUID changed: %q → %q", cfg1.AnonymousID, cfg2.AnonymousID) + } +} + +func TestDisable_PreservesUUID(t *testing.T) { + dir := t.TempDir() + cfg1, err := Enable(dir) + if err != nil { + t.Fatal(err) + } + cfg2, err := Disable(dir) + if err != nil { + t.Fatal(err) + } + if cfg2.Enabled { + t.Error("expected Enabled=false after Disable") + } + if cfg2.AnonymousID != cfg1.AnonymousID { + t.Errorf("UUID changed after Disable: %q → %q", cfg1.AnonymousID, cfg2.AnonymousID) + } +} + +func TestIsAllowed_Enabled(t *testing.T) { + t.Setenv("DO_NOT_TRACK", "") + cfg := Config{Enabled: true, AnonymousID: "test"} + if !IsAllowed(cfg) { + t.Error("expected IsAllowed=true when Enabled and DO_NOT_TRACK unset") + } +} + +func TestIsAllowed_Disabled(t *testing.T) { + t.Setenv("DO_NOT_TRACK", "") + cfg := Config{Enabled: false} + if IsAllowed(cfg) { + t.Error("expected IsAllowed=false when Enabled=false") + } +} + +func TestIsAllowed_DoNotTrack(t *testing.T) { + t.Setenv("DO_NOT_TRACK", "1") + cfg := Config{Enabled: true, AnonymousID: "test"} + if IsAllowed(cfg) { + t.Error("expected IsAllowed=false when DO_NOT_TRACK=1") + } +} diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go new file mode 100644 index 0000000..dd35c77 --- /dev/null +++ b/internal/telemetry/telemetry.go @@ -0,0 +1,90 @@ +package telemetry + +import ( + "os" + "runtime" + + "github.com/DimmKirr/devcell/internal/version" + "github.com/posthog/posthog-go" +) + +const defaultAPIKey = "phc_BbYjyXk7rB3dTD7qqTDzbo6zcS623Jz6LP9ktRCNMaFw" + +var ( + client posthog.Client + anonymousID string + + // captureHook, when non-nil, receives every Capture before it's enqueued. + // Used only in tests. + captureHook func(posthog.Capture) +) + +func Init(configDir string) { + cfg := LoadConfig(configDir) + if !IsAllowed(cfg) { + return + } + initClient(cfg) +} + +func initClient(cfg Config) { + apiKey := os.Getenv("DEVCELL_POSTHOG_PROJECT_KEY") + if apiKey == "" { + apiKey = defaultAPIKey + } + c, err := posthog.NewWithConfig(apiKey, posthog.Config{ + Endpoint: "https://us.i.posthog.com", + }) + if err != nil { + return + } + client = c + anonymousID = cfg.AnonymousID +} + +func Close() { + if client != nil { + client.Close() + client = nil + } +} + +func Track(event string, props map[string]any) { + if client == nil { + return + } + p := posthog.NewProperties() + for k, v := range props { + p.Set(k, v) + } + p.Set("os", runtime.GOOS) + p.Set("arch", runtime.GOARCH) + p.Set("version", version.Full()) + c := posthog.Capture{ + DistinctId: anonymousID, + Event: event, + Properties: p, + } + if captureHook != nil { + captureHook(c) + } + client.Enqueue(c) +} + +func TrackCommandRun(command, engine, stack string, modules []string, thin bool) { + Track("command_run", map[string]any{ + "command": command, + "engine": engine, + "stack": stack, + "modules": modules, + "thin": thin, + }) +} + +func TrackCommandFinish(command string, durationMs int64, clean bool) { + Track("command_finish", map[string]any{ + "command": command, + "duration_ms": durationMs, + "exit_clean": clean, + }) +} diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go new file mode 100644 index 0000000..9a6b172 --- /dev/null +++ b/internal/telemetry/telemetry_test.go @@ -0,0 +1,199 @@ +package telemetry + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/posthog/posthog-go" +) + +func enabledDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + cfg := Config{Enabled: true, AnonymousID: "test-user-123"} + data, _ := json.Marshal(cfg) + if err := os.WriteFile(filepath.Join(dir, "telemetry.json"), data, 0644); err != nil { + t.Fatal(err) + } + return dir +} + +func disabledDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + cfg := Config{Enabled: false, AnonymousID: "test-user-123"} + data, _ := json.Marshal(cfg) + if err := os.WriteFile(filepath.Join(dir, "telemetry.json"), data, 0644); err != nil { + t.Fatal(err) + } + return dir +} + +func TestInit_DisabledConfig(t *testing.T) { + dir := disabledDir(t) + t.Setenv("DO_NOT_TRACK", "") + Init(dir) + defer Close() + if client != nil { + t.Error("expected nil client when telemetry disabled") + } +} + +func TestInit_DoNotTrack(t *testing.T) { + dir := enabledDir(t) + t.Setenv("DO_NOT_TRACK", "1") + Init(dir) + defer Close() + if client != nil { + t.Error("expected nil client when DO_NOT_TRACK=1") + } +} + +func TestInit_Enabled(t *testing.T) { + dir := enabledDir(t) + t.Setenv("DO_NOT_TRACK", "") + Init(dir) + defer Close() + if client == nil { + t.Error("expected non-nil client when telemetry enabled") + } +} + +func TestTrackCommandRun_Properties(t *testing.T) { + dir := enabledDir(t) + t.Setenv("DO_NOT_TRACK", "") + + var captured []posthog.Capture + captureHook = func(c posthog.Capture) { captured = append(captured, c) } + defer func() { captureHook = nil }() + + Init(dir) + defer Close() + + TrackCommandRun("claude", "docker", "go", []string{"llm", "node"}, true) + + if len(captured) != 1 { + t.Fatalf("expected 1 capture, got %d", len(captured)) + } + c := captured[0] + if c.Event != "command_run" { + t.Errorf("event = %q, want command_run", c.Event) + } + if c.DistinctId != "test-user-123" { + t.Errorf("distinctId = %q, want test-user-123", c.DistinctId) + } + checks := map[string]any{ + "command": "claude", + "engine": "docker", + "stack": "go", + "thin": true, + } + for k, want := range checks { + got := c.Properties[k] + if got != want { + t.Errorf("property %q = %v, want %v", k, got, want) + } + } + if c.Properties["os"] == nil { + t.Error("missing os property") + } + if c.Properties["arch"] == nil { + t.Error("missing arch property") + } + if c.Properties["version"] == nil { + t.Error("missing version property") + } +} + +func TestTrackCommandFinish_Properties(t *testing.T) { + dir := enabledDir(t) + t.Setenv("DO_NOT_TRACK", "") + + var captured []posthog.Capture + captureHook = func(c posthog.Capture) { captured = append(captured, c) } + defer func() { captureHook = nil }() + + Init(dir) + defer Close() + + TrackCommandFinish("claude", 1500, true) + + if len(captured) != 1 { + t.Fatalf("expected 1 capture, got %d", len(captured)) + } + c := captured[0] + if c.Event != "command_finish" { + t.Errorf("event = %q, want command_finish", c.Event) + } + if c.Properties["duration_ms"] != int64(1500) { + t.Errorf("duration_ms = %v, want 1500", c.Properties["duration_ms"]) + } + if c.Properties["exit_clean"] != true { + t.Errorf("exit_clean = %v, want true", c.Properties["exit_clean"]) + } +} + +func TestTrack_NilClient(t *testing.T) { + client = nil + anonymousID = "" + Track("test_event", nil) +} + +func TestClose_NilClient(t *testing.T) { + client = nil + Close() +} + +func TestTrack_FeatureEvents(t *testing.T) { + dir := enabledDir(t) + t.Setenv("DO_NOT_TRACK", "") + + var captured []posthog.Capture + captureHook = func(c posthog.Capture) { captured = append(captured, c) } + defer func() { captureHook = nil }() + + Init(dir) + defer Close() + + Track("build", map[string]any{"engine": "qemu", "subcommand": "build", "stack": "go", "thin": true}) + Track("init", map[string]any{"engine": "docker", "stack": "base"}) + Track("serve", map[string]any{"port": 8484, "https": true, "pty": false}) + Track("vnc", map[string]any{"viewer": "royaltsx", "global": true}) + Track("rdp", map[string]any{"viewer": "freerdp", "fullscreen": true}) + Track("models", map[string]any{"source": "all"}) + Track("modules_list", nil) + Track("cleanup", nil) + Track("auth_kube", map[string]any{"skip_cluster": false}) + Track("auth_chrome", map[string]any{"sync_only": false, "no_sync": false}) + + if len(captured) != 10 { + t.Fatalf("expected 10 captures, got %d", len(captured)) + } + + events := make([]string, len(captured)) + for i, c := range captured { + events[i] = c.Event + if c.Properties["os"] == nil { + t.Errorf("capture %d (%s): missing os property", i, c.Event) + } + if c.Properties["version"] == nil { + t.Errorf("capture %d (%s): missing version property", i, c.Event) + } + } + + want := []string{"build", "init", "serve", "vnc", "rdp", "models", "modules_list", "cleanup", "auth_kube", "auth_chrome"} + for i, w := range want { + if events[i] != w { + t.Errorf("event[%d] = %q, want %q", i, events[i], w) + } + } + + if captured[0].Properties["engine"] != "qemu" { + t.Errorf("build.engine = %v, want qemu", captured[0].Properties["engine"]) + } + if captured[2].Properties["port"] != 8484 { + t.Errorf("serve.port = %v, want 8484", captured[2].Properties["port"]) + } +} diff --git a/internal/testutil/testutil.go b/internal/testutil/testutil.go index 2993095..fb1e29e 100644 --- a/internal/testutil/testutil.go +++ b/internal/testutil/testutil.go @@ -1,10 +1,13 @@ // Package testutil provides shared test helpers for saving per-test artifacts -// to persistent output directories for later LLM review. +// to persistent output directories. package testutil import ( + "fmt" "os" + "os/exec" "path/filepath" + "runtime" "strings" "sync" "testing" @@ -12,43 +15,99 @@ import ( ) var ( - runTimestamp string + repoRootOnce sync.Once + repoRootPath string + runTimestampOnce sync.Once + runTimestamp string + + resultsDirsMu sync.Mutex + resultsDirs = map[string]string{} ) -// RunTimestamp returns a stable timestamp for the current test run. -// All tests in the same `go test` invocation share the same timestamp. +// RepoRoot returns the project root by walking up from the caller's source +// file to the nearest directory containing go.mod. +func RepoRoot() string { + repoRootOnce.Do(func() { + _, file, _, _ := runtime.Caller(0) + dir := filepath.Dir(file) + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + repoRootPath = dir + return + } + parent := filepath.Dir(dir) + if parent == dir { + panic(fmt.Sprintf("testutil: could not find project root (go.mod) from %s", file)) + } + dir = parent + } + }) + return repoRootPath +} + +// RunTimestamp returns a stable timestamp for the current test binary run. +// All tests in the same `go test` invocation share the same value. func RunTimestamp() string { runTimestampOnce.Do(func() { - runTimestamp = time.Now().Format("20060102-150405") + runTimestamp = time.Now().Format("20060102T150405") }) return runTimestamp } -// ArtifactDir returns a persistent directory for saving test artifacts: +// ShortSHA returns the abbreviated commit hash of HEAD. +func ShortSHA() string { + cmd := exec.Command("git", "rev-parse", "--short", "HEAD") + cmd.Dir = RepoRoot() + cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1") + out, err := cmd.Output() + if err != nil { + return fmt.Sprintf("dev%s", time.Now().Format("150405")) + } + return strings.TrimSpace(string(out)) +} + +// TestResultsDir returns a persistent results directory for a test: // -// test/testdata/// +// test/results/-// // -// The directory is created automatically. Files written here survive after -// the test finishes, so they can be reviewed by humans or LLMs. -// rootDir should be the path to the repo root (e.g. "../.." from internal/scaffold). -func ArtifactDir(t *testing.T, rootDir string) string { +// All subtests of the same root test share one timestamped parent directory. +// The directory is created automatically. +// +// baseDirFn optionally overrides the base directory (the directory containing +// test/results/). When nil, RepoRoot() is used. Pass a custom function when +// the results path must be remapped (e.g. Docker host path translation). +func TestResultsDir(t *testing.T, baseDirFn func() string) string { t.Helper() - // Sanitize test name: slashes from subtests become dashes - name := strings.ReplaceAll(t.Name(), "/", "-") - dir := filepath.Join(rootDir, "test", "testdata", RunTimestamp(), name) - if err := os.MkdirAll(dir, 0755); err != nil { - t.Fatalf("create artifact dir: %v", err) + + root := t.Name() + if i := strings.Index(root, "/"); i >= 0 { + root = root[:i] } - return dir -} + sub := strings.TrimPrefix(t.Name(), root) + sub = strings.TrimPrefix(sub, "/") -// SaveArtifact writes content to a named file in the test's artifact directory. -func SaveArtifact(t *testing.T, dir, filename string, content []byte) { - t.Helper() - path := filepath.Join(dir, filename) - if err := os.WriteFile(path, content, 0644); err != nil { - t.Fatalf("save artifact %s: %v", filename, err) + resultsDirsMu.Lock() + defer resultsDirsMu.Unlock() + + baseDir, ok := resultsDirs[root] + if !ok { + parent := RepoRoot() + if baseDirFn != nil { + parent = baseDirFn() + } + stamp := time.Now().Format("20060102T150405") + baseDir = filepath.Join(parent, "test", "results", stamp+"-"+root) + resultsDirs[root] = baseDir + } + + dir := baseDir + if sub != "" { + dir = filepath.Join(baseDir, sub) } - t.Logf("artifact: %s", path) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("testutil: create results dir: %v", err) + } + t.Logf("test results: %s", dir) + return dir } diff --git a/internal/ux/phases.go b/internal/ux/phases.go index a0e1846..c012163 100644 --- a/internal/ux/phases.go +++ b/internal/ux/phases.go @@ -32,7 +32,6 @@ // ProgressSpinner.Success because PhaseRunner is a thin convenience wrapper // around it. CELL-261's FormatSecretsPhase produces strings that drop into // the `` slot verbatim. -// package ux // PhaseRunner owns the sequential phase list above the resumed parent @@ -82,6 +81,30 @@ func (p *PhaseRunner) PhaseDetailed(name string, fn func() (detail string, err e return nil } +// PhaseDetailedWarn is like PhaseDetailed but the callback also returns a +// warn bool. When warn is true, the permanent row renders ⚠ (warning) +// instead of ✓ (success). +func (p *PhaseRunner) PhaseDetailedWarn(name string, fn func() (detail string, warn bool, err error)) error { + p.cur = NewProgressSpinner(name) + detail, warn, err := fn() + if err != nil { + p.cur.Fail(name + " — " + err.Error()) + p.cur = nil + return err + } + label := name + if detail != "" { + label = name + " — " + detail + } + if warn { + p.cur.Warn(label) + } else { + p.cur.Success(label) + } + p.cur = nil + return nil +} + // PhaseDetailedRunning is like PhaseDetailed but renders a different label // while the spinner is active vs the permanent ✓/✗ row. Use for phases whose // in-progress text carries a user prompt that no longer applies once the diff --git a/internal/ux/phases_test.go b/internal/ux/phases_test.go index 59c4d7e..50bce3b 100644 --- a/internal/ux/phases_test.go +++ b/internal/ux/phases_test.go @@ -147,6 +147,43 @@ func TestPhaseRunner_PhaseDetailedRunningFailureUsesFinalName(t *testing.T) { } } +func TestPhaseRunner_PhaseDetailedWarnShowsWarningSymbol(t *testing.T) { + defer withPlainText(t)() + out := captureStdoutPhases(func() { + pr := &ux.PhaseRunner{} + _ = pr.PhaseDetailedWarn("Nix store", func() (string, bool, error) { + return "3 profile hashes (drift)", true, nil + }) + }) + stripped := plain(out) + if !strings.Contains(stripped, "⚠") { + t.Errorf("warn=true must render ⚠, got %q", stripped) + } + if strings.Contains(stripped, "✓") { + t.Errorf("warn=true must NOT render ✓, got %q", stripped) + } + if !strings.Contains(stripped, "Nix store — 3 profile hashes (drift)") { + t.Errorf("detail missing: %q", stripped) + } +} + +func TestPhaseRunner_PhaseDetailedWarnFalseShowsSuccess(t *testing.T) { + defer withPlainText(t)() + out := captureStdoutPhases(func() { + pr := &ux.PhaseRunner{} + _ = pr.PhaseDetailedWarn("Nix store", func() (string, bool, error) { + return "clean", false, nil + }) + }) + stripped := plain(out) + if !strings.Contains(stripped, "✓") { + t.Errorf("warn=false must render ✓, got %q", stripped) + } + if strings.Contains(stripped, "⚠") { + t.Errorf("warn=false must NOT render ⚠, got %q", stripped) + } +} + func TestPhaseRunner_SealEmitsFinalRow(t *testing.T) { defer withPlainText(t)() out := captureStdoutPhases(func() { diff --git a/internal/ux/spinner_ansi_test.go b/internal/ux/spinner_ansi_test.go index 4131cbd..4274794 100644 --- a/internal/ux/spinner_ansi_test.go +++ b/internal/ux/spinner_ansi_test.go @@ -194,6 +194,7 @@ func captureStdoutBytes(t *testing.T, fn func()) []byte { // no prior save. Behaviour of restore-with-no-save is terminal-specific: // - xterm: no-op // - Terminal.app / iTerm2: cursor moves to home (1,1) +// // On the affected terminals, the first phase's `\x1b[u\r\x1b[K` jumped // to row 1 and cleared it; subsequent phases then wrote each row at the // same position, leaving only the LAST phase visible. diff --git a/internal/ux/ux.go b/internal/ux/ux.go index 59e4dbf..364b1c7 100644 --- a/internal/ux/ux.go +++ b/internal/ux/ux.go @@ -170,6 +170,14 @@ func (ps *ProgressSpinner) stop() { <-ps.stopped } +// Warn stops the spinner and prints a warning row (⚠ instead of ✓). +func (ps *ProgressSpinner) Warn(message string) *ProgressSpinner { + ps.stop() + elapsed := time.Since(ps.start).Round(time.Millisecond) + fmt.Printf("\r %s %s %s\r\n", prefix(StyleWarning, "⚠"), message, StyleMuted.Render(elapsed.String())) + return ps +} + // Fail stops the spinner and prints a failure message. func (ps *ProgressSpinner) Fail(message string) *ProgressSpinner { ps.stop() @@ -325,12 +333,13 @@ func CloseDebugLog() { func Debugf(format string, a ...any) { if Verbose { msg := fmt.Sprintf(format, a...) - fmt.Printf(" %s %s\n", prefix(StyleDebug, "DBG"), msg) + ts := time.Now().UTC().Format("2006-01-02T15:04:05Z") + fmt.Printf(" %s %s %s\n", prefix(StyleDebug, "DBG"), ts, msg) debugLogMu.Lock() f := debugLogFile debugLogMu.Unlock() if f != nil { - fmt.Fprintln(f, msg) + fmt.Fprintf(f, "%s %s\n", ts, msg) } } } diff --git a/internal/vm/hyperv/hyperv.go b/internal/vm/hyperv/hyperv.go index cf85338..0a479ae 100644 --- a/internal/vm/hyperv/hyperv.go +++ b/internal/vm/hyperv/hyperv.go @@ -9,8 +9,8 @@ import ( // HyperV is a placeholder for a future Hyper-V VM engine. type HyperV struct{} -func New() *HyperV { return &HyperV{} } -func (h *HyperV) Preflight() error { return vm.ErrNotImplemented } -func (h *HyperV) Boot(ctx context.Context) error { return vm.ErrNotImplemented } -func (h *HyperV) Shutdown(ctx context.Context) error { return vm.ErrNotImplemented } +func New() *HyperV { return &HyperV{} } +func (h *HyperV) Preflight() error { return vm.ErrNotImplemented } +func (h *HyperV) Boot(ctx context.Context) error { return vm.ErrNotImplemented } +func (h *HyperV) Shutdown(ctx context.Context) error { return vm.ErrNotImplemented } func (h *HyperV) SSHArgv(binary string, flags, args []string) []string { return nil } diff --git a/internal/vm/libvirt/autodetect.go b/internal/vm/libvirt/autodetect.go new file mode 100644 index 0000000..801eea3 --- /dev/null +++ b/internal/vm/libvirt/autodetect.go @@ -0,0 +1,59 @@ +package libvirt + +import ( + "net" + "os" + + "github.com/DimmKirr/devcell/internal/vm/qemu" +) + +// Probes are the environment signals behind the qemu→libvirt auto-default +// (CELL-378). Injectable so the decision matrix is unit-testable. +type Probes struct { + // InContainer reports whether the CLI runs inside a container. + InContainer func() bool + // HostResolves reports whether the Docker host gateway name resolves. + HostResolves func() bool + // KVMUsable reports whether /dev/kvm can actually be opened. + KVMUsable func() error +} + +// DefaultProbes wires the production signals: /.dockerenv, a DNS lookup of +// host.docker.internal, and qemu.ProbeKVM. All three are one cheap syscall +// or lookup — this runs on every launch. +func DefaultProbes() Probes { + return Probes{ + InContainer: func() bool { + _, err := os.Stat("/.dockerenv") + return err == nil + }, + HostResolves: func() bool { + _, err := net.LookupHost("host.docker.internal") + return err == nil + }, + KVMUsable: func() error { return qemu.ProbeKVM() }, + } +} + +// ShouldDefaultToLibvirt decides whether an --engine=qemu launch should +// upgrade to libvirt remote mode, and why. +// +// Authority ordering follows accel.go: explicit intent always wins (--local +// pins the in-container path; any engine other than qemu is untouched), and +// the upgrade fires only when every probe agrees the environment is a Docker +// cell on a Mac where local qemu can only mean TCG. +func ShouldDefaultToLibvirt(engine string, forceLocal bool, p Probes) (bool, string) { + if engine != "qemu" || forceLocal { + return false, "" + } + if !p.InContainer() { + return false, "" + } + if !p.HostResolves() { + return false, "" + } + if p.KVMUsable() == nil { + return false, "" + } + return true, "in a container with no usable /dev/kvm — local qemu would run TCG (10–20× slower); using libvirt remote mode on the Docker host instead (pin with --local)" +} diff --git a/internal/vm/libvirt/autodetect_test.go b/internal/vm/libvirt/autodetect_test.go new file mode 100644 index 0000000..285f514 --- /dev/null +++ b/internal/vm/libvirt/autodetect_test.go @@ -0,0 +1,79 @@ +package libvirt + +import ( + "errors" + "strings" + "testing" +) + +// --- Auto-default detection (CELL-378) --- +// +// In a Docker cell on a Mac there is no HVF and no /dev/kvm: --engine=qemu +// can only mean TCG, 10–20× slower than the host's HVF behind libvirtd. When +// every probe agrees on that environment, qemu upgrades to libvirt remote +// mode. Authority ordering follows accel.go: an explicit --local always wins, +// and any probe disagreeing means no upgrade. + +func probes(inContainer, hostResolves bool, kvmErr error) Probes { + return Probes{ + InContainer: func() bool { return inContainer }, + HostResolves: func() bool { return hostResolves }, + KVMUsable: func() error { return kvmErr }, + } +} + +var noKVM = errors.New("open /dev/kvm: no such file or directory") + +func TestShouldDefaultToLibvirt_Matrix(t *testing.T) { + cases := []struct { + name string + engine string + forceLocal bool + p Probes + want bool + }{ + {"docker-on-mac, no kvm, qemu", "qemu", false, probes(true, true, noKVM), true}, + {"explicit --local wins", "qemu", true, probes(true, true, noKVM), false}, + {"not in a container", "qemu", false, probes(false, true, noKVM), false}, + {"no docker host gateway", "qemu", false, probes(true, false, noKVM), false}, + {"kvm usable — local is fast", "qemu", false, probes(true, true, nil), false}, + {"docker engine untouched", "docker", false, probes(true, true, noKVM), false}, + {"libvirt engine untouched", "libvirt", false, probes(true, true, noKVM), false}, + {"tart engine untouched", "tart", false, probes(true, true, noKVM), false}, + {"empty engine untouched", "", false, probes(true, true, noKVM), false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, _ := ShouldDefaultToLibvirt(tc.engine, tc.forceLocal, tc.p) + if got != tc.want { + t.Errorf("ShouldDefaultToLibvirt(%q, local=%v) = %v, want %v", + tc.engine, tc.forceLocal, got, tc.want) + } + }) + } +} + +func TestShouldDefaultToLibvirt_ReasonExplainsChoice(t *testing.T) { + ok, reason := ShouldDefaultToLibvirt("qemu", false, probes(true, true, noKVM)) + if !ok { + t.Fatal("expected upgrade") + } + // The reason is user-facing (accel.go's "choice + reason" pattern): it + // must say why local qemu would be slow and where the VM goes instead. + for _, want := range []string{"TCG", "libvirt"} { + if !strings.Contains(reason, want) { + t.Errorf("reason must mention %q, got: %q", want, reason) + } + } +} + +func TestDefaultProbes_AreWired(t *testing.T) { + p := DefaultProbes() + if p.InContainer == nil || p.HostResolves == nil || p.KVMUsable == nil { + t.Fatal("DefaultProbes must wire all three probes") + } + // Smoke: they must be callable without panicking; results depend on env. + _ = p.InContainer() + _ = p.HostResolves() + _ = p.KVMUsable() +} diff --git a/internal/vm/libvirt/client.go b/internal/vm/libvirt/client.go new file mode 100644 index 0000000..26c08b3 --- /dev/null +++ b/internal/vm/libvirt/client.go @@ -0,0 +1,188 @@ +package libvirt + +import ( + "context" + "errors" + "fmt" + "net" + "net/url" + "strings" + "time" + + golibvirt "github.com/digitalocean/go-libvirt" +) + +// defaultTCPPort is libvirtd's standard TCP listen port. +const defaultTCPPort = "16509" + +// Typed connection errors, so callers (preflight, CELL-376) can map each +// failure to its own remediation instead of showing a raw dial error. +var ( + // ErrUnreachable: TCP dial failed — libvirtd is not listening (or the + // host is wrong). + ErrUnreachable = errors.New("libvirtd unreachable") + // ErrHandshake: TCP connected but the libvirt RPC handshake failed — + // wrong service on the port, or auth rejected. + ErrHandshake = errors.New("libvirt handshake failed") +) + +// ParseURI validates a libvirt connection URI and returns the TCP dial +// address. Only qemu+tcp:// is supported: the CLI runs inside a Linux cell +// and reaches the host's libvirtd over TCP (qemu+ssh:// is future work). +func ParseURI(uri string) (string, error) { + u, err := url.Parse(uri) + if err != nil || u.Scheme != "qemu+tcp" || u.Host == "" { + return "", fmt.Errorf("unsupported libvirt URI %q: only qemu+tcp://host[:port]/session|/system is supported", uri) + } + if u.Port() != "" { + return u.Host, nil + } + return net.JoinHostPort(u.Hostname(), defaultTCPPort), nil +} + +// Client is a connection to a libvirtd daemon. +type Client struct { + l *golibvirt.Libvirt + conn net.Conn +} + +// Connect dials the daemon named by uri and completes the libvirt handshake. +// The context bounds both the dial and the handshake. +func Connect(ctx context.Context, uri string) (*Client, error) { + addr, err := ParseURI(uri) + if err != nil { + return nil, err + } + + var d net.Dialer + conn, err := d.DialContext(ctx, "tcp", addr) + if err != nil { + return nil, fmt.Errorf("%w: dial %s: %v", ErrUnreachable, addr, err) + } + + // ConnectToURI has no context parameter; bound the handshake with a + // connection deadline derived from ctx. + if deadline, ok := ctx.Deadline(); ok { + _ = conn.SetDeadline(deadline) + } + + l := golibvirt.New(conn) + remote := golibvirt.QEMUSession + if strings.HasSuffix(strings.TrimSuffix(uri, "/"), "/system") { + remote = golibvirt.QEMUSystem + } + if err := l.ConnectToURI(remote); err != nil { + conn.Close() + return nil, fmt.Errorf("%w: %v", ErrHandshake, err) + } + _ = conn.SetDeadline(time.Time{}) + + return &Client{l: l, conn: conn}, nil +} + +// Close disconnects from the daemon and closes the socket. +// +// go-libvirt's Disconnect tears the socket down itself, so both it and our +// conn.Close can report "use of closed network connection" — benign teardown +// noise, not a failure (it broke the first field run, 2026-07-30). +func (c *Client) Close() error { + err := ignoreErrClosed(c.l.Disconnect()) + if cerr := ignoreErrClosed(c.conn.Close()); err == nil { + err = cerr + } + return err +} + +// ignoreErrClosed drops net.ErrClosed (already-closed socket) and passes +// every other error through. +func ignoreErrClosed(err error) error { + if err == nil || errors.Is(err, net.ErrClosed) { + return nil + } + return err +} + +// ListDomains returns the names of all domains, active and inactive. +func (c *Client) ListDomains() ([]string, error) { + doms, _, err := c.l.ConnectListAllDomains(1, 0) + if err != nil { + return nil, fmt.Errorf("listing domains: %w", err) + } + names := make([]string, 0, len(doms)) + for _, d := range doms { + names = append(names, d.Name) + } + return names, nil +} + +// DefineDomain registers (or replaces) a persistent domain from XML and +// returns its name. +func (c *Client) DefineDomain(xml string) (string, error) { + dom, err := c.l.DomainDefineXML(xml) + if err != nil { + return "", fmt.Errorf("defining domain: %w", err) + } + return dom.Name, nil +} + +// StartDomain boots a defined domain by name. +func (c *Client) StartDomain(name string) error { + dom, err := c.l.DomainLookupByName(name) + if err != nil { + return fmt.Errorf("looking up domain %q: %w", name, err) + } + if err := c.l.DomainCreate(dom); err != nil { + return fmt.Errorf("starting domain %q: %w", name, err) + } + return nil +} + +// ShutdownDomain requests a graceful guest shutdown (ACPI). +func (c *Client) ShutdownDomain(name string) error { + dom, err := c.l.DomainLookupByName(name) + if err != nil { + return fmt.Errorf("looking up domain %q: %w", name, err) + } + if err := c.l.DomainShutdown(dom); err != nil { + return fmt.Errorf("shutting down domain %q: %w", name, err) + } + return nil +} + +// DestroyDomain force-stops a domain (hard power-off). +func (c *Client) DestroyDomain(name string) error { + dom, err := c.l.DomainLookupByName(name) + if err != nil { + return fmt.Errorf("looking up domain %q: %w", name, err) + } + if err := c.l.DomainDestroy(dom); err != nil { + return fmt.Errorf("destroying domain %q: %w", name, err) + } + return nil +} + +// UndefineDomain removes a persistent domain definition. +func (c *Client) UndefineDomain(name string) error { + dom, err := c.l.DomainLookupByName(name) + if err != nil { + return fmt.Errorf("looking up domain %q: %w", name, err) + } + if err := c.l.DomainUndefine(dom); err != nil { + return fmt.Errorf("undefining domain %q: %w", name, err) + } + return nil +} + +// DomainState returns the libvirt run state for a domain (values from +// virDomainState: 1=running, 5=shutoff, ...). +func (c *Client) DomainState(name string) (int32, error) { + dom, err := c.l.DomainLookupByName(name) + if err != nil { + return 0, fmt.Errorf("looking up domain %q: %w", name, err) + } + state, _, err := c.l.DomainGetState(dom, 0) + if err != nil { + return 0, fmt.Errorf("querying state of %q: %w", name, err) + } + return state, nil +} diff --git a/internal/vm/libvirt/client_test.go b/internal/vm/libvirt/client_test.go new file mode 100644 index 0000000..22b98e7 --- /dev/null +++ b/internal/vm/libvirt/client_test.go @@ -0,0 +1,172 @@ +package libvirt + +import ( + "context" + "errors" + "net" + "os" + "strings" + "testing" + "time" +) + +// --- URI parsing (CELL-373) --- +// +// Only the qemu+tcp:// transport is supported for now: the CLI runs inside a +// Linux cell and reaches the macOS host's libvirtd over TCP. qemu+ssh:// is +// the documented hardened alternative but arrives with its own ticket. + +func TestParseURI_DefaultPort(t *testing.T) { + addr, err := ParseURI("qemu+tcp://host.docker.internal/session") + if err != nil { + t.Fatal(err) + } + if addr != "host.docker.internal:16509" { + t.Errorf("addr = %q, want host.docker.internal:16509", addr) + } +} + +func TestParseURI_ExplicitPort(t *testing.T) { + addr, err := ParseURI("qemu+tcp://10.0.0.5:16510/system") + if err != nil { + t.Fatal(err) + } + if addr != "10.0.0.5:16510" { + t.Errorf("addr = %q, want 10.0.0.5:16510", addr) + } +} + +func TestParseURI_RejectsUnsupportedScheme(t *testing.T) { + for _, uri := range []string{ + "qemu+ssh://user@mac/session", + "qemu:///session", + "tcp://host/session", + "", + } { + if _, err := ParseURI(uri); err == nil { + t.Errorf("ParseURI(%q) = nil error, want unsupported-scheme error", uri) + } + } +} + +func TestParseURI_ErrorNamesSupportedScheme(t *testing.T) { + _, err := ParseURI("qemu+ssh://mac/session") + if err == nil { + t.Fatal("expected error") + } + if got := err.Error(); !strings.Contains(got, "qemu+tcp://") { + t.Errorf("error should name the supported scheme, got: %q", got) + } +} + +// --- Connect error classification --- + +func TestConnect_UnreachableIsTyped(t *testing.T) { + // Reserve a port and close the listener so the dial is refused fast. + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + addr := l.Addr().String() + l.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _, err = Connect(ctx, "qemu+tcp://"+addr+"/session") + if err == nil { + t.Fatal("expected connection error against closed port") + } + if !errors.Is(err, ErrUnreachable) { + t.Errorf("error = %v, want errors.Is(err, ErrUnreachable)", err) + } +} + +func TestConnect_HandshakeFailureIsTyped(t *testing.T) { + // A listener that accepts and immediately closes: dial succeeds, the + // libvirt RPC handshake cannot. + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer l.Close() + go func() { + for { + c, err := l.Accept() + if err != nil { + return + } + c.Close() + } + }() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _, err = Connect(ctx, "qemu+tcp://"+l.Addr().String()+"/session") + if err == nil { + t.Fatal("expected handshake error against non-libvirt listener") + } + if !errors.Is(err, ErrHandshake) { + t.Errorf("error = %v, want errors.Is(err, ErrHandshake)", err) + } +} + +func TestConnect_BadURIFailsBeforeDialing(t *testing.T) { + ctx := context.Background() + _, err := Connect(ctx, "qemu+ssh://mac/session") + if err == nil { + t.Fatal("expected URI error") + } + if errors.Is(err, ErrUnreachable) || errors.Is(err, ErrHandshake) { + t.Errorf("URI validation error must not be classified as network error, got: %v", err) + } +} + +// --- Close teardown --- + +func TestIgnoreErrClosed(t *testing.T) { + if got := ignoreErrClosed(nil); got != nil { + t.Errorf("nil must stay nil, got %v", got) + } + wrapped := &net.OpError{Op: "close", Err: net.ErrClosed} + if got := ignoreErrClosed(wrapped); got != nil { + t.Errorf("net.ErrClosed teardown noise must be swallowed, got %v", got) + } + real := errors.New("actual failure") + if got := ignoreErrClosed(real); got != real { + t.Errorf("real errors must pass through, got %v", got) + } +} + +// --- Integration (requires a real libvirtd; opt-in via env) --- + +// Preflight against a live daemon must return nil — the 2026-07-30 field +// failure was Close() surfacing go-libvirt's benign socket-teardown error +// ("use of closed network connection") as a fatal preflight result. +func TestIntegration_PreflightSucceeds(t *testing.T) { + uri := os.Getenv("DEVCELL_LIBVIRT_URI") + if uri == "" { + t.Skip("DEVCELL_LIBVIRT_URI not set — skipping live libvirtd test") + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := Preflight(ctx, uri); err != nil { + t.Fatalf("Preflight against live daemon must succeed, got: %v", err) + } +} + +func TestIntegration_ConnectAndList(t *testing.T) { + uri := os.Getenv("DEVCELL_LIBVIRT_URI") + if uri == "" { + t.Skip("DEVCELL_LIBVIRT_URI not set — skipping live libvirtd test") + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + c, err := Connect(ctx, uri) + if err != nil { + t.Fatalf("Connect(%s): %v", uri, err) + } + defer c.Close() + if _, err := c.ListDomains(); err != nil { + t.Errorf("ListDomains: %v", err) + } +} diff --git a/internal/vm/libvirt/domainxml.go b/internal/vm/libvirt/domainxml.go new file mode 100644 index 0000000..52c83b2 --- /dev/null +++ b/internal/vm/libvirt/domainxml.go @@ -0,0 +1,128 @@ +package libvirt + +import ( + "fmt" + "strings" + + "github.com/DimmKirr/devcell/internal/vm/qemu" + libvirtxml "libvirt.org/go/libvirtxml" +) + +// SpecToDomainXML renders a qemu.Spec as a libvirt domain document. +// +// The domain always targets the macOS host (type hvf, machine virt, cpu +// host-passthrough): in libvirt mode the CLI may run inside a Linux cell, +// but the VM boots on the darwin side of the connection — command.go's +// runtime.GOOS switches must not leak in here. +// +// Anything libvirt can express natively is native (name, memory, vcpu, +// firmware, VNC, reboot policy). Everything else — guest NVMe controller +// (Windows ARM64 has no virtio storage driver inbox, CELL-359), ramfb, +// hostfwd user networking, xhci port sizing, serial chardevs — is taken +// VERBATIM from qemu.BuildRunCommand's argv and passed through +// , so the two launch paths cannot drift: a new argv flag +// flows through automatically unless it is claimed by the native map. +func SpecToDomainXML(spec qemu.Spec) ([]byte, error) { + if spec.VMName == "" || spec.DiskPath == "" || spec.FirmwarePath == "" { + return nil, fmt.Errorf("spec requires VMName, DiskPath, and FirmwarePath (got name=%q disk=%q firmware=%q)", + spec.VMName, spec.DiskPath, spec.FirmwarePath) + } + + d := libvirtxml.Domain{ + Type: "hvf", + Name: spec.VMName, + Memory: &libvirtxml.DomainMemory{ + Value: uint(spec.MemoryGB), + Unit: "GiB", + }, + VCPU: &libvirtxml.DomainVCPU{Value: spec.CPUs}, + OS: &libvirtxml.DomainOS{ + Type: &libvirtxml.DomainOSType{ + Arch: "aarch64", + Machine: "virt", + Type: "hvm", + }, + Loader: &libvirtxml.DomainLoader{ + Path: spec.FirmwarePath, + Readonly: "yes", + Type: "pflash", + }, + }, + CPU: &libvirtxml.DomainCPU{Mode: "host-passthrough"}, + } + if spec.VarsPath != "" { + d.OS.NVRam = &libvirtxml.DomainNVRam{NVRam: spec.VarsPath} + } + if spec.NoReboot { + d.OnReboot = "destroy" + } + if spec.VNCPort > 0 { + d.Devices = &libvirtxml.DomainDeviceList{ + Graphics: []libvirtxml.DomainGraphic{{ + VNC: &libvirtxml.DomainGraphicVNC{ + Port: int(spec.VNCPort), + Listen: "127.0.0.1", + }, + }}, + } + } + + d.QEMUCommandline = &libvirtxml.DomainQEMUCommandline{ + Args: passthroughArgs(spec), + } + + xml, err := d.Marshal() + if err != nil { + return nil, fmt.Errorf("marshalling domain XML: %w", err) + } + return []byte(xml), nil +} + +// nativelyMapped lists BuildRunCommand flags that must NOT be passed through: +// libvirt generates its own equivalents from the native elements above, and +// -qmp is deliberately dropped because libvirt owns the monitor. +var nativelyMapped = map[string]bool{ + "-machine": true, // + "-cpu": true, // + "-accel": true, // + "-smp": true, // + "-m": true, // + "-name": true, // + "-display": true, // absent == headless + "-vnc": true, // + "-qmp": true, // libvirt owns the monitor + "-no-reboot": true, // destroy +} + +// passthroughArgs filters qemu.BuildRunCommand's argv down to the flags +// libvirt cannot express and returns them as qemu:commandline args. +func passthroughArgs(spec qemu.Spec) []libvirtxml.DomainQEMUCommandlineArg { + argv := qemu.BuildRunCommand(spec) + + var out []libvirtxml.DomainQEMUCommandlineArg + i := 1 // argv[0] is the qemu binary + for i < len(argv) { + flag := argv[i] + val := "" + hasVal := false + if i+1 < len(argv) && !strings.HasPrefix(argv[i+1], "-") { + val = argv[i+1] + hasVal = true + i += 2 + } else { + i++ + } + if nativelyMapped[flag] { + continue + } + // Firmware pflash drives map to /. + if flag == "-drive" && strings.Contains(val, "if=pflash") { + continue + } + out = append(out, libvirtxml.DomainQEMUCommandlineArg{Value: flag}) + if hasVal { + out = append(out, libvirtxml.DomainQEMUCommandlineArg{Value: val}) + } + } + return out +} diff --git a/internal/vm/libvirt/domainxml_test.go b/internal/vm/libvirt/domainxml_test.go new file mode 100644 index 0000000..59e0c6e --- /dev/null +++ b/internal/vm/libvirt/domainxml_test.go @@ -0,0 +1,289 @@ +package libvirt + +import ( + "strings" + "testing" + + "github.com/DimmKirr/devcell/internal/vm/qemu" + libvirtxml "libvirt.org/go/libvirtxml" +) + +// --- Spec → domain XML (CELL-374) --- +// +// The translator targets the macOS host explicitly (type hvf, machine virt, +// cpu host) — unlike command.go's runtime.GOOS switches, the CLI may run in +// a Linux cell while the VM always boots on the darwin host behind libvirtd. +// +// Devices that libvirt cannot express natively (guest NVMe controller — +// required by Windows ARM64, CELL-359 — ramfb, xhci port config, hostfwd +// user-net, virtio-serial progress port) ride , keeping exact +// parity with BuildRunCommand. + +func xmlSpec() qemu.Spec { + return qemu.Spec{ + VMName: "devcell-win-test", + CPUs: 4, + MemoryGB: 6, + DiskPath: "/Users/u/.devcell/tpl/disk.qcow2", + FirmwarePath: "/opt/homebrew/share/qemu/edk2-aarch64-code.fd", + VarsPath: "/Users/u/.devcell/inst/vars.fd", + SSHPort: 2222, + SSHHost: "127.0.0.1", + MACAddr: "52:54:00:aa:bb:cc", + DisplayType: "none", + Accel: "hvf", + } +} + +func parseDomain(t *testing.T, xml []byte) *libvirtxml.Domain { + t.Helper() + var d libvirtxml.Domain + if err := d.Unmarshal(string(xml)); err != nil { + t.Fatalf("emitted XML does not parse as a libvirt domain: %v\n%s", err, xml) + } + return &d +} + +func commandlineArgs(d *libvirtxml.Domain) []string { + if d.QEMUCommandline == nil { + return nil + } + var out []string + for _, a := range d.QEMUCommandline.Args { + out = append(out, a.Value) + } + return out +} + +func TestSpecToDomainXML_Basics(t *testing.T) { + xml, err := SpecToDomainXML(xmlSpec()) + if err != nil { + t.Fatal(err) + } + d := parseDomain(t, xml) + + if d.Type != "hvf" { + t.Errorf("domain type = %q, want hvf (macOS host hypervisor)", d.Type) + } + if d.Name != "devcell-win-test" { + t.Errorf("name = %q, want devcell-win-test", d.Name) + } + if d.VCPU == nil || d.VCPU.Value != 4 { + t.Errorf("vcpu = %+v, want 4", d.VCPU) + } + if d.Memory == nil || d.Memory.Value != 6 || d.Memory.Unit != "GiB" { + t.Errorf("memory = %+v, want 6 GiB", d.Memory) + } + if d.OS == nil || d.OS.Type == nil || d.OS.Type.Arch != "aarch64" || d.OS.Type.Machine != "virt" { + t.Errorf("os type = %+v, want arch=aarch64 machine=virt", d.OS) + } +} + +func TestSpecToDomainXML_Firmware(t *testing.T) { + xml, err := SpecToDomainXML(xmlSpec()) + if err != nil { + t.Fatal(err) + } + d := parseDomain(t, xml) + if d.OS.Loader == nil || d.OS.Loader.Path != "/opt/homebrew/share/qemu/edk2-aarch64-code.fd" { + t.Fatalf("loader = %+v, want firmware path", d.OS.Loader) + } + if d.OS.Loader.Readonly != "yes" || d.OS.Loader.Type != "pflash" { + t.Errorf("loader must be readonly pflash, got %+v", d.OS.Loader) + } + if d.OS.NVRam == nil || d.OS.NVRam.NVRam != "/Users/u/.devcell/inst/vars.fd" { + t.Errorf("nvram = %+v, want vars path", d.OS.NVRam) + } +} + +func TestSpecToDomainXML_CPUHostPassthrough(t *testing.T) { + xml, err := SpecToDomainXML(xmlSpec()) + if err != nil { + t.Fatal(err) + } + d := parseDomain(t, xml) + if d.CPU == nil || d.CPU.Mode != "host-passthrough" { + t.Errorf("cpu = %+v, want mode host-passthrough", d.CPU) + } +} + +func TestSpecToDomainXML_NoQMPArg(t *testing.T) { + // libvirt owns the monitor; a -qmp passthrough would fight it. + xml, err := SpecToDomainXML(xmlSpec()) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(xml), "-qmp") { + t.Errorf("domain XML must not pass -qmp (libvirt owns the monitor):\n%s", xml) + } +} + +func TestSpecToDomainXML_NVMeViaCommandline(t *testing.T) { + xml, err := SpecToDomainXML(xmlSpec()) + if err != nil { + t.Fatal(err) + } + d := parseDomain(t, xml) + args := strings.Join(commandlineArgs(d), " ") + if !strings.Contains(args, "file=/Users/u/.devcell/tpl/disk.qcow2") { + t.Errorf("commandline must carry the qcow2 drive, got: %s", args) + } + if !strings.Contains(args, "nvme,drive=disk0,serial=devcell0,bootindex=0") { + t.Errorf("commandline must carry the NVMe device (Windows ARM64 needs stornvme, CELL-359), got: %s", args) + } +} + +func TestSpecToDomainXML_NetHostfwdSSH(t *testing.T) { + xml, err := SpecToDomainXML(xmlSpec()) + if err != nil { + t.Fatal(err) + } + d := parseDomain(t, xml) + args := strings.Join(commandlineArgs(d), " ") + if !strings.Contains(args, "hostfwd=tcp:127.0.0.1:2222-:22") { + t.Errorf("commandline must forward SSH, got: %s", args) + } + if !strings.Contains(args, "virtio-net-pci,netdev=net0,mac=52:54:00:aa:bb:cc") { + t.Errorf("commandline must carry the NIC with pinned MAC, got: %s", args) + } +} + +func TestSpecToDomainXML_NetHostfwdRDPWhenSet(t *testing.T) { + s := xmlSpec() + s.RDPPort = 3390 + xml, err := SpecToDomainXML(s) + if err != nil { + t.Fatal(err) + } + d := parseDomain(t, xml) + args := strings.Join(commandlineArgs(d), " ") + if !strings.Contains(args, "hostfwd=tcp:127.0.0.1:3390-:3389") { + t.Errorf("commandline must forward RDP when RDPPort set, got: %s", args) + } +} + +func TestSpecToDomainXML_VNCNative(t *testing.T) { + s := xmlSpec() + s.VNCPort = 5905 + xml, err := SpecToDomainXML(s) + if err != nil { + t.Fatal(err) + } + d := parseDomain(t, xml) + found := false + if d.Devices != nil { + for _, g := range d.Devices.Graphics { + if g.VNC != nil && g.VNC.Port == 5905 { + found = true + } + } + } + if !found { + t.Errorf("VNCPort=5905 must produce a native , got:\n%s", xml) + } +} + +func TestSpecToDomainXML_GuestProgressChardev(t *testing.T) { + s := xmlSpec() + s.GuestProgressLogPath = "/Users/u/.devcell/inst/guest-progress.log" + xml, err := SpecToDomainXML(s) + if err != nil { + t.Fatal(err) + } + d := parseDomain(t, xml) + args := strings.Join(commandlineArgs(d), " ") + if !strings.Contains(args, "file,id=guestprog,path=/Users/u/.devcell/inst/guest-progress.log") { + t.Errorf("commandline must carry the guest-progress chardev, got: %s", args) + } + if !strings.Contains(args, "virtserialport,bus=virtio-serial0.0,chardev=guestprog,name="+qemu.ProgressPortName) { + t.Errorf("commandline must carry the virtserialport progress device, got: %s", args) + } +} + +func TestSpecToDomainXML_NoRebootMapsToOnReboot(t *testing.T) { + s := xmlSpec() + s.NoReboot = true + xml, err := SpecToDomainXML(s) + if err != nil { + t.Fatal(err) + } + d := parseDomain(t, xml) + if d.OnReboot != "destroy" { + t.Errorf("NoReboot must map to destroy, got %q", d.OnReboot) + } +} + +func TestSpecToDomainXML_RequiresCoreFields(t *testing.T) { + _, err := SpecToDomainXML(qemu.Spec{}) + if err == nil { + t.Error("empty spec must be rejected (no name/disk/firmware)") + } +} + +// Drift guard: every device-shaped argument BuildRunCommand emits must have a +// counterpart in the domain XML — either as a native element (machine, cpu, +// memory, smp, firmware pflash, vnc, name, display) or verbatim on +// . A new argv flag added to baseCommand without a mapping +// here fails this test instead of silently diverging. +func TestSpecToDomainXML_DriftGuardAgainstBuildRunCommand(t *testing.T) { + s := xmlSpec() + s.RDPPort = 3390 + s.VNCPort = 5905 + s.SerialLogPath = "/Users/u/.devcell/inst/serial.log" + s.GuestProgressLogPath = "/Users/u/.devcell/inst/guest-progress.log" + s.NoReboot = true + + xml, err := SpecToDomainXML(s) + if err != nil { + t.Fatal(err) + } + d := parseDomain(t, xml) + cmdline := strings.Join(commandlineArgs(d), " ") + raw := string(xml) + + argv := qemu.BuildRunCommand(s) + + // Flags libvirt owns natively — their values are asserted by the + // dedicated tests above; here we only require the flag be accounted for. + nativelyMapped := map[string]bool{ + "-machine": true, // + "-cpu": true, // + "-accel": true, // + "-smp": true, // + "-m": true, // + "-name": true, // + "-display": true, // omitted == none + "-vnc": true, // + "-qmp": true, // deliberately dropped: libvirt owns the monitor + "-no-reboot": true, // destroy + } + + i := 0 + for i < len(argv) { + arg := argv[i] + if !strings.HasPrefix(arg, "-") { + i++ + continue + } + val := "" + if i+1 < len(argv) && !strings.HasPrefix(argv[i+1], "-") { + val = argv[i+1] + i += 2 + } else { + i++ + } + if nativelyMapped[arg] { + continue + } + // pflash firmware drives map natively to /. + if arg == "-drive" && strings.Contains(val, "if=pflash") { + if !strings.Contains(raw, "pflash") { + t.Errorf("pflash drive %q has no native loader/nvram mapping", val) + } + continue + } + if !strings.Contains(cmdline, val) { + t.Errorf("argv %s %q has no counterpart in domain XML commandline:\n%s", arg, val, cmdline) + } + } +} diff --git a/internal/vm/libvirt/engine.go b/internal/vm/libvirt/engine.go new file mode 100644 index 0000000..d731aea --- /dev/null +++ b/internal/vm/libvirt/engine.go @@ -0,0 +1,169 @@ +package libvirt + +import ( + "context" + "fmt" + "net/url" + "time" + + "github.com/DimmKirr/devcell/internal/vm/qemu" +) + +// virDomainState values (subset). +const ( + DomainRunning int32 = 1 + DomainShutoff int32 = 5 +) + +// DomainClient is the slice of Client the engine needs; injectable in tests. +type DomainClient interface { + DefineDomain(xml string) (string, error) + StartDomain(name string) error + ShutdownDomain(name string) error + DestroyDomain(name string) error + DomainState(name string) (int32, error) + Close() error +} + +// Engine boots and stops a prepped Windows template on the machine behind a +// libvirtd connection (CELL-377). It implements the vm.Engine lifecycle. +type Engine struct { + URI string + Spec qemu.Spec + Map PathMap + + // SSHWaitTimeout bounds the post-boot SSH wait. The template is already + // installed and provisioned, so this is a boot, not a Windows install. + SSHWaitTimeout time.Duration + // ShutdownGraceTimeout bounds the graceful-shutdown wait before escalating + // to destroy. + ShutdownGraceTimeout time.Duration + // ShutdownPollInterval is how often Shutdown re-checks the domain state. + ShutdownPollInterval time.Duration + + // ConnectFn is the transport factory; tests inject a fake. + ConnectFn func(ctx context.Context, uri string) (DomainClient, error) + // WaitSSHFn waits for the forwarded SSH port; tests inject a fake. + WaitSSHFn func(host string, port uint16, timeout time.Duration) error + + client DomainClient + booted bool +} + +// NewEngine builds an engine with production defaults. +func NewEngine(uri string, spec qemu.Spec, m PathMap) *Engine { + e := &Engine{ + URI: uri, + Spec: spec, + Map: m, + SSHWaitTimeout: 5 * time.Minute, + ShutdownGraceTimeout: 30 * time.Second, + ShutdownPollInterval: time.Second, + } + e.ConnectFn = func(ctx context.Context, u string) (DomainClient, error) { + return Connect(ctx, u) + } + e.WaitSSHFn = func(host string, port uint16, timeout time.Duration) error { + return qemu.WaitForSSH(host, port, timeout, 3*time.Second, qemu.NopObserver{}) + } + return e +} + +// SSHHost returns where the forwarded guest ports are reachable from the +// CLI's network namespace: the libvirt URI's hostname — the forward lives on +// the same machine as libvirtd. +func (e *Engine) SSHHost() string { + if u, err := url.Parse(e.URI); err == nil && u.Hostname() != "" { + return u.Hostname() + } + return "host.docker.internal" +} + +// Preflight verifies the daemon answers (vm.Engine interface). +func (e *Engine) Preflight() error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return Preflight(ctx, e.URI) +} + +// Boot defines and starts the domain, then waits for SSH. A domain that is +// already running is attached to instead of redefined. +func (e *Engine) Boot(ctx context.Context) error { + client, err := e.ConnectFn(ctx, e.URI) + if err != nil { + return err + } + e.client = client + + state, stateErr := client.DomainState(e.Spec.VMName) + if stateErr == nil && state == DomainRunning { + // Attach: the VM is up; just verify SSH answers. + e.booted = true + return e.WaitSSHFn(e.SSHHost(), e.Spec.SSHPort, e.SSHWaitTimeout) + } + + xml, err := e.DomainXML() + if err != nil { + return err + } + if _, err := client.DefineDomain(string(xml)); err != nil { + return err + } + if err := client.StartDomain(e.Spec.VMName); err != nil { + return err + } + e.booted = true + return e.WaitSSHFn(e.SSHHost(), e.Spec.SSHPort, e.SSHWaitTimeout) +} + +// DomainXML renders the domain document this engine would define: paths +// translated to the host namespace, hostfwd bound on all interfaces so the +// forward is reachable from the container (the mac's 127.0.0.1 is not). +func (e *Engine) DomainXML() ([]byte, error) { + spec, err := TranslateSpecPaths(e.Spec, e.Map) + if err != nil { + return nil, err + } + spec.SSHHost = "" // hostfwd=tcp::PORT-:22 — bind all interfaces + return SpecToDomainXML(spec) +} + +// Shutdown requests a graceful stop and escalates to destroy when the guest +// does not power off within ShutdownGraceTimeout. A no-op before Boot. +func (e *Engine) Shutdown(ctx context.Context) error { + if !e.booted || e.client == nil { + return nil + } + defer func() { + e.client.Close() + e.client = nil + e.booted = false + }() + + if err := e.client.ShutdownDomain(e.Spec.VMName); err != nil { + return fmt.Errorf("requesting shutdown: %w", err) + } + deadline := time.Now().Add(e.ShutdownGraceTimeout) + for time.Now().Before(deadline) { + state, err := e.client.DomainState(e.Spec.VMName) + if err == nil && state == DomainShutoff { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(e.ShutdownPollInterval): + } + } + return e.client.DestroyDomain(e.Spec.VMName) +} + +// SSHArgv builds the exec argv for the booted guest (vm.Engine interface). +func (e *Engine) SSHArgv(binary string, flags, args []string) []string { + spec := e.Spec + spec.SSHHost = e.SSHHost() + spec.Binary = binary + spec.DefaultFlags = flags + spec.UserArgs = args + return qemu.BuildSSHArgv(spec) +} diff --git a/internal/vm/libvirt/engine_test.go b/internal/vm/libvirt/engine_test.go new file mode 100644 index 0000000..482b8f7 --- /dev/null +++ b/internal/vm/libvirt/engine_test.go @@ -0,0 +1,193 @@ +package libvirt + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/DimmKirr/devcell/internal/vm/qemu" +) + +// --- Engine lifecycle (CELL-377) --- +// +// Boot = translate paths → domain XML → define → start → wait for SSH on the +// forwarded port, reached at the libvirt URI's hostname (the forward lives on +// the same machine as libvirtd). Shutdown = graceful, escalate to destroy. + +type fakeDomains struct { + definedXML string + started []string + shutdown []string + destroyed []string + state int32 // returned by DomainState + stateErr error + afterShut int32 // state after ShutdownDomain was called + shutApplied bool + closed bool +} + +func (f *fakeDomains) DefineDomain(xml string) (string, error) { + f.definedXML = xml + return "fake-domain", nil +} +func (f *fakeDomains) StartDomain(name string) error { + f.started = append(f.started, name) + return nil +} +func (f *fakeDomains) ShutdownDomain(name string) error { + f.shutdown = append(f.shutdown, name) + f.shutApplied = true + return nil +} +func (f *fakeDomains) DestroyDomain(name string) error { + f.destroyed = append(f.destroyed, name) + return nil +} +func (f *fakeDomains) DomainState(name string) (int32, error) { + if f.stateErr != nil { + return 0, f.stateErr + } + if f.shutApplied { + return f.afterShut, nil + } + return f.state, nil +} +func (f *fakeDomains) Close() error { f.closed = true; return nil } + +func engineSpec() qemu.Spec { + return qemu.Spec{ + VMName: "devcell-cell1", + CPUs: 2, + MemoryGB: 4, + DiskPath: "/home/dmitry/.devcell/inst/disk.qcow2", + FirmwarePath: "/home/dmitry/.devcell/fw/code.fd", + SSHPort: 2222, + SSHHost: "127.0.0.1", + } +} + +func testEngine(f *fakeDomains) (*Engine, *[]string) { + var sshWaits []string + e := NewEngine("qemu+tcp://host.docker.internal/session", engineSpec(), testMap()) + e.ConnectFn = func(ctx context.Context, uri string) (DomainClient, error) { return f, nil } + e.WaitSSHFn = func(host string, port uint16, timeout time.Duration) error { + sshWaits = append(sshWaits, host) + return nil + } + return e, &sshWaits +} + +func TestEngine_SSHHostDerivedFromURI(t *testing.T) { + e := NewEngine("qemu+tcp://host.docker.internal/session", engineSpec(), nil) + if got := e.SSHHost(); got != "host.docker.internal" { + t.Errorf("SSHHost() = %q, want host.docker.internal", got) + } +} + +func TestEngine_BootDefinesTranslatedXMLThenStartsThenWaits(t *testing.T) { + f := &fakeDomains{state: DomainShutoff} + e, sshWaits := testEngine(f) + + if err := e.Boot(context.Background()); err != nil { + t.Fatal(err) + } + if f.definedXML == "" { + t.Fatal("Boot must define the domain") + } + if !strings.Contains(f.definedXML, "/Users/dmitry/.devcell/inst/disk.qcow2") { + t.Errorf("defined XML must carry HOST paths, got:\n%s", f.definedXML) + } + if strings.Contains(f.definedXML, "/home/dmitry/.devcell") { + t.Errorf("defined XML must not leak container paths, got:\n%s", f.definedXML) + } + if len(f.started) != 1 { + t.Errorf("Boot must start the defined domain once, got %v", f.started) + } + if len(*sshWaits) != 1 || (*sshWaits)[0] != "host.docker.internal" { + t.Errorf("Boot must wait for SSH at the URI host, got %v", *sshWaits) + } +} + +func TestEngine_BootBindsHostfwdOnAllInterfaces(t *testing.T) { + // The container reaches the forward via host.docker.internal; a forward + // bound to the mac's 127.0.0.1 is unreachable from the Docker VM. + f := &fakeDomains{state: DomainShutoff} + e, _ := testEngine(f) + if err := e.Boot(context.Background()); err != nil { + t.Fatal(err) + } + if !strings.Contains(f.definedXML, "hostfwd=tcp::2222-:22") { + t.Errorf("hostfwd must bind all interfaces (empty host), got:\n%s", f.definedXML) + } +} + +func TestEngine_BootAttachesWhenAlreadyRunning(t *testing.T) { + f := &fakeDomains{state: DomainRunning} + e, sshWaits := testEngine(f) + if err := e.Boot(context.Background()); err != nil { + t.Fatal(err) + } + if f.definedXML != "" || len(f.started) != 0 { + t.Errorf("running domain must be attached, not redefined/restarted (defined=%q started=%v)", f.definedXML, f.started) + } + if len(*sshWaits) != 1 { + t.Errorf("attach must still verify SSH, got %v", *sshWaits) + } +} + +func TestEngine_BootPropagatesConnectFailure(t *testing.T) { + e := NewEngine("qemu+tcp://host.docker.internal/session", engineSpec(), testMap()) + e.ConnectFn = func(ctx context.Context, uri string) (DomainClient, error) { + return nil, ErrUnreachable + } + if err := e.Boot(context.Background()); !errors.Is(err, ErrUnreachable) { + t.Errorf("Boot must propagate connect failure, got %v", err) + } +} + +func TestEngine_ShutdownGraceful(t *testing.T) { + f := &fakeDomains{state: DomainRunning, afterShut: DomainShutoff} + e, _ := testEngine(f) + if err := e.Boot(context.Background()); err != nil { + t.Fatal(err) + } + e.ShutdownPollInterval = time.Millisecond + if err := e.Shutdown(context.Background()); err != nil { + t.Fatal(err) + } + if len(f.shutdown) != 1 { + t.Errorf("expected one graceful shutdown request, got %v", f.shutdown) + } + if len(f.destroyed) != 0 { + t.Errorf("graceful path must not destroy, got %v", f.destroyed) + } +} + +func TestEngine_ShutdownEscalatesToDestroy(t *testing.T) { + f := &fakeDomains{state: DomainRunning, afterShut: DomainRunning} // never shuts down + e, _ := testEngine(f) + if err := e.Boot(context.Background()); err != nil { + t.Fatal(err) + } + e.ShutdownPollInterval = time.Millisecond + e.ShutdownGraceTimeout = 5 * time.Millisecond + if err := e.Shutdown(context.Background()); err != nil { + t.Fatal(err) + } + if len(f.destroyed) != 1 { + t.Errorf("stuck guest must be destroyed, got %v", f.destroyed) + } +} + +func TestEngine_ShutdownWithoutBootIsNoop(t *testing.T) { + f := &fakeDomains{} + e, _ := testEngine(f) + if err := e.Shutdown(context.Background()); err != nil { + t.Errorf("Shutdown before Boot must be a no-op, got %v", err) + } + if len(f.shutdown)+len(f.destroyed) != 0 { + t.Error("no domain operations expected before Boot") + } +} diff --git a/internal/vm/libvirt/libvirt.go b/internal/vm/libvirt/libvirt.go deleted file mode 100644 index 6fae8e8..0000000 --- a/internal/vm/libvirt/libvirt.go +++ /dev/null @@ -1,16 +0,0 @@ -package libvirt - -import ( - "context" - - "github.com/DimmKirr/devcell/internal/vm" -) - -// Libvirt is a placeholder for a future libvirt/QEMU VM engine. -type Libvirt struct{} - -func New() *Libvirt { return &Libvirt{} } -func (l *Libvirt) Preflight() error { return vm.ErrNotImplemented } -func (l *Libvirt) Boot(ctx context.Context) error { return vm.ErrNotImplemented } -func (l *Libvirt) Shutdown(ctx context.Context) error { return vm.ErrNotImplemented } -func (l *Libvirt) SSHArgv(binary string, flags, args []string) []string { return nil } diff --git a/internal/vm/libvirt/pathmap.go b/internal/vm/libvirt/pathmap.go new file mode 100644 index 0000000..ee67e2e --- /dev/null +++ b/internal/vm/libvirt/pathmap.go @@ -0,0 +1,83 @@ +package libvirt + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/DimmKirr/devcell/internal/vm/qemu" +) + +// PathMapping rewrites one container path prefix to its host equivalent. +type PathMapping struct { + From string // container-side prefix (bind mount target) + To string // host-side prefix (bind mount source) +} + +// PathMap translates container paths to host paths for domain XML. +// +// Empty means the CLI already runs on the host — every path passes through. +// Non-empty means strict translation: QEMU on the host cannot open a +// container-only path, so an unmapped path is an error, not a passthrough. +type PathMap []PathMapping + +// TranslateToHost rewrites p using the longest matching mapping prefix. +// Prefixes match on path boundaries only: /devcell-1555 does not match a +// /devcell-155 mapping. +func (m PathMap) TranslateToHost(p string) (string, error) { + if len(m) == 0 { + return p, nil + } + clean := filepath.Clean(p) + + best := -1 + bestLen := -1 + for i, mp := range m { + from := filepath.Clean(mp.From) + if clean != from && !strings.HasPrefix(clean, from+"/") { + continue + } + if len(from) > bestLen { + best, bestLen = i, len(from) + } + } + if best < 0 { + return "", fmt.Errorf("path %q is outside every libvirt path mapping — QEMU on the host cannot open it (add a [cell] libvirt_path_map entry)", p) + } + + from := filepath.Clean(m[best].From) + to := filepath.Clean(m[best].To) + if clean == from { + return to, nil + } + return to + strings.TrimPrefix(clean, from), nil +} + +// TranslateSpecPaths returns a copy of spec with every field QEMU opens on +// the host rewritten through the map. Empty fields stay empty; the input is +// not mutated. SSHKeyPath is deliberately absent: the ssh client runs on the +// CLI side, in the container namespace. +func TranslateSpecPaths(spec qemu.Spec, m PathMap) (qemu.Spec, error) { + out := spec + for _, f := range []struct { + name string + p *string + }{ + {"DiskPath", &out.DiskPath}, + {"FirmwarePath", &out.FirmwarePath}, + {"VarsPath", &out.VarsPath}, + {"VirtioISO", &out.VirtioISO}, + {"SerialLogPath", &out.SerialLogPath}, + {"GuestProgressLogPath", &out.GuestProgressLogPath}, + } { + if *f.p == "" { + continue + } + t, err := m.TranslateToHost(*f.p) + if err != nil { + return qemu.Spec{}, fmt.Errorf("%s: %w", f.name, err) + } + *f.p = t + } + return out, nil +} diff --git a/internal/vm/libvirt/pathmap_test.go b/internal/vm/libvirt/pathmap_test.go new file mode 100644 index 0000000..ca96cc3 --- /dev/null +++ b/internal/vm/libvirt/pathmap_test.go @@ -0,0 +1,164 @@ +package libvirt + +import ( + "strings" + "testing" + + "github.com/DimmKirr/devcell/internal/vm/qemu" +) + +// --- Container→host path translation (CELL-375) --- +// +// The CLI sees bind-mounted paths under container prefixes; QEMU on the host +// must open the same files at their host paths. An empty map means the CLI +// already runs on the host — passthrough. A non-empty map is strict: a path +// outside every mapping can never boot, so it is an error, not a warning. + +func testMap() PathMap { + return PathMap{ + {From: "/devcell-155", To: "/Users/dmitry/dev/dimmkirr/devcell"}, + {From: "/home/dmitry", To: "/Users/dmitry"}, + {From: "/home/dmitry/special", To: "/Volumes/special"}, + } +} + +func TestTranslateToHost_ExactPrefix(t *testing.T) { + got, err := testMap().TranslateToHost("/devcell-155/disk.qcow2") + if err != nil { + t.Fatal(err) + } + if got != "/Users/dmitry/dev/dimmkirr/devcell/disk.qcow2" { + t.Errorf("got %q", got) + } +} + +func TestTranslateToHost_NestedPath(t *testing.T) { + got, err := testMap().TranslateToHost("/home/dmitry/.devcell/tpl/base/disk.qcow2") + if err != nil { + t.Fatal(err) + } + if got != "/Users/dmitry/.devcell/tpl/base/disk.qcow2" { + t.Errorf("got %q", got) + } +} + +func TestTranslateToHost_LongestPrefixWins(t *testing.T) { + got, err := testMap().TranslateToHost("/home/dmitry/special/file") + if err != nil { + t.Fatal(err) + } + if got != "/Volumes/special/file" { + t.Errorf("longest prefix must win, got %q", got) + } +} + +func TestTranslateToHost_PrefixIsPathBoundary(t *testing.T) { + // /devcell-1555 must NOT match the /devcell-155 mapping. + _, err := testMap().TranslateToHost("/devcell-1555/disk.qcow2") + if err == nil { + t.Error("prefix match must respect path boundaries") + } +} + +func TestTranslateToHost_TrailingSlashNormalized(t *testing.T) { + m := PathMap{{From: "/devcell-155/", To: "/Users/dmitry/dev/dimmkirr/devcell/"}} + got, err := m.TranslateToHost("/devcell-155/x") + if err != nil { + t.Fatal(err) + } + if got != "/Users/dmitry/dev/dimmkirr/devcell/x" { + t.Errorf("got %q", got) + } +} + +func TestTranslateToHost_ExactRootOfMapping(t *testing.T) { + got, err := testMap().TranslateToHost("/devcell-155") + if err != nil { + t.Fatal(err) + } + if got != "/Users/dmitry/dev/dimmkirr/devcell" { + t.Errorf("got %q", got) + } +} + +func TestTranslateToHost_UnmappedIsError(t *testing.T) { + _, err := testMap().TranslateToHost("/etc/passwd") + if err == nil { + t.Fatal("unmapped path must be a hard error") + } + if !strings.Contains(err.Error(), "/etc/passwd") { + t.Errorf("error must name the offending path, got: %v", err) + } +} + +func TestTranslateToHost_EmptyMapIsPassthrough(t *testing.T) { + got, err := PathMap(nil).TranslateToHost("/anything/at/all") + if err != nil { + t.Fatal(err) + } + if got != "/anything/at/all" { + t.Errorf("empty map must pass through, got %q", got) + } +} + +// --- Spec-level translation --- + +func TestTranslateSpecPaths_AllFileFields(t *testing.T) { + s := qemu.Spec{ + VMName: "x", + DiskPath: "/home/dmitry/.devcell/inst/disk.qcow2", + FirmwarePath: "/home/dmitry/.devcell/fw/code.fd", + VarsPath: "/home/dmitry/.devcell/inst/vars.fd", + SerialLogPath: "/devcell-155/.scratch/serial.log", + GuestProgressLogPath: "/devcell-155/.scratch/progress.log", + } + out, err := TranslateSpecPaths(s, testMap()) + if err != nil { + t.Fatal(err) + } + want := map[string]string{ + out.DiskPath: "/Users/dmitry/.devcell/inst/disk.qcow2", + out.FirmwarePath: "/Users/dmitry/.devcell/fw/code.fd", + out.VarsPath: "/Users/dmitry/.devcell/inst/vars.fd", + out.SerialLogPath: "/Users/dmitry/dev/dimmkirr/devcell/.scratch/serial.log", + out.GuestProgressLogPath: "/Users/dmitry/dev/dimmkirr/devcell/.scratch/progress.log", + } + for got, expect := range want { + if got != expect { + t.Errorf("got %q, want %q", got, expect) + } + } +} + +func TestTranslateSpecPaths_EmptyFieldsStayEmpty(t *testing.T) { + s := qemu.Spec{VMName: "x", DiskPath: "/devcell-155/d.qcow2", FirmwarePath: "/devcell-155/f.fd"} + out, err := TranslateSpecPaths(s, testMap()) + if err != nil { + t.Fatal(err) + } + if out.VarsPath != "" || out.SerialLogPath != "" { + t.Errorf("empty path fields must stay empty, got %+v", out) + } +} + +func TestTranslateSpecPaths_UnmappedDiskFails(t *testing.T) { + s := qemu.Spec{VMName: "x", DiskPath: "/nix/store/x/disk.qcow2", FirmwarePath: "/devcell-155/f.fd"} + _, err := TranslateSpecPaths(s, testMap()) + if err == nil { + t.Fatal("unmapped DiskPath must fail") + } + if !strings.Contains(err.Error(), "/nix/store/x/disk.qcow2") { + t.Errorf("error must name the path, got: %v", err) + } +} + +func TestTranslateSpecPaths_DoesNotMutateInput(t *testing.T) { + s := qemu.Spec{VMName: "x", DiskPath: "/devcell-155/d.qcow2", FirmwarePath: "/devcell-155/f.fd"} + _, err := TranslateSpecPaths(s, testMap()) + if err != nil { + t.Fatal(err) + } + if s.DiskPath != "/devcell-155/d.qcow2" { + t.Errorf("input spec mutated: %q", s.DiskPath) + } +} diff --git a/internal/vm/libvirt/preflight.go b/internal/vm/libvirt/preflight.go new file mode 100644 index 0000000..6892b67 --- /dev/null +++ b/internal/vm/libvirt/preflight.go @@ -0,0 +1,39 @@ +package libvirt + +import ( + "context" + "errors" + "fmt" +) + +// Preflight verifies a libvirtd daemon answers at uri and completes the RPC +// handshake, mapping each failure mode to one actionable message (the +// CELL-44 pattern: read the error, know the next command). +func Preflight(ctx context.Context, uri string) error { + c, err := Connect(ctx, uri) + if err == nil { + return c.Close() + } + + switch { + case errors.Is(err, ErrUnreachable): + addr, _ := ParseURI(uri) + return fmt.Errorf(`%w + +libvirtd is not answering at %s. On the macOS host: + brew install libvirt + brew services start libvirt +and enable TCP listen for the daemon (listen_tcp = 1, auth_tcp = "none" in +libvirtd.conf — see the libvirt engine docs; qemu+ssh:// is the hardened +alternative). From inside a cell the host is host.docker.internal.`, err, addr) + case errors.Is(err, ErrHandshake): + return fmt.Errorf(`%w + +The port answered but the libvirt RPC handshake failed. Either something +else is listening there, or the daemon requires authentication — devcell's +qemu+tcp transport needs auth_tcp = "none" in libvirtd.conf (or switch to +qemu+ssh://).`, err) + default: + return err + } +} diff --git a/internal/vm/libvirt/preflight_test.go b/internal/vm/libvirt/preflight_test.go new file mode 100644 index 0000000..a46f68f --- /dev/null +++ b/internal/vm/libvirt/preflight_test.go @@ -0,0 +1,82 @@ +package libvirt + +import ( + "context" + "errors" + "net" + "strings" + "testing" + "time" +) + +// --- Preflight (CELL-376) --- +// +// Preflight turns each failure mode into one actionable message, mirroring +// runner.DockerDaemonReachable (CELL-44): the user should read the error and +// know the next command to run on the Mac, not a raw dial error. + +func TestPreflight_ClosedPortNamesRemediation(t *testing.T) { + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + addr := l.Addr().String() + l.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + err = Preflight(ctx, "qemu+tcp://"+addr+"/session") + if err == nil { + t.Fatal("expected error against closed port") + } + if !errors.Is(err, ErrUnreachable) { + t.Errorf("must stay errors.Is-able as ErrUnreachable, got: %v", err) + } + msg := err.Error() + for _, want := range []string{addr, "libvirtd", "brew"} { + if !strings.Contains(msg, want) { + t.Errorf("remediation must mention %q, got: %s", want, msg) + } + } +} + +func TestPreflight_HandshakeFailureNamesAuth(t *testing.T) { + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer l.Close() + go func() { + for { + c, err := l.Accept() + if err != nil { + return + } + c.Close() + } + }() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + err = Preflight(ctx, "qemu+tcp://"+l.Addr().String()+"/session") + if err == nil { + t.Fatal("expected handshake error") + } + if !errors.Is(err, ErrHandshake) { + t.Errorf("must stay errors.Is-able as ErrHandshake, got: %v", err) + } + msg := err.Error() + if !strings.Contains(msg, "auth_tcp") && !strings.Contains(msg, "handshake") { + t.Errorf("remediation must point at handshake/auth config, got: %s", msg) + } +} + +func TestPreflight_BadURIPropagates(t *testing.T) { + err := Preflight(context.Background(), "qemu+ssh://mac/session") + if err == nil { + t.Fatal("expected URI error") + } + if !strings.Contains(err.Error(), "qemu+tcp://") { + t.Errorf("URI error must name the supported scheme, got: %v", err) + } +} diff --git a/internal/vm/qemu/accel.go b/internal/vm/qemu/accel.go new file mode 100644 index 0000000..48cbe8b --- /dev/null +++ b/internal/vm/qemu/accel.go @@ -0,0 +1,95 @@ +package qemu + +import ( + "fmt" + "os" + "runtime" + "strings" +) + +// KVMDevice is the character device QEMU opens to use hardware virtualization +// on Linux. Inside a container it is present only when the launcher passed +// --device=/dev/kvm (see `[cell] kvm` in .devcell.toml). +const KVMDevice = "/dev/kvm" + +// DefaultTCGAccel is the software-emulation fallback. thread=multi lets TCG +// spread guest vCPUs across host threads, which is the single largest win +// available without hardware virtualization. +const DefaultTCGAccel = "tcg,thread=multi" + +// probeDevice reports whether path can be opened read-write — the same check +// QEMU performs before it will use an accelerator. It deliberately opens +// rather than stats: a passed-through /dev/kvm is present but unreadable until +// the session user joins the device's group, and stat cannot tell those apart. +// +// Cost is one open + one close, so it is cheap enough to run on every launch. +func probeDevice(path string) error { + f, err := os.OpenFile(path, os.O_RDWR, 0) + if err != nil { + return err + } + return f.Close() +} + +// ProbeKVM reports whether /dev/kvm is usable by the current process. +func ProbeKVM() error { return probeDevice(KVMDevice) } + +// ResolveAccel picks the QEMU accelerator and returns the choice plus a +// human-readable reason for the launch log. +// +// Order of authority: +// 1. an explicit Spec.Accel — callers (notably tests) always win; +// 2. `[cell] kvm = true` AND the device actually opens — KVM (linux only); +// 3. otherwise TCG. +// +// darwin used to default to HVF, but QEMU 11.x/HVF has USB xhci enumeration +// bugs (CELL-427) that break WinPE boot. TCG is slower but reliable. Pass +// Accel:"hvf" explicitly to opt back in. +// +// Both conditions in (2) are load-bearing. Config alone is not enough: it +// describes intent, and a launch that trusts it on a host without nested +// virtualization dies with "Could not access KVM kernel module". A usable +// device alone is not enough either — config stays the authority, so an +// unrequested accelerator is never silently adopted. +func ResolveAccel(explicit string, kvmRequested bool, goos string, probe func() error) (accel, reason string) { + if explicit != "" { + return explicit, "explicit Spec.Accel override" + } + if goos != "darwin" && kvmRequested { + if err := probe(); err != nil { + return DefaultTCGAccel, fmt.Sprintf("software emulation: kvm requested but %s is unusable: %v", KVMDevice, err) + } + return accelerator(goos), fmt.Sprintf("hardware virtualization: kvm requested and %s opened", KVMDevice) + } + if goos == "darwin" { + return DefaultTCGAccel, "software emulation: TCG default on darwin (pass Accel:\"hvf\" to override)" + } + return DefaultTCGAccel, "software emulation: set `[cell] kvm = true` (and pass --device=/dev/kvm) to use hardware virtualization" +} + +// PreferredAccel returns hardware virtualization when the host can provide it, +// and the caller's TCG string otherwise. +// +// It differs from ResolveAccel in who grants consent: there is no cfg layer in +// a test binary, so a usable device *is* the consent. The fallback stays a +// caller argument because TCG tuning is workload-specific — tb-size=512 is +// worth it for a 70-minute Windows install and meaningless elsewhere, and it is +// rejected outright when passed alongside an accel of kvm. +func PreferredAccel(tcgFallback string) string { + return preferredAccel(tcgFallback, runtime.GOOS, ProbeKVM) +} + +func preferredAccel(tcgFallback, goos string, probe func() error) string { + accel, _ := ResolveAccel("", true, goos, probe) + if strings.HasPrefix(accel, "tcg") { + return tcgFallback + } + return accel +} + +// resolveAccel pins the spec's accelerator so machineType, cpuType and the +// argv builder cannot disagree, and so the probe runs once per launch rather +// than once per caller. +func (s *Spec) resolveAccel(goos string, probe func() error) { + s.Accel, s.AccelReason = ResolveAccel(s.Accel, s.KVM, goos, probe) +} diff --git a/internal/vm/qemu/accel_test.go b/internal/vm/qemu/accel_test.go new file mode 100644 index 0000000..6964606 --- /dev/null +++ b/internal/vm/qemu/accel_test.go @@ -0,0 +1,163 @@ +package qemu + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Accelerator selection (CELL-352 follow-up). +// +// Before this, effectiveAccel() returned a bare "kvm" on every linux host, so +// QEMU aborted at launch with "Could not access KVM kernel module" whenever +// /dev/kvm was absent or unreadable. The only reason that never surfaced is +// that every test hardcodes Accel: "tcg,...". The decision now has two inputs: +// the `[cell] kvm` config intent, and a probe that the device is actually +// openable — the same open(2) QEMU itself performs. + +func okProbe() error { return nil } +func badProbe() error { return errors.New("permission denied") } + +func TestResolveAccel_ExplicitOverrideWins(t *testing.T) { + accel, reason := ResolveAccel("tcg,thread=multi,tb-size=512", true, "linux", okProbe) + assert.Equal(t, "tcg,thread=multi,tb-size=512", accel) + assert.Contains(t, reason, "explicit") +} + +func TestResolveAccel_DarwinDefaultsTCG(t *testing.T) { + accel, reason := ResolveAccel("", true, "darwin", badProbe) + assert.Equal(t, DefaultTCGAccel, accel) + assert.Contains(t, reason, "TCG default on darwin") +} + +func TestResolveAccel_DarwinExplicitHVF(t *testing.T) { + accel, reason := ResolveAccel("hvf", false, "darwin", badProbe) + assert.Equal(t, "hvf", accel) + assert.Contains(t, reason, "explicit") +} + +func TestResolveAccel_LinuxKVMRequestedAndUsable(t *testing.T) { + accel, reason := ResolveAccel("", true, "linux", okProbe) + assert.Equal(t, "kvm", accel) + assert.Contains(t, reason, "kvm") +} + +func TestResolveAccel_LinuxKVMRequestedButUnusableFallsBackToTCG(t *testing.T) { + // The whole point: a config asking for KVM on a host that cannot provide + // it must degrade to emulation, not abort the launch. + accel, reason := ResolveAccel("", true, "linux", badProbe) + assert.Equal(t, DefaultTCGAccel, accel) + assert.Contains(t, reason, "permission denied", "the reason must carry the probe error, not just say 'unavailable'") +} + +func TestResolveAccel_LinuxKVMNotRequestedStaysTCG(t *testing.T) { + // Config is the authority: an unrequested KVM is not silently adopted even + // when the device happens to be usable. + accel, reason := ResolveAccel("", false, "linux", okProbe) + assert.Equal(t, DefaultTCGAccel, accel) + assert.Contains(t, reason, "kvm = true") +} + +// --- the probe itself --- + +func TestProbeDevice_OpenableDevice(t *testing.T) { + assert.NoError(t, probeDevice("/dev/null")) +} + +func TestProbeDevice_MissingDevice(t *testing.T) { + err := probeDevice(filepath.Join(t.TempDir(), "definitely-not-here")) + require.Error(t, err) + assert.Contains(t, err.Error(), "no such file") +} + +func TestProbeDevice_UnreadableDevice(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root bypasses file permissions") + } + p := filepath.Join(t.TempDir(), "locked") + require.NoError(t, os.WriteFile(p, nil, 0o644)) + require.NoError(t, os.Chmod(p, 0o000)) + assert.Error(t, probeDevice(p), "mode 0000 must fail the probe — this is the group-membership case") +} + +// --- Spec wiring --- + +func TestApplyDefaults_ResolvesAccelOnce(t *testing.T) { + var s Spec + s.ApplyDefaults() + assert.NotEmpty(t, s.Accel, "ApplyDefaults must pin the accelerator so machineType/cpuType/argv cannot disagree") + assert.NotEmpty(t, s.AccelReason, "the decision must be explainable in the launch log") +} + +func TestApplyDefaults_KeepsExplicitAccel(t *testing.T) { + s := Spec{Accel: "tcg,thread=multi,tb-size=512"} + s.ApplyDefaults() + assert.Equal(t, "tcg,thread=multi,tb-size=512", s.Accel) +} + +func TestApplyDefaults_KVMSpecGetsKVMArgvWhenUsable(t *testing.T) { + s := testSpec() + s.Accel, s.AccelReason = ResolveAccel("", true, "linux", okProbe) + joined := strings.Join(BuildRunCommand(s), " ") + + assert.Contains(t, joined, "-accel kvm") + // EL2 for the guest needs nested virt the host cannot provide under an + // already-nested KVM ("host kernel KVM does not support providing + // Virtualization extensions to the guest CPU"), so virtualization=true + // must NOT appear. pauth-impdef is a TCG-only speed hack. + assert.NotContains(t, joined, "virtualization=true") + assert.NotContains(t, joined, "pauth-impdef") + assert.Contains(t, joined, "-cpu max") // QEMU maps max→host under KVM +} + +func TestSpec_KVMFieldDrivesResolution(t *testing.T) { + s := Spec{KVM: true} + s.resolveAccel("linux", okProbe) + assert.Equal(t, "kvm", s.Accel) + + s2 := Spec{KVM: false} + s2.resolveAccel("linux", okProbe) + assert.Equal(t, DefaultTCGAccel, s2.Accel) +} + +// --- PreferredAccel (test/tool path: a usable device is the consent) --- + +func TestPreferredAccel_KeepsTunedTCGStringOnFallback(t *testing.T) { + // tb-size is TCG-only and QEMU rejects it alongside accel=kvm, so the + // caller's tuned string must survive the fallback verbatim. + got := preferredAccel("tcg,thread=multi,tb-size=512", "linux", badProbe) + assert.Equal(t, "tcg,thread=multi,tb-size=512", got) +} + +func TestPreferredAccel_UsesKVMWhenUsable(t *testing.T) { + got := preferredAccel("tcg,thread=multi,tb-size=512", "linux", okProbe) + assert.Equal(t, "kvm", got) + assert.NotContains(t, got, "tb-size", "tb-size alongside kvm is rejected by QEMU") +} + +func TestPreferredAccel_DarwinUsesTCG(t *testing.T) { + assert.Equal(t, "tcg,thread=multi", preferredAccel("tcg,thread=multi", "darwin", badProbe)) +} + +// --- Spec.CPU override (single-variable CPU experiments) --- + +func TestBaseCommand_CPUOverride(t *testing.T) { + s := testSpec() + s.Accel = "tcg,thread=multi" + s.CPU = "max,pauth-impdef=on,pmu=off" + joined := strings.Join(BuildRunCommand(s), " ") + assert.Contains(t, joined, "-cpu max,pauth-impdef=on,pmu=off") + assert.Equal(t, 1, strings.Count(joined, "-cpu "), "override must replace the default, not add a second -cpu") +} + +func TestBaseCommand_EmptyCPUKeepsAcceleratorDefault(t *testing.T) { + s := testSpec() + s.Accel = "tcg,thread=multi" + joined := strings.Join(BuildRunCommand(s), " ") + assert.Contains(t, joined, "-cpu max,pauth-impdef=on") +} diff --git a/internal/vm/qemu/boot_nowimlib_test.go b/internal/vm/qemu/boot_nowimlib_test.go new file mode 100644 index 0000000..2caad7d --- /dev/null +++ b/internal/vm/qemu/boot_nowimlib_test.go @@ -0,0 +1,457 @@ +//go:build !wimlib + +package qemu + +import ( + "hash/fnv" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/devcell-sh/go-winkit/diag" + "github.com/devcell-sh/go-winkit/winpe" + + "github.com/devcell-sh/go-winkit/isokit" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func assembleISOFromESD(t *testing.T, _, _ string) { + t.Skip("assembleISOFromESD requires -tags wimlib") +} + +// TestEmptyDiskBoot_StallDetected boots QEMU with an empty disk (no ISO, no +// bootable OS). The firmware finds nothing bootable and drops to the UEFI +// Interactive Shell. The StallTracker must detect the stall within 1 minute: +// the screen, disk reads, and vCPU PC all freeze once the shell prompt appears. +// +// This is the "success if stall detected" test: it validates that the stall +// detection machinery catches a VM that never progressed past the bootloader. +// The real-world failure mode: `cell build --engine=qemu` sat at the UEFI +// shell for 20 minutes before the WriteProgressTracker's window expired, +// because no richer detector was wired into the build path. +// +// Run with: +// +// go test -run TestEmptyDiskBoot_StallDetected -timeout 5m ./internal/vm/qemu/ +func TestEmptyDiskBoot_StallDetected(t *testing.T) { + if testing.Short() { + t.Skip("long: boots QEMU with empty disk to validate stall detection (~2 min)") + } + + qemuBin := requireQEMUBin(t) + fwPath := requireFirmware(t) + + tmpDir := t.TempDir() + resultsDir := testResultsDir(t) + + diskPath := filepath.Join(tmpDir, "disk.qcow2") + out, err := exec.Command(qemuBin+"-img", "create", "-f", "qcow2", diskPath, "64G").CombinedOutput() + if err != nil { + out, err = exec.Command("qemu-img", "create", "-f", "qcow2", diskPath, "64G").CombinedOutput() + } + require.NoError(t, err, "qemu-img create: %s", out) + + varsPath := filepath.Join(tmpDir, "vars.fd") + require.NoError(t, PrepareVarsFile(fwPath, varsPath)) + + serialLog := filepath.Join(resultsDir, "serial.log") + + spec := Spec{ + VMName: "stall-detect-test", + CPUs: 2, + MemoryGB: 2, + DiskPath: diskPath, + FirmwarePath: fwPath, + VarsPath: varsPath, + QMPSocketDir: tmpDir, + DisplayType: "none", + Accel: "tcg,thread=multi", + SerialLogPath: serialLog, + NoReboot: true, + } + spec.ApplyDefaults() + require.NoError(t, spec.Validate()) + + qmpSock := QMPSocketPath(spec) + + // Boot with BuildRunCommand (no ISO) — firmware will find nothing bootable. + argv := BuildRunCommand(spec) + argv[0] = qemuBin + argv = append(argv, "-d", "guest_errors,unimp", "-D", filepath.Join(resultsDir, "qemu-guest-errors.log")) + + t.Logf("QEMU command: %v", argv) + updateRunJSON(t, resultsDir, map[string]any{ + "test": t.Name(), "accel": spec.Accel, "qemu-args": strings.Join(argv, " "), + }) + + exclusiveQEMU(t) + cmd := exec.Command(argv[0], argv[1:]...) + qemuLog := qemuOutput(t, resultsDir, argv) + cmd.Stdout = qemuLog + cmd.Stderr = qemuLog + require.NoError(t, cmd.Start(), "starting QEMU") + defer func() { + cmd.Process.Kill() + cmd.Wait() + }() + + waitForSocket(t, qmpSock, 30*time.Second, qemuLog) + + const ( + pollInterval = 10 * time.Second + stallBudget = 60 * time.Second + timeout = 3 * time.Minute + ) + stallLimit := StallPollsFor(int(stallBudget.Seconds()), int(pollInterval.Seconds())) + var stall StallTracker + + ppmPath := filepath.Join(tmpDir, "screen.ppm") + deadline := time.Now().Add(timeout) + attempt := 0 + + for time.Now().Before(deadline) { + time.Sleep(pollInterval) + attempt++ + + var pollHash uint64 + var pollRead int64 + var pollPC string + + // Screenshot hash + os.Remove(ppmPath) + if err := QMPScreendump(qmpSock, ppmPath); err != nil { + t.Logf("[attempt %d] screendump failed: %v", attempt, err) + continue + } + if ppmData, err := os.ReadFile(ppmPath); err == nil { + h := fnv.New64a() + h.Write(ppmData) + pollHash = h.Sum64() + } + + // Disk I/O + if stats, err := QMPBlockStats(qmpSock); err == nil { + for _, s := range stats { + pollRead += s.ReadBytes + } + } + + // vCPU PC + if regs, err := QMPHumanMonitor(qmpSock, "info registers"); err == nil { + pollPC = diag.ExtractRegister(regs, "PC=") + } + + n := stall.Observe(StallSignal{ScreenHash: pollHash, ReadBytes: pollRead, PC: pollPC}) + t.Logf("[attempt %d] hash=%016x rd=%d PC=%s stall=%d/%d", + attempt, pollHash, pollRead, pollPC, n, stallLimit) + + if stall.Stalled(stallLimit) { + // Save the stalled screenshot for debugging + if _, err := os.Stat(ppmPath); err == nil { + ConvertPPMtoPNG(ppmPath, filepath.Join(resultsDir, "stalled-last.png")) + } + t.Logf("stall detected after %d polls (%v) — empty-disk boot stuck at UEFI shell as expected", + stall.Consecutive(), time.Duration(stall.Consecutive())*pollInterval) + return // SUCCESS: stall was detected + } + } + + // If we get here, the stall detector did not fire — that's a test failure. + assert.Fail(t, "stall detector did not fire within %v — an empty-disk boot should stall at the UEFI shell", timeout) +} + +// TestISOBootReachesBootloader boots QEMU with the Windows ISO and asserts that +// the UEFI firmware finds and starts the boot entry — i.e. does NOT fall +// through to the EFI Interactive Shell. The test kills the VM as soon as the +// serial log shows the outcome, so it finishes in ~10 seconds. +// +// All CDs ride usb-storage on a shared xhci controller (the UTM rule for +// aarch64 `virt`). The old CDBus strategies (usb-bot, virtio-scsi) were +// removed — usb-storage is the only wiring that both EDK2 and WinPE can see. +// +// go test -run TestISOBootReachesBootloader -timeout 5m ./internal/vm/qemu/ +func TestISOBootReachesBootloader(t *testing.T) { + if testing.Short() { + t.Skip("long: boots QEMU with Windows ISO to check firmware device visibility (~10s)") + } + + qemuBin := requireQEMUBin(t) + fwPath := requireFirmware(t) + isoPath := requireWindowsISO(t) + + tmpDir := t.TempDir() + resultsDir := testResultsDir(t) + + diskPath := filepath.Join(tmpDir, "disk.qcow2") + out, err := exec.Command(qemuBin+"-img", "create", "-f", "qcow2", diskPath, "64G").CombinedOutput() + if err != nil { + out, err = exec.Command("qemu-img", "create", "-f", "qcow2", diskPath, "64G").CombinedOutput() + } + require.NoError(t, err, "qemu-img create: %s", out) + + varsPath := filepath.Join(tmpDir, "vars.fd") + require.NoError(t, PrepareVarsFile(fwPath, varsPath)) + + serialLog := filepath.Join(resultsDir, "serial.log") + + spec := Spec{ + VMName: "iso-boot-test", + CPUs: 2, + MemoryGB: 2, + DiskPath: diskPath, + FirmwarePath: fwPath, + VarsPath: varsPath, + QMPSocketDir: tmpDir, + DisplayType: "none", + Accel: "tcg,thread=multi", + SerialLogPath: serialLog, + NoReboot: true, + } + spec.ApplyDefaults() + require.NoError(t, spec.Validate()) + + argv := BuildInstallCommand(spec, isoPath, "") + argv[0] = qemuBin + argv = append(argv, "-d", "guest_errors,unimp", "-D", filepath.Join(resultsDir, "qemu-guest-errors.log")) + + t.Logf("QEMU command: %v", argv) + updateRunJSON(t, resultsDir, map[string]any{ + "test": t.Name(), "accel": spec.Accel, "qemu-args": strings.Join(argv, " "), + }) + + exclusiveQEMU(t) + cmd := exec.Command(argv[0], argv[1:]...) + qemuLog := qemuOutput(t, resultsDir, argv) + cmd.Stdout = qemuLog + cmd.Stderr = qemuLog + require.NoError(t, cmd.Start(), "starting QEMU") + defer func() { + cmd.Process.Kill() + cmd.Wait() + }() + + stop := make(chan struct{}) + defer close(stop) + efiShellCh := WatchSerialForEFIShell(serialLog, stop) + + bootSuccess := make(chan string, 1) + go func() { + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-stop: + return + case <-ticker.C: + data, err := os.ReadFile(serialLog) + if err != nil { + continue + } + s := string(data) + if strings.Contains(s, "BdsDxe: starting") && !strings.Contains(s, EFIShellMarker) { + for _, line := range strings.Split(s, "\n") { + if strings.Contains(line, "BdsDxe: starting") { + bootSuccess <- stripANSI(line) + return + } + } + } + } + } + }() + + timeout := 90 * time.Second + select { + case reason := <-efiShellCh: + if data, err := os.ReadFile(serialLog); err == nil { + t.Logf("serial log:\n%s", string(data)) + } + t.Fatalf("firmware dropped to EFI shell — ISO not visible via usb-storage: %s", reason) + + case device := <-bootSuccess: + t.Logf("firmware found boot device via usb-storage: %s", device) + if data, err := os.ReadFile(serialLog); err == nil { + t.Logf("serial log:\n%s", string(data)) + os.WriteFile(filepath.Join(resultsDir, "serial-final.log"), data, 0644) + } + + case <-time.After(timeout): + if data, err := os.ReadFile(serialLog); err == nil { + t.Logf("serial log at timeout:\n%s", string(data)) + } + t.Fatalf("timed out after %s waiting for firmware boot decision via usb-storage", timeout) + } +} + +// TestISOBootWithStartupNSH boots QEMU with the Windows ISO and a FAT image +// carrying startup.nsh. This tests the CELL-427 recovery path: when the +// firmware's boot manager can't load the CD (QEMU 11/HVF regression), the EFI +// shell executes startup.nsh which chainloads BOOTAA64.EFI from whichever FS +// has it. +// +// On TCG/QEMU 10 the firmware boots directly and startup.nsh is never needed. +// On HVF/QEMU 11 the firmware drops to the EFI shell and startup.nsh recovers. +// Both outcomes are success: the test fails only if startup.nsh itself reports +// "BOOTAA64.EFI not found". +// +// go test -run TestISOBootWithStartupNSH -timeout 5m ./internal/vm/qemu/ +func TestISOBootWithStartupNSH(t *testing.T) { + if testing.Short() { + t.Skip("long: boots QEMU with startup.nsh fallback (~15s)") + } + + qemuBin := requireQEMUBin(t) + fwPath := requireFirmware(t) + isoPath := requireWindowsISO(t) + + tmpDir := t.TempDir() + resultsDir := testResultsDir(t) + + diskPath := filepath.Join(tmpDir, "disk.qcow2") + out, err := exec.Command(qemuBin+"-img", "create", "-f", "qcow2", diskPath, "64G").CombinedOutput() + if err != nil { + out, err = exec.Command("qemu-img", "create", "-f", "qcow2", diskPath, "64G").CombinedOutput() + } + require.NoError(t, err, "qemu-img create: %s", out) + + varsPath := filepath.Join(tmpDir, "vars.fd") + require.NoError(t, PrepareVarsFile(fwPath, varsPath)) + + // Build a FAT image with startup.nsh — the same recovery script the + // production build puts on the answer volume. + startupImg := filepath.Join(tmpDir, "startup.img") + require.NoError(t, isokit.CreateFATImage(startupImg, map[string][]byte{ + "/startup.nsh": winpe.PadForFAT([]byte(winpe.StartupNSH)), + })) + + serialLog := filepath.Join(resultsDir, "serial.log") + + spec := Spec{ + VMName: "iso-boot-nsh-test", + CPUs: 2, + MemoryGB: 2, + DiskPath: diskPath, + FirmwarePath: fwPath, + VarsPath: varsPath, + QMPSocketDir: tmpDir, + DisplayType: "none", + SerialLogPath: serialLog, + NoReboot: true, + } + // Let ApplyDefaults resolve the accelerator — HVF on macOS, TCG on Linux. + spec.ApplyDefaults() + require.NoError(t, spec.Validate()) + + argv := BuildInstallCommand(spec, isoPath, startupImg) + argv[0] = qemuBin + argv = append(argv, "-d", "guest_errors,unimp", "-D", filepath.Join(resultsDir, "qemu-guest-errors.log")) + + t.Logf("accel=%s QEMU command: %v", spec.Accel, argv) + updateRunJSON(t, resultsDir, map[string]any{ + "test": t.Name(), "accel": spec.Accel, "qemu-args": strings.Join(argv, " "), + }) + + exclusiveQEMU(t) + cmd := exec.Command(argv[0], argv[1:]...) + qemuLog := qemuOutput(t, resultsDir, argv) + cmd.Stdout = qemuLog + cmd.Stderr = qemuLog + require.NoError(t, cmd.Start(), "starting QEMU") + defer func() { + cmd.Process.Kill() + cmd.Wait() + }() + + stop := make(chan struct{}) + defer close(stop) + + // Success: direct boot (BdsDxe: starting without EFI shell) + directBoot := make(chan string, 1) + go func() { + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-stop: + return + case <-ticker.C: + data, _ := os.ReadFile(serialLog) + s := string(data) + if strings.Contains(s, "BdsDxe: starting") && !strings.Contains(s, EFIShellMarker) { + for _, line := range strings.Split(s, "\n") { + if strings.Contains(line, "BdsDxe: starting") { + directBoot <- stripANSI(line) + return + } + } + } + } + } + }() + + // Success: startup.nsh recovery (EFI shell appeared, startup.nsh ran, + // "Searching for Windows EFI boot loader" appeared but "not found" didn't) + nshRecovery := make(chan string, 1) + go func() { + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + sawSearch := false + for { + select { + case <-stop: + return + case <-ticker.C: + data, _ := os.ReadFile(serialLog) + s := string(data) + if !sawSearch && strings.Contains(s, "Searching for Windows EFI boot loader") { + sawSearch = true + } + // Once startup.nsh started searching and enough time has + // passed for it to complete (it's sequential FS0-FS4 checks), + // if we haven't seen the failure marker, it found BOOTAA64.EFI. + if sawSearch && !strings.Contains(s, StartupNSHFailMarker) { + // Give startup.nsh 3 seconds to either succeed or fail + time.Sleep(3 * time.Second) + data, _ = os.ReadFile(serialLog) + if !strings.Contains(string(data), StartupNSHFailMarker) { + nshRecovery <- "startup.nsh chainloaded BOOTAA64.EFI" + return + } + } + } + } + }() + + // Failure: startup.nsh reported BOOTAA64.EFI not found + nshFail := WatchSerialForStartupNSHFail(serialLog, stop) + + timeout := 90 * time.Second + select { + case device := <-directBoot: + t.Logf("direct boot success: %s", device) + + case msg := <-nshRecovery: + t.Logf("startup.nsh recovery success: %s", msg) + + case reason := <-nshFail: + if data, err := os.ReadFile(serialLog); err == nil { + t.Logf("serial log:\n%s", string(data)) + } + t.Fatalf("startup.nsh could not find BOOTAA64.EFI: %s", reason) + + case <-time.After(timeout): + if data, err := os.ReadFile(serialLog); err == nil { + t.Logf("serial log at timeout:\n%s", string(data)) + } + t.Fatalf("timed out after %s waiting for boot decision", timeout) + } + + if data, err := os.ReadFile(serialLog); err == nil { + t.Logf("serial log:\n%s", string(data)) + os.WriteFile(filepath.Join(resultsDir, "serial-final.log"), data, 0644) + } +} diff --git a/internal/vm/qemu/boot_test.go b/internal/vm/qemu/boot_test.go new file mode 100644 index 0000000..d6f6af3 --- /dev/null +++ b/internal/vm/qemu/boot_test.go @@ -0,0 +1,923 @@ +package qemu + +import ( + "bytes" + "encoding/json" + "fmt" + "hash/fnv" + "image/color" + "net" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "testing" + "time" + + "github.com/devcell-sh/go-winkit/diag" + + "github.com/DimmKirr/devcell/internal/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// Unit tests for BluePixelRatio (always run) +// --------------------------------------------------------------------------- + +func TestBluePixelRatio_AllBlue(t *testing.T) { + dir := t.TempDir() + ppm := filepath.Join(dir, "blue.ppm") + // Windows Setup blue: approximately (0, 102, 204) + writePPMP6(t, ppm, 100, 100, color.RGBA{R: 0, G: 80, B: 200, A: 255}) + + ratio, err := BluePixelRatio(ppm) + require.NoError(t, err) + assert.InDelta(t, 1.0, ratio, 0.01, "all-blue image should be ~100%%") +} + +func TestBluePixelRatio_NoBlue(t *testing.T) { + dir := t.TempDir() + ppm := filepath.Join(dir, "red.ppm") + writePPMP6(t, ppm, 100, 100, color.RGBA{R: 200, G: 50, B: 50, A: 255}) + + ratio, err := BluePixelRatio(ppm) + require.NoError(t, err) + assert.InDelta(t, 0.0, ratio, 0.01, "all-red image should be ~0%% blue") +} + +func TestBluePixelRatio_MixedHalf(t *testing.T) { + dir := t.TempDir() + ppm := filepath.Join(dir, "mixed.ppm") + + // 2x1 image: one blue pixel, one white pixel + f, err := os.Create(ppm) + require.NoError(t, err) + fmt.Fprintf(f, "P6\n2 1\n255\n") + f.Write([]byte{0, 80, 200}) // blue + f.Write([]byte{255, 255, 255}) // white + f.Close() + + ratio, err := BluePixelRatio(ppm) + require.NoError(t, err) + assert.InDelta(t, 0.5, ratio, 0.01, "half-blue image should be ~50%%") +} + +func TestBluePixelRatio_Black(t *testing.T) { + dir := t.TempDir() + ppm := filepath.Join(dir, "black.ppm") + writePPMP6(t, ppm, 100, 100, color.RGBA{R: 0, G: 0, B: 0, A: 255}) + + ratio, err := BluePixelRatio(ppm) + require.NoError(t, err) + assert.InDelta(t, 0.0, ratio, 0.01, "all-black should be 0%% blue") +} + +// --------------------------------------------------------------------------- +// Unit tests for screen classification / screenshot naming (always run) +// --------------------------------------------------------------------------- + +// Regression: screenshots were named `screen-%03d-blue%.0f.png`, encoding only +// the blue ratio. Windows 11 Setup measures ~1.2% blue, so the two runs that +// SUCCEEDED via the white-on-purple criterion were written as +// `screen-006-blue1.png` / `screen-007-blue1.png` — indistinguishable from a +// failure by name. That cost a full misdiagnosis cycle. The file name must say +// which criterion decided. +func TestClassifyScreen_Win11SetupIsNotNamedAfterBlue(t *testing.T) { + // Ratios as measured on test/results/20260730T044831 screen-007, the + // Windows 11 "Select language settings" wizard. + v := classifyScreen(0.012, 0.73, 0.16) + assert.Equal(t, verdictWin11UI, v, "a white wizard on a purple backdrop is a Win11 Setup pass") + + name := screenshotName(screenshotNameTestTime, 7, v, 0.012, 0.73, 0.16) + assert.Contains(t, name, string(verdictWin11UI), "the name must state the deciding criterion") + assert.NotContains(t, name, "-blue", "a Win11 pass must not be named as if blue decided it") +} + +func TestClassifyScreen_ClassicBlueStillRecognised(t *testing.T) { + v := classifyScreen(0.85, 0.05, 0.0) + assert.Equal(t, verdictClassicBlue, v, "legacy Setup media must still pass on blue") + assert.Contains(t, screenshotName(screenshotNameTestTime, 1, v, 0.85, 0.05, 0.0), string(verdictClassicBlue)) +} + +// install-080.png of run 20260729T190505: the TianoCore firmware splash, almost +// entirely black. It must not read as a running installer. +func TestClassifyScreen_FirmwareSplashIsNotSuccess(t *testing.T) { + v := classifyScreen(0.0, 0.02, 0.0) + assert.Equal(t, verdictNone, v) + assert.Contains(t, screenshotName(screenshotNameTestTime, 80, v, 0.0, 0.02, 0.0), string(verdictNone)) +} + +// A fixed instant so name-format tests are deterministic. +var screenshotNameTestTime = time.Date(2026, 7, 30, 22, 15, 30, 0, time.UTC) + +// All three ratios belong in the name: a frame that fails is far easier to +// triage when the numbers that were measured are visible without opening it. +func TestScreenshotName_EncodesAllThreeRatios(t *testing.T) { + name := screenshotName(screenshotNameTestTime, 12, verdictNone, 0.01, 0.73, 0.16) + for _, want := range []string{"b01", "w73", "p16"} { + assert.Contains(t, name, want, "name must encode every measured ratio") + } + assert.True(t, strings.HasSuffix(name, ".png"), "name must stay a .png: %s", name) +} + +// The name is `---.png`: the acquisition +// technology first (qmp screendump vs an rdp/vnc session capture — they see +// different framebuffers and must never be conflated when triaging), then +// capture time so a listing sorts chronologically within a tech. The +// timestamp is ISO 8601 basic format — no colons, safe on every filesystem. +func TestScreenshotName_TimestampFirstThenScreenNameThenID(t *testing.T) { + name := screenshotName(screenshotNameTestTime, 12, verdictNone, 0.01, 0.73, 0.16) + assert.Equal(t, "qmp-20260730T221530Z-none-b01-w73-p16-012.png", name) + + // The instant is stamped in UTC regardless of the caller's zone. + inCET := screenshotNameTestTime.In(time.FixedZone("CET", 3600)) + assert.Equal(t, name, screenshotName(inCET, 12, verdictNone, 0.01, 0.73, 0.16), + "the timestamp must be normalised to UTC") +} + +// --------------------------------------------------------------------------- +// Integration: boot Windows ISO in QEMU with TCG, screenshot blue detection +// --------------------------------------------------------------------------- + +// windowsBootConfigs is the executable evidence table behind the 2026-07-30 +// PMU root cause. Each row is one accelerator/CPU configuration with the +// outcome the evidence demands; the rows form single-variable pairs: +// +// config accel cpu expected +// TCG tcg,thread=multi max,pauth-impdef=on (dflt) Setup UI (~76s) +// TCG_NoPMU tcg,thread=multi … same +pmu=off park in sync-exception +// vector (+0x200, DAIF masked) +// KVM kvm max (dflt) Setup UI — but only on a +// host whose KVM has a vPMU; +// skips pre-launch otherwise +// +// TCG vs TCG_NoPMU differ in exactly one CPU feature, so together they prove +// "this media requires a PMU" with no accelerator involved. The KVM row ties +// that requirement to the host: its pre-launch ioctl (WindowsBootBlocker) +// names the missing vPMU on nested hosts, and on PMU-capable hosts it is a +// live boot assertion. Anyone doubting the PMU claim runs TCG_NoPMU. +type windowsBootConfig struct { + accel string + cpu string // "" = cpuType default for the accelerator + expect windowsBootOutcome +} + +type windowsBootOutcome int + +const ( + // expectSetup: the Windows Setup UI must be detected. + expectSetup windowsBootOutcome = iota + // expectPMUStall: the guest must park in its synchronous-exception vector + // — the no-PMU signature. Booting to Setup FAILS this expectation. + expectPMUStall +) + +var windowsBootConfigs = map[string]windowsBootConfig{ + "TCG": {accel: "tcg,thread=multi", expect: expectSetup}, + "TCG_NoPMU": {accel: "tcg,thread=multi", cpu: "max,pauth-impdef=on,pmu=off", expect: expectPMUStall}, + "KVM": {accel: "kvm", expect: expectSetup}, +} + +// TestWindowsISOBoot boots a Windows installer ISO in QEMU and asserts the +// installer starts by detecting the Setup UI in a screenshot. +// +// Long test: requires QEMU, UEFI firmware, and a Windows ISO (or ESD to +// assemble one). Run with: +// +// go test -tags wimlib -run TestWindowsISOBoot/tcg -timeout 30m ./internal/vm/qemu/ +// go test -tags wimlib -run TestWindowsISOBoot/hvf -timeout 30m ./internal/vm/qemu/ +// +// The ISO is resolved from (in priority order): +// 1. DEVCELL_TEST_WINDOWS_ISO env var (pre-built ISO) +// 2. Cached ISO at ~/.devcell/cache/qemu/windows-arm64-en-us.iso +// 3. DEVCELL_TEST_ESD_PATH env var → assembled on the fly (needs -tags wimlib) +func TestWindowsISOBoot(t *testing.T) { + if testing.Short() { + t.Skip("long: boots Windows ISO in QEMU (~5 min)") + } + for _, accel := range []string{"tcg", "hvf"} { + t.Run(accel, func(t *testing.T) { + if accel == "hvf" && runtime.GOOS != "darwin" { + t.Skip("hvf requires macOS") + } + cfg := windowsBootConfigs["TCG"] + if accel == "hvf" { + cfg.accel = "hvf" + } + bootWindowsISO(t, cfg) + }) + } +} + +// TestWindowsISOBoot_TCG_NoPMU is the promoted form of the 2026-07-30 ad-hoc +// disproof of "Windows runs fine without a PMU": the passing TCG config with +// exactly one token added (pmu=off) must park in bootmgr's panic vector +// instead of reaching Setup. If this test ever FAILS by booting, the PMU +// requirement no longer holds (new media or QEMU behavior) and the KVM +// blocker in KVMHostCaps.WindowsBootBlocker deserves re-examination. +func TestWindowsISOBoot_TCG_NoPMU(t *testing.T) { + if testing.Short() { + t.Skip("long: boots Windows ISO in QEMU with TCG, PMU disabled (~2 min)") + } + bootWindowsISO(t, windowsBootConfigs["TCG_NoPMU"]) +} + +// TestWindowsISOBoot_KVM boots the same media through the same code path under +// hardware virtualization, so the accelerator is the only variable between the +// two tests. +// +// It skips rather than fails when /dev/kvm is unusable: that is a host +// property, not a defect. Getting the device requires `[cell] kvm = true` in +// .devcell.toml (which passes --device=/dev/kvm) AND the session user in the +// device's group, which the entrypoint arranges at container start. +// +// go test -run TestWindowsISOBoot_KVM -timeout 30m ./internal/vm/qemu/ +func TestWindowsISOBoot_KVM(t *testing.T) { + if testing.Short() { + t.Skip("long: boots Windows ISO in QEMU with KVM") + } + if err := ProbeKVM(); err != nil { + t.Skipf("%s unusable (%v) — needs `[cell] kvm = true` and group membership on the device", + KVMDevice, err) + } + // A usable device is not sufficient: Windows has host-capability + // requirements KVM cannot paper over. Skip with the specific wall rather + // than spend a stall-timeout rediscovering it — this keeps the test a live + // assertion on capable hosts (bare-metal ARM with a PMU) and an accurate + // explanation on incapable ones. + if caps, err := QueryKVMHostCaps(KVMDevice); err == nil { + t.Logf("kvm host caps: %s", caps.Summary()) + if reason := caps.WindowsBootBlocker(); reason != "" { + t.Skipf("KVM is usable but cannot boot Windows ARM64: %s "+ + "(local proof of the PMU requirement: TestWindowsISOBoot_TCG_NoPMU)", reason) + } + } + bootWindowsISO(t, windowsBootConfigs["KVM"]) +} + +// bootWindowsISO is the shared body of the accelerator-specific tests above. +// Keeping one body means a device-wiring or classifier change cannot silently +// apply to one configuration and not the others. +func bootWindowsISO(t *testing.T, cfg windowsBootConfig) { + t.Helper() + accel := cfg.accel + + qemuBin := requireQEMUBin(t) + fwPath := requireFirmware(t) + isoPath := requireWindowsISO(t) + + tmpDir := t.TempDir() + resultsDir := testResultsDir(t) + + // Create a small qcow2 disk (UEFI needs a disk target even for ISO boot) + diskPath := filepath.Join(tmpDir, "disk.qcow2") + // 100G thin-provisioned: Win11 setup enforces a ~64GB minimum disk at the + // partitioning step; qcow2 only consumes host space as the guest writes. + out, err := exec.Command(qemuBin+"-img", "create", "-f", "qcow2", diskPath, "100G").CombinedOutput() + if err != nil { + out, err = exec.Command("qemu-img", "create", "-f", "qcow2", diskPath, "100G").CombinedOutput() + } + require.NoError(t, err, "qemu-img create: %s", out) + + varsPath := filepath.Join(tmpDir, "vars.fd") + require.NoError(t, PrepareVarsFile(fwPath, varsPath)) + + serialLog := filepath.Join(resultsDir, "serial.log") + + // Build the argv through the same code path production uses — no + // hand-rolled device list. A divergence here is what let the broken + // usb-storage CD wiring survive in BuildInstallCommand unnoticed. + spec := Spec{ + VMName: "boot-test", + CPUs: 4, + MemoryGB: 4, + DiskPath: diskPath, + FirmwarePath: fwPath, + VarsPath: varsPath, + QMPSocketDir: tmpDir, + DisplayType: "none", + Accel: accel, + CPU: cfg.cpu, + SerialLogPath: serialLog, + NoReboot: true, + } + spec.ApplyDefaults() + require.NoError(t, spec.Validate()) + + qmpSock := QMPSocketPath(spec) + argv := BuildInstallCommand(spec, isoPath, "") + argv[0] = qemuBin + // QEMU-side diagnostics. guest_errors reports invalid guest accesses (bad + // MMIO width, writes to read-only regions) and unimp reports unimplemented + // device functionality — both are silent otherwise, and both are prime + // suspects for a firmware fault that happens only under KVM. + argv = append(argv, "-d", "guest_errors,unimp", "-D", filepath.Join(resultsDir, "qemu-guest-errors.log")) + t.Logf("serial log: %s", serialLog) + t.Logf("accel: %s", spec.Accel) + + // Persist the decisive facts next to the screenshots. Reading the accel out + // of stdout is not good enough: a run directory that records 40 frames but + // not which accelerator produced them cannot be attributed afterwards + // without re-deriving it from the code state at launch. + updateRunJSON(t, resultsDir, map[string]any{ + "test": t.Name(), "accel": spec.Accel, "machine": machineType(spec), + "cpu": cpuType(spec), "qemu": qemuBin, "iso": isoPath, + "qemu-args": strings.Join(argv, " "), + }) + + t.Logf("QEMU command: %v", argv) + exclusiveQEMU(t) + cmd := exec.Command(argv[0], argv[1:]...) + qemuLog := qemuOutput(t, resultsDir, argv) + cmd.Stdout = qemuLog + cmd.Stderr = qemuLog + require.NoError(t, cmd.Start(), "starting QEMU") + + defer func() { + cmd.Process.Kill() + cmd.Wait() + }() + + waitForSocket(t, qmpSock, 30*time.Second, qemuLog) + + assertAccel(t, qmpSock, accel, resultsDir) + + // KVM-specific: what can this host's KVM actually give the guest? + if kvmEnabled, _, err := QMPQueryKVM(qmpSock); err == nil && kvmEnabled { + if caps, err := QueryKVMHostCaps(KVMDevice); err == nil { + t.Logf("kvm host caps: %s", caps.Summary()) + updateRunJSON(t, resultsDir, map[string]any{"kvm-caps": caps.Summary()}) + } else { + t.Logf("WARNING: kvm host caps query failed: %v", err) + } + } + + blockStats, err := QMPBlockStats(qmpSock) + require.NoError(t, err, "query-blockstats after VM start") + require.Contains(t, blockStats, "cdrom0", "installer CD-ROM not attached to VM") + require.Contains(t, blockStats, "disk0", "target NVMe disk not attached to VM") + if blk, err := QMPHumanMonitor(qmpSock, "info block"); err == nil { + t.Logf("attached block devices:\n%s", blk) + } + + // Recognition thresholds live with the classifier in screenshot.go + // (blueThreshold / win11WhiteMin / win11PurpleMin) so the loop, the file + // names and the unit tests cannot drift apart. + const ( + pollInterval = 15 * time.Second + timeout = 10 * time.Minute + // A guest that shows the same frame, reads nothing and never moves its + // PC for this long is hung, not slow. Without this the deterministic + // KVM firmware fault burned the full 10-minute deadline (602s) to + // report what was already certain after one minute. + stallBudget = 60 * time.Second + ) + stallLimit := StallPollsFor(int(stallBudget.Seconds()), int(pollInterval.Seconds())) + var stall StallTracker + + // Fallback: if the Enter spam misses cdboot's prompt window, the VM drops + // to the EFI Shell. Relaunch cdboot from the El Torito FAT (FS0 is the + // only filesystem EDK2 mounts — the ISO's genisoimage UDF is not + // EDK2-readable, so there is no FS1). Retry once. + shellCmds := []string{ + `FS0:\EFI\BOOT\BOOTAA64.EFI` + "\n", + `FS0:\EFI\BOOT\BOOTAA64.EFI` + "\n", + } + shellAttempt := 0 + shellSent := false + deadline := time.Now().Add(timeout) + ppmPath := filepath.Join(tmpDir, "screen.ppm") + attempt := 0 + + // cdboot from efisys.bin shows "Press any key to boot from CD or DVD..." + // and returns EFI_TIMEOUT if nothing is pressed (~10s window), dropping + // the VM to the EFI Shell. Spam Enter through the early boot window so + // the El Torito path proceeds unattended. Root-caused 2026-07-29. + go func() { + spamDeadline := time.Now().Add(90 * time.Second) + for time.Now().Before(spamDeadline) { + time.Sleep(2 * time.Second) + _ = QMPSendKeys(qmpSock, [][]string{{"ret"}}) + } + }() + + // Liveness telemetry: distinguish "display frozen but guest booting" + // (virtio-gpu stops updating at ExitBootServices) from "guest hung". + var prevStats map[string]BlockDeviceStats + var prevScreenHash uint64 + frozenPolls := 0 + + for time.Now().Before(deadline) { + time.Sleep(pollInterval) + attempt++ + + // Check serial log for Shell prompt and send bootloader command. + // Re-send on each subsequent "Shell>" (bootloader may time out, + // and we try the next FS path). + if logData, err := os.ReadFile(serialLog); err == nil { + logStr := string(logData) + shellCount := strings.Count(logStr, "Shell>") + if shellCount > shellAttempt && shellAttempt < len(shellCmds) { + t.Logf("EFI Shell prompt #%d detected — sending bootloader command (attempt %d)", shellCount, shellAttempt+1) + time.Sleep(2 * time.Second) + // Dump the firmware's device map to serial first: which + // FS*/BLK* devices UEFI actually sees (host attachment is + // asserted via QMP; this shows the guest-side view). + if shellAttempt == 0 { + if err := QMPSendKeys(qmpSock, StringToQKeyStrokes("map -r\n")); err == nil { + time.Sleep(3 * time.Second) + } + } + keystrokes := StringToQKeyStrokes(shellCmds[shellAttempt]) + if err := QMPSendKeys(qmpSock, keystrokes); err != nil { + t.Logf("WARNING: send-key failed: %v", err) + } else { + t.Logf("sent %d keystrokes: %s", len(keystrokes), strings.TrimSpace(shellCmds[shellAttempt])) + shellAttempt++ + shellSent = true + // Answer cdboot's "Press any key" prompt after relaunch. + for i := 0; i < 8; i++ { + time.Sleep(2 * time.Second) + _ = QMPSendKeys(qmpSock, [][]string{{"ret"}}) + } + } + } + } + + os.Remove(ppmPath) + if err := QMPScreendump(qmpSock, ppmPath); err != nil { + t.Logf("[attempt %d] screendump failed: %v", attempt, err) + continue + } + + if info, _ := os.Stat(ppmPath); info == nil || info.Size() == 0 { + t.Logf("[attempt %d] empty screenshot", attempt) + continue + } + + ratio, err := BluePixelRatio(ppmPath) + if err != nil { + t.Logf("[attempt %d] pixel analysis failed: %v", attempt, err) + continue + } + white, _ := WhitePixelRatio(ppmPath) + purple, _ := WindowsPurpleRatio(ppmPath) + + t.Logf("[attempt %d] blue=%.1f%% white=%.1f%% purple=%.1f%% (shell_sent=%v)", + attempt, ratio*100, white*100, purple*100, shellSent) + + // Signals for this poll, fed to the stall detector below. + var pollHash uint64 + var pollRead int64 + var pollPC string + + // Display-freeze detection: hash the raw screendump. + if ppmData, err := os.ReadFile(ppmPath); err == nil { + h := fnv.New64a() + h.Write(ppmData) + screenHash := h.Sum64() + if screenHash == prevScreenHash { + frozenPolls++ + } else { + frozenPolls = 0 + } + prevScreenHash = screenHash + pollHash = screenHash + t.Logf("[attempt %d] display: hash=%016x unchanged_polls=%d", attempt, screenHash, frozenPolls) + } + + // Guest liveness: disk I/O counters + vCPU program counter. + if stats, err := QMPBlockStats(qmpSock); err != nil { + t.Logf("[attempt %d] blockstats failed: %v", attempt, err) + } else { + for _, dev := range []string{"cdrom0", "disk0"} { + cur, ok := stats[dev] + if !ok { + continue + } + var delta BlockDeviceStats + if prev, ok := prevStats[dev]; ok { + delta = BlockDeviceStats{ + ReadBytes: cur.ReadBytes - prev.ReadBytes, + ReadOps: cur.ReadOps - prev.ReadOps, + WriteBytes: cur.WriteBytes - prev.WriteBytes, + } + } + t.Logf("[attempt %d] io %s: rd=%d (+%d) rd_ops=%d (+%d) wr=%d (+%d)", + attempt, dev, + cur.ReadBytes, delta.ReadBytes, + cur.ReadOps, delta.ReadOps, + cur.WriteBytes, delta.WriteBytes) + } + for _, st := range stats { + pollRead += st.ReadBytes + } + prevStats = stats + } + if regs, err := QMPHumanMonitor(qmpSock, "info registers"); err != nil { + t.Logf("[attempt %d] info registers failed: %v", attempt, err) + } else if i := strings.Index(regs, "PC="); i >= 0 { + end := i + 3 + for end < len(regs) && regs[end] != ' ' && regs[end] != '\n' { + end++ + } + pollPC = regs[i+3 : end] + t.Logf("[attempt %d] vcpu PC=%s", attempt, pollPC) + } + + // Fail fast on a hung guest. All three signals must be static: a + // blanked-but-live display, or a quiet disk on a running guest, is not + // a stall — only a PC that never moves alongside them is. + if n := stall.Observe(StallSignal{ScreenHash: pollHash, ReadBytes: pollRead, PC: pollPC}); n > 0 { + t.Logf("[attempt %d] stall: %d/%d consecutive static polls", attempt, n, stallLimit) + } + if stall.Stalled(stallLimit) { + if _, err := os.Stat(ppmPath); err == nil { + ConvertPPMtoPNG(ppmPath, filepath.Join(resultsDir, "stalled-last.png")) + } + interp := captureStallDiagnostics(t, qmpSock, resultsDir, spec) + if cfg.expect == expectPMUStall { + // The stall is the expected outcome — but only THIS stall: a + // synchronous-exception park with interrupts fully masked. A + // generic hang (wrong slot, DAIF clear) would be a different + // bug wearing the same timeout. + requireNoPMUStallSignature(t, pollPC, interp) + t.Logf("EXPECTED no-PMU stall confirmed after %v: %s", + time.Duration(stallLimit)*pollInterval, interp) + return // SUCCESS for expectPMUStall + } + t.Fatalf("guest hung after %v: %d consecutive polls with an unchanged frame, "+ + "zero bytes read (rd=%d) and a static PC=%s.\n %s\nFull dump: %s", + time.Duration(stallLimit)*pollInterval, stall.Consecutive(), pollRead, pollPC, + interp, filepath.Join(resultsDir, "stall-diagnostics.txt")) + } + + // Classify BEFORE naming the file, so the name records which criterion + // decided rather than a ratio that did not. + verdict := classifyScreen(ratio, white, purple) + pngPath := filepath.Join(resultsDir, screenshotName(time.Now(), attempt, verdict, ratio, white, purple)) + if err := ConvertPPMtoPNG(ppmPath, pngPath); err == nil { + t.Logf(" saved: %s", pngPath) + } + + switch verdict { + case verdictClassicBlue, verdictWin11UI: + if cfg.expect == expectPMUStall { + t.Fatalf("Windows booted to Setup (%s) despite the configuration expected to stall "+ + "(cpu=%q). The PMU requirement no longer holds for this media/QEMU — "+ + "re-examine KVMHostCaps.WindowsBootBlocker before trusting its skip.", + verdict, cpuType(spec)) + } + if verdict == verdictClassicBlue { + t.Logf("Windows installer detected: %.1f%% blue pixels (threshold %.0f%%)", ratio*100, blueThreshold*100) + } else { + // Windows 11 Setup is a large white wizard window on the purple + // backdrop (real UI measures ~73% white / ~16% purple) — the + // classic blue criterion never fires on it (peaks ~1.2%). + t.Logf("Windows 11 Setup UI detected: %.1f%% white window on %.1f%% purple backdrop", white*100, purple*100) + } + return // SUCCESS for expectSetup + } + } + + // Save final screenshot on timeout + if _, err := os.Stat(ppmPath); err == nil { + finalPNG := filepath.Join(resultsDir, "timeout-last.png") + ConvertPPMtoPNG(ppmPath, finalPNG) + t.Logf("timeout screenshot: %s", finalPNG) + } + + t.Fatalf("timed out after %v waiting for Windows installer; no frame satisfied either criterion "+ + "(legacy: blue >= %.0f%%; Windows 11: white >= %.0f%% AND purple >= %.0f%%). Screenshots in %s — "+ + "each is named with its verdict and measured b/w/p ratios", + timeout, blueThreshold*100, win11WhiteMin*100, win11PurpleMin*100, resultsDir) +} + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +func requireQEMUBin(t *testing.T) string { + t.Helper() + if p, err := exec.LookPath("qemu-system-aarch64"); err == nil { + return p + } + t.Skip("qemu-system-aarch64 not found — install QEMU") + return "" +} + +func requireFirmware(t *testing.T) string { + t.Helper() + if override := os.Getenv("QEMU_FIRMWARE_OVERRIDE"); override != "" { + if _, err := os.Stat(override); err != nil { + t.Fatalf("QEMU_FIRMWARE_OVERRIDE=%s: %v", override, err) + } + t.Logf("using firmware override: %s", override) + return override + } + fw := FirmwarePath() + if _, err := os.Stat(fw); err != nil { + t.Skipf("UEFI firmware not found at %s — install QEMU", fw) + } + return fw +} + +func requireWindowsISO(t *testing.T) string { + t.Helper() + + // 1. Explicit env var (pre-built ISO) + if p := os.Getenv("DEVCELL_TEST_WINDOWS_ISO"); p != "" { + if _, err := os.Stat(p); err != nil { + t.Fatalf("DEVCELL_TEST_WINDOWS_ISO=%s: %v", p, err) + } + return p + } + + // 2. Assemble from ESD on the fly (needs -tags wimlib + genisoimage/hdiutil) + if esdPath := os.Getenv("DEVCELL_TEST_ESD_PATH"); esdPath != "" { + if _, err := os.Stat(esdPath); err != nil { + t.Fatalf("DEVCELL_TEST_ESD_PATH=%s: %v", esdPath, err) + } + isoPath := filepath.Join(t.TempDir(), "windows-arm64.iso") + assembleISOFromESD(t, esdPath, isoPath) + return isoPath + } + + // 3. Download via MCT catalog / UUP dump (uses cache) + home, err := os.UserHomeDir() + require.NoError(t, err) + path, err := DownloadWindowsISO(t.Context(), home, "en-us", false, NopObserver{}) + if err != nil { + t.Skipf("could not obtain Windows ISO: %v", err) + } + return path +} + +func repoRoot(_ *testing.T) string { + return testutil.RepoRoot() +} + +func testResultsDir(t *testing.T) string { + t.Helper() + return testutil.TestResultsDir(t, nil) +} + +func waitForSocket(t *testing.T, sockPath string, timeout time.Duration, ql *qemuLog) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + conn, err := net.DialTimeout("unix", sockPath, 500*time.Millisecond) + if err == nil { + conn.Close() + return + } + time.Sleep(500 * time.Millisecond) + } + t.Fatalf("QMP socket %s did not appear within %v%s", sockPath, timeout, qemuLaunchHint(ql)) +} + +type qemuLog struct { + bytes.Buffer +} + +// qemuOutput tees QEMU's stdout+stderr into a buffer and the test log. +// On cleanup it writes the captured output into run.json["qemu-output"]. +func qemuOutput(t *testing.T, resultsDir string, argv []string) *qemuLog { + t.Helper() + ql := &qemuLog{} + t.Cleanup(func() { + if ql.Len() > 0 { + updateRunJSON(t, resultsDir, map[string]any{ + "qemu-output": ql.String(), + }) + } + }) + return ql +} + +func (ql *qemuLog) Write(p []byte) (int, error) { + os.Stderr.Write(p) + return ql.Buffer.Write(p) +} + +// qemuLaunchHint turns a bare QMP timeout into the actual reason when QEMU +// failed at launch rather than hanging. +func qemuLaunchHint(ql *qemuLog) string { + if ql == nil || ql.Len() == 0 { + return "" + } + output := ql.String() + for _, line := range strings.Split(output, "\n") { + if strings.Contains(line, "Could not set up host forwarding rule") { + return "\n QEMU never started: a forwarded host port was already in use:\n " + strings.TrimSpace(line) + } + } + for _, line := range strings.Split(output, "\n") { + if strings.HasPrefix(strings.TrimSpace(line), "qemu-system") { + return "\n QEMU reported at launch:\n " + strings.TrimSpace(line) + } + } + return "" +} + +// updateRunJSON merges fields into resultsDir/run.json. It reads the existing +// file (if any), applies the updates, and writes it back. Safe for incremental +// additions (argv at launch, query-kvm after boot). +func updateRunJSON(t *testing.T, resultsDir string, fields map[string]any) { + t.Helper() + path := filepath.Join(resultsDir, "run.json") + data := map[string]any{} + if b, err := os.ReadFile(path); err == nil { + json.Unmarshal(b, &data) + } + for k, v := range fields { + data[k] = v + } + b, err := json.MarshalIndent(data, "", " ") + if err != nil { + t.Logf("WARNING: could not marshal run.json: %v", err) + return + } + if err := os.WriteFile(path, b, 0o644); err != nil { + t.Logf("WARNING: could not write run.json: %v", err) + } +} + +// assertAccel proves via QMP that the running VM uses the expected accelerator. +// query-kvm only reports KVM state — HVF returns enabled=false because it is +// a separate accelerator. For HVF we verify KVM is NOT enabled (QEMU exits if +// the requested accelerator is unavailable, so a live VM is sufficient proof). +func assertAccel(t *testing.T, qmpSock, requestedAccel, resultsDir string) { + t.Helper() + kvmEnabled, kvmPresent, err := QMPQueryKVM(qmpSock) + if err != nil { + t.Logf("WARNING: query-kvm failed: %v", err) + return + } + t.Logf("query-kvm: enabled=%v present=%v (requested %s)", kvmEnabled, kvmPresent, requestedAccel) + updateRunJSON(t, resultsDir, map[string]any{"query-kvm": fmt.Sprintf("enabled=%v present=%v", kvmEnabled, kvmPresent)}) + + switch { + case strings.HasPrefix(requestedAccel, "kvm"): + require.True(t, kvmEnabled, + "asked for -accel kvm but query-kvm reports enabled=false (present=%v)", kvmPresent) + case requestedAccel == "hvf": + require.False(t, kvmEnabled, + "asked for -accel hvf but query-kvm reports enabled=true — unexpected KVM when HVF was requested") + t.Logf("HVF active — query-kvm correctly reports enabled=false (HVF is not KVM)") + default: + require.False(t, kvmEnabled, + "asked for -accel %s but query-kvm reports enabled=true — expected TCG (software emulation)", requestedAccel) + } +} + +// captureStallDiagnostics dumps everything the live VM can still tell us at the +// moment of the hang, and returns a one-line interpretation for the failure +// message. +// +// Written to a file rather than only the test log: the 10-minute KVM runs that +// preceded this left directories full of screenshots and nothing explaining +// them, so the analysis had to be re-derived from the code state at launch. +// +// Each dump targets one question about the KVM firmware hang: +// - PSTATE decode: fatal-exception dead loop, or a WFI idle? +// - disassembly at PC: `b .` self-branch, or a wild branch into data? +// - disassembly at LR: which caller got there. +// - info registers -a: is every vCPU stuck, or only vCPU 0? +// - info mtree -f: does the guest-physical map differ from the TCG run +// (the "ConvertPages: failed to find range" lead). +// - info pci / info block: device state at the fault. +func captureStallDiagnostics(t *testing.T, qmpSock, resultsDir string, spec Spec) string { + t.Helper() + + var b strings.Builder + fmt.Fprintf(&b, "accel: %s\nmachine: %s\ncpu: %s\n\n", + spec.Accel, machineType(spec), cpuType(spec)) + + regs, regErr := QMPHumanMonitor(qmpSock, "info registers") + if regErr != nil { + fmt.Fprintf(&b, "info registers FAILED: %v\n", regErr) + } + + interp := "PSTATE unavailable — cannot tell a dead loop from a WFI idle" + pc := diag.ExtractRegister(regs, "PC=") + lr := diag.ExtractRegister(regs, "X30=") + pcVal, pcValErr := strconv.ParseUint(pc, 16, 64) + if ps := diag.ExtractRegister(regs, "PSTATE="); ps != "" { + if decoded, err := DecodePSTATE(ps); err == nil { + interp = decoded.Summary() + // A masked-DAIF park at a vector-shaped offset names the + // exception class: +0x200 = a synchronous exception taken at the + // current EL on SP_ELx — the KVM-hang signature. + if decoded.AllInterruptsMasked() && pcValErr == nil { + base, slot := ExceptionVectorSlot(pcVal) + interp += fmt.Sprintf("; PC sits at slot %s of an 0x800-aligned vector frame (VBAR would be 0x%x)", slot, base) + } + } else { + interp = fmt.Sprintf("PSTATE=%s undecodable: %v", ps, err) + } + } + fmt.Fprintf(&b, "INTERPRETATION: %s\n", interp) + t.Logf("stall interpretation: %s", interp) + + dumps := []struct{ label, hmp string }{ + {"info registers (vCPU 0)", "info registers"}, + {"info registers -a (all vCPUs — is only one stuck?)", "info registers -a"}, + } + if pc != "" { + dumps = append(dumps, struct{ label, hmp string }{ + "disassembly at PC 0x" + pc, "x/16i 0x" + pc}) + } + if lr != "" { + dumps = append(dumps, struct{ label, hmp string }{ + "disassembly at LR 0x" + lr + " (caller)", "x/8i 0x" + lr}) + } + if pcValErr == nil { + // If the parked PC really is a vector slot, sibling slots of the same + // 0x800 frame should hold the same `b .` stubs. Three probes tell a + // minimal panic-vector table apart from a coincidental address. + base := pcVal &^ 0x7FF + for _, off := range []uint64{0x000, 0x200, 0x400} { + addr := base + off + dumps = append(dumps, struct{ label, hmp string }{ + fmt.Sprintf("assumed vector table, slot +0x%03x (0x%x)", off, addr), + fmt.Sprintf("x/2i 0x%x", addr)}) + } + } + dumps = append(dumps, + struct{ label, hmp string }{"info mtree -f (guest-physical map)", "info mtree -f"}, + struct{ label, hmp string }{"info pci", "info pci"}, + struct{ label, hmp string }{"info block", "info block"}, + struct{ label, hmp string }{"info status", "info status"}, + ) + + for _, d := range dumps { + fmt.Fprintf(&b, "\n===== %s =====\n", d.label) + out, err := QMPHumanMonitor(qmpSock, d.hmp) + if err != nil { + fmt.Fprintf(&b, "FAILED: %v\n", err) + continue + } + b.WriteString(strings.TrimRight(out, "\n") + "\n") + } + + path := filepath.Join(resultsDir, "stall-diagnostics.txt") + if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil { + t.Logf("WARNING: could not write %s: %v", path, err) + t.Logf("stall diagnostics:\n%s", b.String()) + } + if pc != "" { + if dis, err := QMPHumanMonitor(qmpSock, "x/4i 0x"+pc); err == nil { + t.Logf("instructions at PC 0x%s:\n%s", pc, strings.TrimRight(dis, "\n")) + } + } + return interp +} + +// requireNoPMUStallSignature asserts the stall is the specific no-PMU death, +// not merely "some hang": the PC parked in the synchronous-exception slot +// (+0x200 of an 0x800-aligned vector frame) with all of DAIF masked. Measured +// on both occurrences: KVM PC=0x13c347200, TCG+pmu=off PC=0x4064e200 — +// different load addresses, same slot. +func requireNoPMUStallSignature(t *testing.T, pc, interp string) { + t.Helper() + pcVal, err := strconv.ParseUint(pc, 16, 64) + require.NoError(t, err, "stalled without a parseable PC (%q)", pc) + require.Equal(t, uint64(0x200), pcVal&0x7FF, + "parked PC 0x%x is not in the sync-exception vector slot — a different failure than the no-PMU death", pcVal) + require.Contains(t, interp, "DAIF=all masked", + "a park with interrupts enabled is an idle, not the no-PMU dead loop") +} + +// testResultsDir must answer "this run's directory", not "a new directory". +// +// It used to stamp time.Now() on every call, so calling it twice in one test +// scattered that run's artifacts across two directories seconds apart — +// observed on 2026-07-31 as 20260731T140208-… holding the screenshots while +// 20260731T140210-… held the live logs. Fixing the caller is not enough: the +// next second caller reintroduces it. +func TestTestResultsDir_IsStableWithinOneTest(t *testing.T) { + // Clean up after itself: this asserts on a helper, and a helper test has no + // business leaving empty directories in the shared results tree that real + // runs write to. Seven accumulated before it was noticed. + t.Cleanup(func() { os.RemoveAll(testResultsDir(t)) }) + + first := testResultsDir(t) + time.Sleep(1100 * time.Millisecond) // cross a timestamp boundary + second := testResultsDir(t) + + assert.Equal(t, first, second, "one test, one results directory") +} + +// Different tests still get their own. +func TestTestResultsDir_DiffersBetweenTests(t *testing.T) { + t.Cleanup(func() { os.RemoveAll(testResultsDir(t)) }) + + mine := testResultsDir(t) + + assert.Contains(t, mine, t.Name(), "the directory must name the test that produced it") + assert.NotContains(t, mine, "IsStableWithinOneTest") +} diff --git a/internal/vm/qemu/boot_wimlib_test.go b/internal/vm/qemu/boot_wimlib_test.go new file mode 100644 index 0000000..caa34e2 --- /dev/null +++ b/internal/vm/qemu/boot_wimlib_test.go @@ -0,0 +1,30 @@ +//go:build wimlib + +package qemu + +import ( + "context" + "os" + "testing" + + "github.com/devcell-sh/go-winkit/mctcatalog" +) + +func assembleISOFromESD(t *testing.T, esdPath, isoPath string) { + t.Helper() + t.Logf("assembling Windows ISO from ESD (%s) → %s", esdPath, isoPath) + err := mctcatalog.AssembleMCTISO(context.Background(), esdPath, mctcatalog.AssembleConfig{ + WorkDir: t.TempDir(), + ISOPath: isoPath, + Label: "YOURISO", + LogFunc: func(f string, a ...any) { t.Logf(f, a...) }, + }) + if err != nil { + t.Fatalf("assembling ISO from ESD: %v", err) + } + info, err := os.Stat(isoPath) + if err != nil || info.Size() < 100*1024*1024 { + t.Fatalf("assembled ISO is too small or missing: %v", err) + } + t.Logf("ISO assembled: %.1f GB", float64(info.Size())/(1024*1024*1024)) +} diff --git a/internal/vm/qemu/bootmatrix_test.go b/internal/vm/qemu/bootmatrix_test.go new file mode 100644 index 0000000..b08cbe7 --- /dev/null +++ b/internal/vm/qemu/bootmatrix_test.go @@ -0,0 +1,146 @@ +package qemu + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// TestWSL2MachineBoot_DeviceMatrix bisects the run-20260802T083354 regression +// (CELL-398): the EL3 machine booted to SSH bare (green run 2) but died +// before SSH with the virtio-fs share and RDP forward attached (run 3). +// +// Table-driven and STRICTLY SEQUENTIAL: one VM at a time, never in parallel — +// parallel probes share the host CPU, distort boot timing, and make verdicts +// incomparable (the first ad-hoc attempt at this bisect proved that). Each +// case asserts no other QEMU is running before it boots. +// +// Each case is a pure boot probe of the nix-ready checkpoint: no stages, no +// WSL — the verdict is sshd answering (the state the image was saved in) +// within the boot window, versus the VM dying. QEMU stderr is captured by +// startVM, so a death names its cause. +func TestWSL2MachineBoot_DeviceMatrix(t *testing.T) { + if testing.Short() { + t.Skip("long: boots the nix-ready Windows image once per device configuration") + } + if os.Getenv("DEVCELL_TEST_BOOTMATRIX") == "" { + t.Skip("set DEVCELL_TEST_BOOTMATRIX=1 to run the EL3 boot device matrix") + } + requireQEMUBin(t) + + kernelFW, err := KernelFirmwarePath() + if err != nil { + t.Skipf("no kernel-bootable firmware: %v", err) + } + baseImage, err := LatestNixReadyTestImage(testdataDir(t)) + if err != nil { + t.Skipf("no nix-ready checkpoint image: %v", err) + } + + // A healthy single-VM boot of this image reached SSH in ~10 minutes + // (run 20260802T071510); triple that is failure, not slowness. + const bootWindow = 30 * time.Minute + + cases := []struct { + name string + withRDP bool + withVirtioFS bool + }{ + // Control first: proves the image + machine still boot at all before + // any suspect device gets the blame. + {name: "control-plain"}, + {name: "rdp-forward", withRDP: true}, + {name: "virtio-fs", withVirtioFS: true}, + } + + for _, tc := range cases { + tc := tc + ok := t.Run(tc.name, func(t *testing.T) { + requireNoOtherVMs(t) + + workDir := t.TempDir() + resultsDir := testResultsDir(t) + overlay := filepath.Join(workDir, "probe.qcow2") + require.NoError(t, CloneDisk(baseImage, overlay)) + + spec := Spec{ + VMName: "devcell-qemu-bootmatrix", + CPUs: 6, + MemoryGB: 6, + DiskPath: overlay, + SerialLogPath: filepath.Join(resultsDir, "serial.log"), + FirmwarePath: kernelFW, + FirmwareKernel: true, + SecureWorld: true, + SSHHost: "127.0.0.1", + SSHPort: freeTCPPort(10222), + MACAddr: DeterministicMAC("devcell-qemu-bootmatrix-" + tc.name), + QMPSocketDir: workDir, + DiskCacheMode: "unsafe", + } + if tc.withRDP { + spec.RDPPort = freeTCPPort(13389) + } + if tc.withVirtioFS { + fsdBin, err := VirtiofsdPath() + require.NoError(t, err, "the virtio-fs case needs virtiofsd") + sock := filepath.Join(workDir, "virtiofs.sock") + fsd := VirtiofsdCommand(fsdBin, sock, repoRoot(t)) + fsdLog, err := os.OpenFile(filepath.Join(resultsDir, "host-virtiofsd.log"), + os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + require.NoError(t, err) + fsd.Stdout, fsd.Stderr = fsdLog, fsdLog + require.NoError(t, fsd.Start()) + t.Cleanup(func() { + if fsd.Process != nil { + _ = fsd.Process.Kill() + } + _ = fsd.Wait() + _ = fsdLog.Close() + }) + spec.VirtioFSSocketPath = sock + spec.VirtioFSTag = "devcell" + } + spec.ApplyDefaults() + require.NoError(t, spec.Validate()) + + vmDone := startVM(t, spec) + // The VM must be fully gone before the next case boots — stop() + // kills and waits on the process. + defer vmDone.stop() + + qmpSock := QMPSocketPath(spec) + bootErr := WaitForSSH(spec.SSHHost, spec.SSHPort, bootWindow, + 5*time.Second, testLogObserver{t}, vmStateFn(qmpSock)) + if bootErr != nil { + stderrTail := "" + if b, err := os.ReadFile(filepath.Join(resultsDir, "qemu-stderr.log")); err == nil { + stderrTail = tailLines(string(b), 10) + } + t.Fatalf("case %q did not reach SSH: %v\nqemu stderr:\n%s", + tc.name, bootErr, stderrTail) + } + t.Logf("case %q: SSH answered — boot OK", tc.name) + }) + // Sequential AND dependent: if the control fails, blaming a device + // in later cases would be noise. + if !ok && tc.name == "control-plain" { + t.Fatal("control failed — the base image or machine is broken; device verdicts would be meaningless") + } + } +} + +// requireNoOtherVMs enforces the one-VM-at-a-time rule: boot verdicts are +// only comparable when each guest has the whole host. +func requireNoOtherVMs(t *testing.T) { + t.Helper() + out, _ := exec.Command("pgrep", "-f", "qemu-system-aarch64 -machine").Output() + if pids := strings.TrimSpace(string(out)); pids != "" { + t.Fatalf("another QEMU VM is running (pids: %s) — matrix cases must run alone", pids) + } +} diff --git a/internal/vm/qemu/build_test.go b/internal/vm/qemu/build_test.go new file mode 100644 index 0000000..63ae8dc --- /dev/null +++ b/internal/vm/qemu/build_test.go @@ -0,0 +1,1731 @@ +//go:build wimlib + +package qemu + +import ( + "bufio" + "encoding/binary" + "fmt" + "hash/fnv" + "image" + "image/color" + "image/png" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/devcell-sh/go-winkit/diag" + "github.com/devcell-sh/go-winkit/wim" + "github.com/devcell-sh/go-winkit/winpe" + + "github.com/devcell-sh/go-regedit" + "github.com/devcell-sh/go-wimlib" + "github.com/devcell-sh/go-winkit/gosshd" + "github.com/devcell-sh/go-winkit/isokit" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + cryptossh "golang.org/x/crypto/ssh" +) + +// wimBuilderRun holds output from a single WIM builder QEMU session. +type wimBuilderRun struct { + agentOut string + sharedImg string + resultsDir string + tmpDir string + doneMarker string +} + +// wimSourceOverride provides a pre-built WIM to place on the shared volume +// instead of extracting boot.wim from the Windows ISO. Used for multi-pass +// builds where pass N operates on the output of pass N-1. +type wimSourceOverride struct { + name string // filename on shared volume (e.g. "devcell.wim") + data []byte + // asBootMedia also makes this image the WinPE the builder VM boots, + // rather than only the servicing target on the shared volume. That is + // how a produced devcell.wim gets verified: boot the artifact itself and + // let it report whether its transplanted services load. + asBootMedia bool + // patchBCD sets hypervisorlaunchtype=Auto on the staged boot media. + // Needed when booting a transplanted image: the drivers are inert + // unless winload is told to start the hypervisor. + patchBCD bool + // agentCommand replaces the command the in-guest agent runs. The verify + // pass inspects an image rather than building one. + agentCommand string + // extraFiles are added to the shared volume, e.g. the verify script. + extraFiles map[string][]byte + // readyMarker, once seen in the guest progress stream, means the guest + // has finished setting itself up and onGuestReady may run. + readyMarker string + // onGuestReady runs on the host against the still-running guest, with + // the host port the guest's :22 is forwarded to. The VM is shut down as + // soon as it returns, so an assertion that needs a live guest can run + // without keepAlive leaving a VM behind for someone to clean up. + onGuestReady func(t *testing.T, sshPort uint16) + // keepAlive leaves the VM running after the agent finishes instead of + // shutting it down, so a booted image can be inspected in place through + // QMP. It changes nothing about the boot itself — same argv, same + // volume, same ISO as a normal run — only what happens afterwards. + // The run ends when /STOP appears or the deadline expires. + keepAlive bool + // secureWorld boots the VM with secure=on + the kernel firmware, + // enabling EL3/EL2 so Windows' hypervisor can launch. Without it + // HypervisorPresent is always False under TCG. + secureWorld bool +} + +// runWimBuilder boots a WinPE builder VM, polls until it finishes, and +// returns the captured output. The caller supplies the WIM prep config and +// the deadline; everything else (QEMU config, polling, stall detection) is +// shared across subtests. +func runWimBuilder(t *testing.T, accel string, cfg winpe.WimPrepConfig, deadline time.Duration, guestMemGB uint64, source *wimSourceOverride) wimBuilderRun { + t.Helper() + + qemuAccel := "tcg,thread=multi" + if accel == "hvf" { + qemuAccel = "hvf" + } + + qemuBin := requireQEMUBin(t) + fwPath := requireFirmware(t) + virtioISO := requireVirtioISO(t) + pwshFiles := requirePwshFiles(t) + + winISO := requireWindowsISO(t) + + needsInstallWim := false + for _, op := range cfg.Ops { + if op.Feature != "" || op.Capability != "" || op.Package != "" { + needsInstallWim = true + break + } + } + + tmpDir := t.TempDir() + resultsDir := testResultsDir(t) + + // ── 1. Extract boot.wim and EFI boot files ── + stageDir := filepath.Join(tmpDir, "stage") + require.NoError(t, winpe.ExtractStage(winISO, stageDir)) + + // ── 2. Extract vioserial + vioscsi drivers ── + vioserialDrivers, err := winpe.LoadWinPEVioserialDrivers(virtioISO) + require.NoError(t, err) + vioscsiDrivers, err := winpe.LoadWinPEStorageDrivers(virtioISO) + require.NoError(t, err) + + // ── 3. Create shared FAT volume with source WIM ── + bootWimPath := filepath.Join(stageDir, "sources", "boot.wim") + var sourceWimName string + var sourceWimData []byte + if source != nil { + sourceWimName = source.name + sourceWimData = source.data + t.Logf("source override %s: %d bytes (%.1f MB)", sourceWimName, len(sourceWimData), float64(len(sourceWimData))/(1024*1024)) + + // Replacing the staged boot.wim here means the ISO built further + // down boots this image. The agent payload is injected afterwards, + // so the artifact still comes up talking to the host. + if source.asBootMedia { + require.NoError(t, os.WriteFile(bootWimPath, source.data, 0644)) + t.Logf("booting %s as WinPE media", sourceWimName) + } + if source.patchBCD { + patchStagedBCD(t, stageDir) + } + if source.secureWorld { + patchStagedBootWim(t, stageDir) + } + } else { + sourceWimName = "boot.wim" + readFrom := bootWimPath + + // The transplant targets a copy, not the builder's own boot media. + // Both roles are served by stage/sources/boot.wim — the ISO the + // builder VM boots, and the image copied to the shared volume for + // servicing — so transplanting in place puts the product's drivers + // into the builder itself. That is what hung an earlier run: boot + // start drivers meant for the product stalled the builder's winload. + // The builder needs no Hyper-V; only its output does. + // + // DISM cannot do this work in-guest: CBS rejects the VMP packages + // because their parent is Microsoft-Windows-Foundation-Package while + // boot.wim's is Microsoft-Windows-WinPE-Package. Transplanting before + // the builder runs means its DISM pass commits our changes through + // into devcell.wim. + if cfg.TransplantVMP { + readFrom = filepath.Join(tmpDir, "target-boot.wim") + stock, err := os.ReadFile(bootWimPath) + require.NoError(t, err) + require.NoError(t, os.WriteFile(readFrom, stock, 0644)) + transplantBootWim(t, readFrom, resultsDir) + + // The BCD the image boots from lives on the media, not in the + // WIM, so patching it has to happen here. Set DEVCELL_VMP_NO_BCD + // to skip it: the verify pass reported HypervisorPresent=True + // without it, and telling winload to launch the hypervisor is + // the outstanding suspect for the builder's own boot stalling. + if os.Getenv("DEVCELL_VMP_NO_BCD") == "" { + patchStagedBCD(t, stageDir) + } + } + + var err error + sourceWimData, err = os.ReadFile(readFrom) + require.NoError(t, err) + t.Logf("boot.wim: %d bytes (%.1f MB)", len(sourceWimData), float64(len(sourceWimData))/(1024*1024)) + } + + var efiBootLoader []byte + if bl, err := winpe.InstallerBootloader(winISO); err != nil { + t.Logf("could not extract BOOTAA64.EFI: %v", err) + } else if _, err := winpe.ValidateBootloaderPE(bl); err != nil { + t.Logf("BOOTAA64.EFI validation failed: %v", err) + } else { + efiBootLoader = bl + t.Logf("BOOTAA64.EFI: %d bytes", len(bl)) + } + + sharedFiles := winpe.SharedVolumeFiles(cfg, efiBootLoader, pwshFiles) + sharedFiles["/"+sourceWimName] = sourceWimData + if source != nil { + for name, data := range source.extraFiles { + sharedFiles[name] = data + } + if source.agentCommand != "" { + sharedFiles["/"+winpe.AgentCommandFile] = []byte(source.agentCommand) + } + } + + sharedImg := filepath.Join(tmpDir, "shared.qcow2") + require.NoError(t, CreateFATQcow2(sharedImg, sharedFiles, 20*1024*1024*1024)) + t.Logf("shared volume: %s", sharedImg) + + // ── 4. Inject agent into boot.wim for standalone WinPE ── + injectDir := filepath.Join(tmpDir, "inject") + require.NoError(t, os.MkdirAll(injectDir, 0755)) + + for _, driverSet := range []map[string][]byte{vioserialDrivers, vioscsiDrivers} { + for answerPath, data := range driverSet { + hostPath := filepath.Join(injectDir, filepath.FromSlash(answerPath)) + require.NoError(t, os.MkdirAll(filepath.Dir(hostPath), 0755)) + require.NoError(t, os.WriteFile(hostPath, data, 0644)) + } + } + + payloadCfg := winpe.PayloadConfig{ + WPEInit: true, + ProgressPort: `\\.\Global\` + ProgressPortName, + PollSeconds: 5, + SyncAgent: true, + } + var driverINFs []string + if len(vioserialDrivers) > 0 { + driverINFs = append(driverINFs, `X:\devcell\drivers\vioserial\vioser.inf`) + } + if len(vioscsiDrivers) > 0 { + driverINFs = append(driverINFs, `X:\devcell\drivers\vioscsi\vioscsi.inf`) + } + payloadCfg.DriverINFs = driverINFs + + require.NoError(t, os.WriteFile( + filepath.Join(injectDir, "winpeshl.ini"), + winpe.GenerateShellINI_NoSetup(), 0644)) + require.NoError(t, os.WriteFile( + filepath.Join(injectDir, "bootstrap.cmd"), + winpe.GenerateBootstrapCmd(), 0644)) + require.NoError(t, os.WriteFile( + filepath.Join(injectDir, "bootstrap.ps1"), + winpe.GenerateBootstrap(payloadCfg), 0644)) + require.NoError(t, os.WriteFile( + filepath.Join(injectDir, "agent.ps1"), + winpe.GenerateAgent(payloadCfg), 0644)) + + require.NoError(t, wim.InjectWinPEPayload(bootWimPath, injectDir)) + + // ── 5. Create WinPE ISO ── + winpeISO := filepath.Join(tmpDir, "winpe-builder.iso") + require.NoError(t, isokit.CreateWindowsISO(winpeISO, stageDir, "WINPE")) + + // ── 6. Build QEMU command ── + diskPath := filepath.Join(tmpDir, "scratch.qcow2") + out, err := exec.Command(qemuBin+"-img", "create", "-f", "qcow2", diskPath, "8G").CombinedOutput() + if err != nil { + out, err = exec.Command("qemu-img", "create", "-f", "qcow2", diskPath, "8G").CombinedOutput() + } + require.NoError(t, err, "qemu-img create: %s", out) + + secureWorld := source != nil && source.secureWorld + if secureWorld { + kernelFW, err := KernelFirmwarePath() + if err != nil { + t.Skipf("secure world requested but no kernel firmware: %v", err) + } + fwPath = kernelFW + } + + fwInfo, err := os.Stat(fwPath) + require.NoError(t, err) + kernelMode := fwInfo.Size() < 64*1024*1024 + + var varsPath string + if !kernelMode { + varsPath = filepath.Join(tmpDir, "vars.fd") + require.NoError(t, PrepareVarsFile(fwPath, varsPath)) + } + + serialLog := filepath.Join(resultsDir, "serial.log") + guestProgressLog := filepath.Join(resultsDir, "build.log") + guestStructuredLog := filepath.Join(resultsDir, "build.jsonl") + machType := "virt" + if secureWorld { + machType = "" // let machineType() compute secureMachineType + } + spec := Spec{ + VMName: "wim-builder-test", + CPUs: 2, + MemoryGB: guestMemGB, + DiskPath: diskPath, + FirmwarePath: fwPath, + VarsPath: varsPath, + FirmwareKernel: kernelMode, + SecureWorld: secureWorld, + QMPSocketDir: tmpDir, + DisplayType: "none", + Accel: qemuAccel, + MachineType: machType, + SerialLogPath: serialLog, + GuestProgressLogPath: guestProgressLog, + GuestStructuredLogPath: guestStructuredLog, + NoReboot: true, + CDBus: "scsi", + } + spec.ApplyDefaults() + require.NoError(t, spec.Validate()) + + qmpSock := QMPSocketPath(spec) + + wbs := WimBuilderSpec{ + Spec: spec, + WinPEISO: winpeISO, + SharedImg: sharedImg, + VirtIOISO: virtioISO, + } + if needsInstallWim { + wbs.WindowsISO = winISO + } + argv := BuildWimBuilderArgv(wbs) + argv[0] = qemuBin + var gdbSock string + if secureWorld { + qemuDebugLog := filepath.Join(resultsDir, "qemu-debug.log") + gdbSock = filepath.Join(tmpDir, "qemu-gdb.sock") + argv = append(argv, "-d", "guest_errors,int", + "-D", qemuDebugLog, + "-gdb", "unix:"+gdbSock+",server,nowait") + t.Logf("QEMU debug log: %s", qemuDebugLog) + t.Logf("GDB stub socket: %s", gdbSock) + } + updateRunJSON(t, resultsDir, map[string]any{ + "test": t.Name(), "qemu-args": strings.Join(argv, " "), + }) + + require.NoError(t, EnsureScreenshotDir(resultsDir, ScreenSourceQMP)) + + // ── 7. Boot and poll ── + exclusiveQEMU(t) + cmd := exec.Command(argv[0], argv[1:]...) + qemuLog := qemuOutput(t, resultsDir, argv) + cmd.Stdout = qemuLog + cmd.Stderr = qemuLog + require.NoError(t, cmd.Start(), "starting QEMU") + qemuExited := make(chan struct{}) + qemuDied := make(chan error, 1) + keepAlive := source != nil && source.keepAlive + defer func() { + if keepAlive { + return + } + QMPQuit(qmpSock) + select { + case <-qemuExited: + case <-qemuDied: + case <-time.After(10 * time.Second): + cmd.Process.Kill() + } + }() + + waitForSocket(t, qmpSock, 30*time.Second, qemuLog) + assertAccel(t, qmpSock, accel, resultsDir) + + go func() { + err := cmd.Wait() + t.Logf("QEMU process exited: %v", err) + qemuDied <- err + }() + + // Set early GDB breakpoints before the HV boots. The HVC vector + // breakpoint catches securekernel's first HVC #1 whether or not the + // timezone spin loop fires. GDB Z0 breakpoints are VA-based in TCG + // and survive page table remaps, unlike memory writes. + type earlyBP struct { + gdb *GDBConn + hvVectorHit chan string + } + var ebp *earlyBP + if secureWorld && gdbSock != "" { + bp := &earlyBP{hvVectorHit: make(chan string, 1)} + QMPHumanMonitor(qmpSock, "stop") + time.Sleep(300 * time.Millisecond) + gdb, err := GDBDial("unix:"+gdbSock, 5*time.Second) + if err != nil { + t.Logf("early GDB: dial failed: %v", err) + QMPHumanMonitor(qmpSock, "cont") + } else { + bp.gdb = gdb + const hvVec = 0x13d92c400 + if err := gdb.SetBreakpoint(hvVec); err != nil { + t.Logf("early GDB: breakpoint at 0x%x failed: %v", hvVec, err) + gdb.Close() + QMPHumanMonitor(qmpSock, "cont") + } else { + t.Logf("early GDB: breakpoint at HVC vector 0x%x set", hvVec) + gdb.Continue() + ebp = bp + go func() { + reply, err := gdb.WaitBreak(180 * time.Second) + if err != nil { + t.Logf("early GDB: HVC vector wait failed: %v", err) + return + } + bp.hvVectorHit <- reply + }() + } + } + } + + stop := make(chan struct{}) + defer close(stop) + efiShellCh := WatchSerialForEFIShell(serialLog, stop) + syncExCh := WatchSerialForSyncException(serialLog, stop) + + pollInterval := 15 * time.Second + stallBudget := 90 * time.Second + stallLimit := StallPollsFor(int(stallBudget.Seconds()), int(pollInterval.Seconds())) + var stall StallTracker + + ppmPath := filepath.Join(tmpDir, "screen.ppm") + start := time.Now() + frame := 0 + for time.Since(start) < deadline { + time.Sleep(pollInterval) + frame++ + + // Check if the HVC vector breakpoint fired. + if ebp != nil { + select { + case reply := <-ebp.hvVectorHit: + t.Logf("HVC vector breakpoint hit: %s", reply) + if mem, err := ebp.gdb.ReadMemory(0x13d92c400, 16); err == nil { + t.Logf(" HVC vector memory (live): %x", mem) + } + // Dump key registers: PC(32), ELR_EL1(68), SP_EL0(various) + for _, ri := range []struct { + idx int + name string + }{ + {32, "PC"}, {33, "CPSR"}, + } { + if raw, err := ebp.gdb.ReadRegister(ri.idx); err == nil { + if len(raw) == 8 { + v := binary.LittleEndian.Uint64(raw) + t.Logf(" reg %s = 0x%x", ri.name, v) + } else { + t.Logf(" reg %s = %x", ri.name, raw) + } + } else { + t.Logf(" reg %s: %v", ri.name, err) + } + } + // Dump x0-x5 (HVC argument regs) + for i := 0; i <= 5; i++ { + if raw, err := ebp.gdb.ReadRegister(i); err == nil && len(raw) == 8 { + v := binary.LittleEndian.Uint64(raw) + t.Logf(" reg x%d = 0x%x", i, v) + } + } + // Read ELR_EL2 via system register (QEMU index varies) + // Try reading the instruction at the HVC call site + // x0 at entry to HVC handler often has the HVC immediate + // Read memory around the ELR (return address) if we can find it + // QEMU GDB exposes ELR_EL1 at index 68, but ELR_EL2 is what we need + // In QEMU TCG, when stopped at EL2, ELR_EL2 can be read via + // custom XML registers. Try indices 68-75 for system regs. + for _, ri := range []struct { + idx int + name string + }{ + {68, "sysreg68"}, {69, "sysreg69"}, {70, "sysreg70"}, + {71, "sysreg71"}, {72, "sysreg72"}, + } { + if raw, err := ebp.gdb.ReadRegister(ri.idx); err == nil && len(raw) >= 4 { + if len(raw) == 8 { + v := binary.LittleEndian.Uint64(raw) + if v != 0 { + t.Logf(" %s = 0x%x", ri.name, v) + } + } + } + } + + // Read the all-registers dump to find ELR_EL2 + if allRegs, err := ebp.gdb.ReadRegisters(); err == nil { + t.Logf(" all regs hex length: %d", len(allRegs)) + // Each AArch64 GP reg is 8 bytes = 16 hex chars + // x0-x30 = 31 regs, SP, PC, CPSR = 34 regs = 544 hex chars + // After that: V0-V31 (16 bytes each = 512 bytes = 1024 hex) + // Then FPSR, FPCR (4 bytes each = 16 hex) + // Then system regs... + if len(allRegs) > 544 { + t.Logf(" regs after CPSR (first 128 hex): %.128s...", allRegs[544:]) + } + } + + // Strategy: instead of simple ERET, write a stub that + // modifies ELR_EL2 to skip the faulting insn, then ERETing. + // But first, let's try: just NOP-sled the HVC vector and + // continue without ERET, letting execution fall through. + // Actually, simplest: patch the HVC vector to a self-loop + // (b .) to freeze the HV at this point but let VTL0 continue. + // The HV should handle this by timing out. + // + // For now: write ERET and see what the debug log says about + // the return address and instruction there. + eret := []byte{0xe0, 0x03, 0x9f, 0xd6} + if err := ebp.gdb.WriteMemory(0x13d92c400, eret); err != nil { + t.Logf(" ERET write at HVC vector failed: %v", err) + } else { + after, _ := ebp.gdb.ReadMemory(0x13d92c400, 4) + t.Logf(" HVC vector patched to ERET: %x", after) + } + ebp.gdb.RemoveBreakpoint(0x13d92c400) + ebp.gdb.Continue() + ebp.gdb.Close() + ebp = nil + stall.Reset() + default: + } + } + + var pollHash uint64 + var pollRead int64 + var pollPC string + + os.Remove(ppmPath) + if err := QMPScreendump(qmpSock, ppmPath); err == nil { + if ppmData, err := os.ReadFile(ppmPath); err == nil { + h := fnv.New64a() + h.Write(ppmData) + pollHash = h.Sum64() + } + pngPath := ScreenshotPath(resultsDir, ScreenSourceQMP, time.Now(), + "none", frame, frame, "png") + if err := ConvertPPMtoPNG(ppmPath, pngPath); err == nil { + t.Logf("[frame %d] saved %s", frame, filepath.Base(pngPath)) + } + } + + if stats, err := QMPBlockStats(qmpSock); err == nil { + for _, s := range stats { + pollRead += s.ReadBytes + } + } + if regs, err := QMPHumanMonitor(qmpSock, "info registers"); err == nil { + pollPC = diag.ExtractRegister(regs, "PC=") + } + + n := stall.Observe(StallSignal{ScreenHash: pollHash, ReadBytes: pollRead, PC: pollPC}) + t.Logf("[frame %d] hash=%016x rd=%d PC=%s stall=%d/%d", + frame, pollHash, pollRead, pollPC, n, stallLimit) + + if secureWorld && n == 1 && strings.Contains(pollPC, "13e370e") { + if regs, err := QMPHumanMonitor(qmpSock, "info registers"); err == nil { + t.Logf("=== HV spin registers ===\n%s", regs) + regPath := filepath.Join(resultsDir, "registers-hv-spin.txt") + os.WriteFile(regPath, []byte(regs), 0644) + x19 := diag.ExtractRegister(regs, "X19=") + t.Logf("x19=%s (timezone bias at x19+0xc)", x19) + } + + // NOP the b.ge branch at 0x13e370700 that causes the timezone + // retry loop. QMP stop first: TCG can't service GDB 0x03 during + // a tight spin. + const hvTZBranchAddr = 0x13e370700 + t.Logf("pausing VM via QMP to NOP HV timezone retry branch") + if _, err := QMPHumanMonitor(qmpSock, "stop"); err != nil { + t.Logf("QMP stop failed: %v", err) + } else { + time.Sleep(500 * time.Millisecond) + gdb, err := GDBDial("unix:"+gdbSock, 5*time.Second) + if err != nil { + t.Logf("GDB dial failed: %v", err) + QMPHumanMonitor(qmpSock, "cont") + } else { + before, _ := gdb.ReadMemory(hvTZBranchAddr, 4) + t.Logf("HV branch at 0x%x before: %x", hvTZBranchAddr, before) + nop := []byte{0x1f, 0x20, 0x03, 0xd5} + if err := gdb.WriteMemory(hvTZBranchAddr, nop); err != nil { + t.Logf("GDB NOP write failed: %v", err) + } else { + after, _ := gdb.ReadMemory(hvTZBranchAddr, 4) + t.Logf("HV branch at 0x%x after: %x (NOP)", hvTZBranchAddr, after) + } + gdb.Close() + if _, err := QMPHumanMonitor(qmpSock, "cont"); err != nil { + t.Logf("QMP cont failed: %v", err) + } else { + t.Logf("VM resumed, HV timezone retry branch NOP'd") + stall.Reset() + } + } + } + } + + if stall.Stalled(stallLimit) { + dumpSerialLog(t, serialLog, resultsDir) + if regs, err := QMPHumanMonitor(qmpSock, "info registers"); err == nil { + t.Logf("=== registers at stall ===\n%s", regs) + regPath := filepath.Join(resultsDir, "registers-stall.txt") + os.WriteFile(regPath, []byte(regs), 0644) + } + t.Fatalf("guest stalled: screen, disk IO, and PC unchanged for %d consecutive polls", + stall.Consecutive()) + } + + select { + case reason := <-syncExCh: + dumpSerialLog(t, serialLog, resultsDir) + t.Fatalf("Synchronous Exception: %s", reason) + case reason := <-efiShellCh: + dumpSerialLog(t, serialLog, resultsDir) + t.Fatalf("firmware dropped to EFI shell: %s", reason) + case err := <-qemuDied: + dumpSerialLog(t, serialLog, resultsDir) + t.Fatalf("QEMU exited unexpectedly at frame %d: %v", frame, err) + default: + } + + // A host-side assertion that needs the guest alive runs here, before + // any of the shutdown paths below. The guest keeps running only for + // as long as the callback takes. + if source != nil && source.onGuestReady != nil && source.readyMarker != "" && + progressLogContains(guestProgressLog, source.readyMarker) { + t.Logf("guest ready marker %q seen (after %s, %d frames)", + source.readyMarker, time.Since(start).Round(time.Second), frame) + source.onGuestReady(t, spec.SSHPort) + break + } + + if progressLogContains(guestProgressLog, winpe.WimBuilderCompleteToken) { + t.Logf("builder complete token in progress log (after %s, %d frames)", + time.Since(start).Round(time.Second), frame) + break + } + + doneMarker := readAnswerVolumeFile(t, sharedImg, "/"+winpe.WimBuilderDoneFile) + if doneMarker != "" { + t.Logf("builder done marker: %q (after %s, %d frames)", + strings.TrimSpace(doneMarker), time.Since(start).Round(time.Second), frame) + break + } + + // Agent commands other than the builder write no builder marker, so + // a verify pass that finished in 30 seconds would otherwise hold the + // VM until the deadline. Watch the progress stream rather than the + // shared volume: the guest's FAT writes sit in cache until shutdown, + // so AgentDoneFile does not appear on the host in time to help. + if progressLogContains(guestProgressLog, "devcell: ran ") { + t.Logf("agent finished its command (after %s, %d frames)", + time.Since(start).Round(time.Second), frame) + break + } + } + + // Troubleshooting mode: hold the guest at the point the agent finished + // so it can be driven through QMP from another shell. Nothing above + // this line differs from a normal run. + if keepAlive { + stopFile := filepath.Join(resultsDir, "STOP") + t.Logf("=== VM LEFT RUNNING (keepAlive) ===") + t.Logf(" QMP socket: %s", qmpSock) + t.Logf(" serial log: %s", serialLog) + t.Logf(" progress log: %s", guestProgressLog) + t.Logf(" shared FAT: %s", sharedImg) + t.Logf(" screenshot: QMPScreendump via qmp socket above") + // The guest's :22 is forwarded to a per-run host port; without it + // printed here a session has to reverse-engineer the argv. + for _, a := range argv { + if i := strings.Index(a, "hostfwd=tcp:"); i >= 0 { + t.Logf(" ssh forward: %s", a[i:]) + } + } + t.Logf(" stop with: touch %s", stopFile) + + for time.Since(start) < deadline { + if _, err := os.Stat(stopFile); err == nil { + t.Logf("STOP file seen; shutting the guest down") + break + } + // A guest can die on its own — a stray Ctrl+C in the console + // ends winpeshl and WinPE with it. Without this the loop would + // hold the QEMU exclusivity lock until the deadline and block + // every later run. + if _, err := QueryVMState(qmpSock); err != nil { + t.Logf("guest is gone (%v); ending the session", err) + break + } + time.Sleep(5 * time.Second) + } + + // Wait for the process, not on a timer with a default branch: a + // select/default returns immediately, so the kill never fires and + // cmd.Wait() then blocks forever on a guest that ignored the quit. + QMPQuit(qmpSock) + waitCh := make(chan error, 1) + go func() { waitCh <- cmd.Wait() }() + select { + case <-waitCh: + case <-time.After(30 * time.Second): + t.Log("QEMU did not exit within 30s after quit, killing") + cmd.Process.Kill() + <-waitCh + } + close(qemuExited) + + // The guest's FAT writes only reach the image after shutdown, so + // read the streamed progress log rather than the shared volume. + streamed, _ := os.ReadFile(guestProgressLog) + return wimBuilderRun{ + agentOut: string(streamed), + sharedImg: sharedImg, + resultsDir: resultsDir, + tmpDir: tmpDir, + } + } + + // Graceful shutdown: QMP quit flushes writeback caches so the qcow2 + // is consistent on disk. Fall back to Kill if QMP is unreachable. + if err := QMPQuit(qmpSock); err != nil { + t.Logf("QMPQuit failed (%v), falling back to Kill", err) + cmd.Process.Kill() + } + waitCh := make(chan error, 1) + go func() { waitCh <- cmd.Wait() }() + select { + case <-waitCh: + case <-time.After(30 * time.Second): + t.Log("QEMU did not exit within 30s after quit, killing") + cmd.Process.Kill() + <-waitCh + } + close(qemuExited) + + agentOut := readAnswerVolumeFile(t, sharedImg, "/"+winpe.AgentResultFile) + t.Logf("=== builder output ===\n%s", agentOut) + + doneMarker := readAnswerVolumeFile(t, sharedImg, "/"+winpe.WimBuilderDoneFile) + + return wimBuilderRun{ + agentOut: agentOut, + sharedImg: sharedImg, + resultsDir: resultsDir, + tmpDir: tmpDir, + doneMarker: strings.TrimSpace(doneMarker), + } +} + +// TestWimBuilder boots a builder WinPE that runs DISM offline servicing +// against a copy of boot.wim. +// +// Subtests: +// +// boot-wim — VirtIO drivers only, no install.wim mount (~5 min TCG) +// full — Hyper-V + WSL + OpenSSH + VirtIO drivers (~30 min TCG) +// +// go test -tags wimlib -run TestWimBuilder/tcg/boot-wim -timeout 15m ./internal/vm/qemu/ +// go test -tags wimlib -run TestWimBuilder/tcg/full -timeout 50m ./internal/vm/qemu/ +func TestWimBuilder(t *testing.T) { + if testing.Short() { + t.Skip("long: boots WinPE to run DISM offline servicing") + } + + for _, accel := range []string{"tcg", "hvf"} { + t.Run(accel, func(t *testing.T) { + if accel == "hvf" && runtime.GOOS != "darwin" { + t.Skip("hvf requires macOS") + } + + t.Run("boot-wim", func(t *testing.T) { + cfg := winpe.WimPrepConfig{Ops: winpe.VirtIODriverPrepOps()} + run := runWimBuilder(t, accel, cfg, 10*time.Minute, 3, nil) + + require.NotEmpty(t, run.doneMarker, "builder never completed") + assert.Contains(t, run.agentOut, "DEVCELL WIM BUILDER") + assert.Contains(t, run.agentOut, "Found virtio-win ISO") + assert.Contains(t, run.agentOut, "Mounting boot.wim") + assert.Contains(t, run.agentOut, "boot.wim committed successfully") + assert.Contains(t, run.agentOut, "devcell.wim created") + + if run.doneMarker != "SUCCESS" { + t.Skipf("builder reported %s; skipping WIM verification", run.doneMarker) + } + + devcellWimData, err := ReadFileFromFATQcow2(run.sharedImg, "/devcell.wim") + require.NoError(t, err, "reading devcell.wim from shared volume") + require.NotEmpty(t, devcellWimData, "devcell.wim is empty") + t.Logf("devcell.wim: %d bytes (%.1f MB)", len(devcellWimData), float64(len(devcellWimData))/(1024*1024)) + + devcellWimPath := filepath.Join(run.resultsDir, "devcell.wim") + require.NoError(t, os.WriteFile(devcellWimPath, devcellWimData, 0644)) + + wim, err := wimlib.OpenWIM(devcellWimPath) + require.NoError(t, err, "opening devcell.wim") + defer wim.Close() + + count, err := wim.ImageCount() + require.NoError(t, err) + require.GreaterOrEqual(t, count, 2, "devcell.wim must still have at least 2 images") + + extractDir := filepath.Join(run.tmpDir, "devcell-extracted") + require.NoError(t, os.MkdirAll(extractDir, 0755)) + require.NoError(t, wim.ExtractImage(2, extractDir, nil)) + + // VirtIO drivers + assert.Contains(t, run.agentOut, `OK: Add-Driver NetKVM\w11\ARM64`) + assert.Contains(t, run.agentOut, `OK: Add-Driver vioserial\w11\ARM64`) + assert.Contains(t, run.agentOut, `OK: Add-Driver vioscsi\w11\ARM64`) + + driverStoreDir := filepath.Join(extractDir, "Windows", "System32", "DriverStore", "FileRepository") + for _, drv := range []struct { + name string + sys string + }{ + {"NetKVM", "netkvm.sys"}, + {"vioserial", "vioser.sys"}, + {"vioscsi", "vioscsi.sys"}, + } { + matches, _ := filepath.Glob(filepath.Join(driverStoreDir, "*", drv.sys)) + if len(matches) > 0 { + info, _ := os.Stat(matches[0]) + t.Logf(" VirtIO OK: %s -> %s (%d bytes)", drv.name, filepath.Base(filepath.Dir(matches[0])), info.Size()) + } else { + t.Errorf(" VirtIO MISSING: %s (%s not found in DriverStore/FileRepository)", drv.name, drv.sys) + } + } + + // install.wim must NOT have been mounted + assert.NotContains(t, run.agentOut, "Mounting install.wim") + }) + + // inject-features runs the production pipeline — DISM in the builder + // VM for drivers and capabilities — then applies the offline VMP + // transplant to the devcell.wim it produces. + // + // The transplant is host-side on purpose. DISM cannot enable + // VirtualMachinePlatform in a WinPE image at all: every backing + // package declares Microsoft-Windows-Foundation-Package as its + // parent and boot.wim's parent is Microsoft-Windows-WinPE-Package, + // so CBS rejects both /Add-Package (0x800f081e) and /Enable-Feature + // (0x800f080c). Copying the signed binaries in and cloning the + // service keys bypasses CBS entirely. + t.Run("inject-features", func(t *testing.T) { + var ops []winpe.WimPrepOp + ops = append(ops, winpe.OpenSSHPrepOps()...) + ops = append(ops, winpe.VirtIODriverPrepOps()...) + cfg := winpe.WimPrepConfig{Ops: ops, TransplantVMP: true} + run := runWimBuilder(t, accel, cfg, 45*time.Minute, 5, nil) + + require.NotEmpty(t, run.doneMarker, "builder never completed") + + // --- Early assertions: the builder got off the ground --- + assert.Contains(t, run.agentOut, "DEVCELL WIM BUILDER", + "builder script must have started") + + // --- Core assertions: builder completed and produced output --- + assert.Contains(t, run.agentOut, "Found Windows ISO") + assert.Contains(t, run.agentOut, "Found virtio-win ISO") + assert.Contains(t, run.agentOut, "Mounting boot.wim") + assert.Contains(t, run.agentOut, "boot.wim committed successfully") + assert.Contains(t, run.agentOut, "devcell.wim created") + + if run.doneMarker != "SUCCESS" { + t.Skipf("builder reported %s; skipping WIM verification", run.doneMarker) + } + + devcellWimData, err := ReadFileFromFATQcow2(run.sharedImg, "/devcell.wim") + require.NoError(t, err, "reading devcell.wim from shared volume") + require.NotEmpty(t, devcellWimData, "devcell.wim is empty") + t.Logf("devcell.wim: %d bytes (%.1f MB)", len(devcellWimData), float64(len(devcellWimData))/(1024*1024)) + + devcellWimPath := filepath.Join(run.resultsDir, "devcell.wim") + require.NoError(t, os.WriteFile(devcellWimPath, devcellWimData, 0644)) + + wim, err := wimlib.OpenWIM(devcellWimPath) + require.NoError(t, err, "opening devcell.wim") + defer wim.Close() + + count, err := wim.ImageCount() + require.NoError(t, err) + require.GreaterOrEqual(t, count, 2, "devcell.wim must still have at least 2 images") + + extractDir := filepath.Join(run.tmpDir, "devcell-extracted") + require.NoError(t, os.MkdirAll(extractDir, 0755)) + require.NoError(t, wim.ExtractImage(2, extractDir, nil)) + + // --- Transplant results: binaries in place --- + for _, svc := range winpe.VMPTransplantServices() { + fullPath := filepath.Join(extractDir, filepath.FromSlash(svc.File)) + info, err := os.Stat(fullPath) + if err != nil { + t.Errorf(" VMP MISSING: %s (%s)", svc.File, svc.Name) + continue + } + t.Logf(" VMP OK: %-20s %s (%d bytes)", svc.Name, svc.File, info.Size()) + } + + // --- Transplant results: VMP parity payload in place --- + for _, f := range winpe.VMPParityFiles() { + fullPath := filepath.Join(extractDir, filepath.FromSlash(f.Dest)) + info, err := os.Stat(fullPath) + if err != nil { + t.Errorf(" Parity MISSING: %s", f.Dest) + continue + } + t.Logf(" Parity OK: %s (%d bytes)", f.Dest, info.Size()) + } + + // --- Transplant results: services registered in the hive --- + hiveDir := filepath.Join(run.tmpDir, "devcell-hive") + require.NoError(t, os.MkdirAll(hiveDir, 0755)) + require.NoError(t, wim.ExtractPaths(2, hiveDir, + []string{`\Windows\System32\config\SYSTEM`})) + hive := filepath.Join(hiveDir, "Windows", "System32", "config", "SYSTEM") + + for _, svc := range winpe.VMPTransplantServices() { + key, err := regedit.ReadServiceKey(hive, `ControlSet001\Services\`+svc.Name) + if err != nil { + t.Errorf(" VMP service NOT REGISTERED: %s (%v)", svc.Name, err) + continue + } + assert.NotEmpty(t, key.Values["ImagePath"].String(), + "%s must carry an ImagePath", svc.Name) + t.Logf(" VMP registered: %-20s Start=%d", svc.Name, key.Values["Start"].DWord()) + } + + hvservice, err := regedit.ReadServiceKey(hive, `ControlSet001\Services\hvservice`) + require.NoError(t, err) + assert.Equal(t, uint32(0), hvservice.Values["Start"].DWord(), + "hvservice must be boot-start so WinPE brings up the hypervisor") + + // OpenSSH capabilities need Windows Update; the builder VM has + // no route out, so only assert them when it reported a link. + if strings.Contains(run.agentOut, "Internet: not available") { + t.Log(" OpenSSH: skipped (builder VM had no internet)") + } else { + for _, f := range []string{ + "Windows/System32/OpenSSH/sshd.exe", + "Windows/System32/OpenSSH/ssh.exe", + "Windows/System32/OpenSSH/ssh-keygen.exe", + } { + fullPath := filepath.Join(extractDir, filepath.FromSlash(f)) + if info, err := os.Stat(fullPath); err == nil { + t.Logf(" OpenSSH OK: %s (%d bytes)", f, info.Size()) + } else { + t.Errorf(" OpenSSH MISSING: %s", f) + } + } + } + + // VirtIO drivers + assert.Contains(t, run.agentOut, `OK: Add-Driver NetKVM\w11\ARM64`) + assert.Contains(t, run.agentOut, `OK: Add-Driver vioserial\w11\ARM64`) + assert.Contains(t, run.agentOut, `OK: Add-Driver vioscsi\w11\ARM64`) + + driverStoreDir := filepath.Join(extractDir, "Windows", "System32", "DriverStore", "FileRepository") + for _, drv := range []struct { + name string + sys string + }{ + {"NetKVM", "netkvm.sys"}, + {"vioserial", "vioser.sys"}, + {"vioscsi", "vioscsi.sys"}, + } { + matches, _ := filepath.Glob(filepath.Join(driverStoreDir, "*", drv.sys)) + if len(matches) > 0 { + info, _ := os.Stat(matches[0]) + t.Logf(" VirtIO OK: %s -> %s (%d bytes)", drv.name, filepath.Base(filepath.Dir(matches[0])), info.Size()) + } else { + t.Errorf(" VirtIO MISSING: %s (%s not found in DriverStore/FileRepository)", drv.name, drv.sys) + } + } + }) + + // verify-vmp answers the question the offline checks cannot: the + // transplant can be perfectly valid on disk — files present, + // service keys parsing — and still be inert at runtime. Pass 1 + // builds and transplants; pass 2 boots that artifact and lets it + // report whether SCM sees the services and winload started the + // hypervisor. + t.Run("verify-vmp", func(t *testing.T) { + var artifact []byte + + // Reusing an artifact from an earlier run halves the cycle when + // bisecting a boot failure: pass 1 is deterministic, so there + // is nothing to learn from rebuilding it each time. + if p := os.Getenv("DEVCELL_VMP_ARTIFACT"); p != "" { + data, err := os.ReadFile(p) + require.NoError(t, err, "reading DEVCELL_VMP_ARTIFACT") + artifact = data + t.Logf("reusing artifact %s (%d bytes)", p, len(data)) + } + + t.Run("pass1-build", func(t *testing.T) { + if artifact != nil { + t.Skip("using DEVCELL_VMP_ARTIFACT; skipping build") + } + cfg := winpe.WimPrepConfig{Ops: winpe.VirtIODriverPrepOps(), TransplantVMP: true} + run := runWimBuilder(t, accel, cfg, 45*time.Minute, 5, nil) + + require.Equal(t, "SUCCESS", run.doneMarker, "pass 1 must produce devcell.wim") + + var err error + artifact, err = ReadFileFromFATQcow2(run.sharedImg, "/devcell.wim") + require.NoError(t, err, "reading devcell.wim from pass 1") + require.NotEmpty(t, artifact) + t.Logf("devcell.wim: %d bytes (%.1f MB)", + len(artifact), float64(len(artifact))/(1024*1024)) + + // Save it so later runs can bisect boot failures without + // rebuilding: DEVCELL_VMP_ARTIFACT=/devcell.wim + saved := filepath.Join(run.resultsDir, "devcell.wim") + if err := os.WriteFile(saved, artifact, 0644); err != nil { + t.Logf("could not save artifact for reuse: %v", err) + } else { + t.Logf("artifact saved: %s", saved) + } + }) + + require.NotEmpty(t, artifact, "pass 1 must produce devcell.wim") + + t.Run("pass2-boot", func(t *testing.T) { + run := runWimBuilder(t, accel, winpe.WimPrepConfig{}, 30*time.Minute, 4, + &wimSourceOverride{ + name: "devcell.wim", + data: artifact, + asBootMedia: true, + // Isolating which change breaks the boot: set + // DEVCELL_VMP_NO_BCD=1 to boot the artifact without + // telling winload to start the hypervisor. + patchBCD: os.Getenv("DEVCELL_VMP_NO_BCD") == "", + agentCommand: winpe.VMPVerifyScriptCommand(), + extraFiles: map[string][]byte{ + "/" + winpe.VMPVerifyScriptName: winpe.GenerateVMPVerifyScript(), + }, + }) + + // The verify script is not the builder, so it never writes + // the builder's done marker. Reaching the banner is the + // proof that the transplanted image booted far enough to + // run an agent command. + require.Contains(t, run.agentOut, winpe.VMPVerifyBanner, + "verify script did not start") + require.Contains(t, run.agentOut, winpe.VMPVerifyComplete, + "verify script did not run to completion") + + // SCM must recognise every cloned key. NOT_EXIST here means + // the key is in the hive but unusable at runtime. + for _, svc := range winpe.VMPTransplantServices() { + assert.NotContains(t, run.agentOut, svc.Name+"_SC=NOT_EXIST", + "SCM does not recognise %s", svc.Name) + assert.NotContains(t, run.agentOut, svc.Name+"_START=ABSENT", + "%s has no Start value in the booted image", svc.Name) + } + + // The boot-start pair should already be running: nothing + // else in WinPE would have started them. + for _, svc := range []string{"hvservice", "vmbus"} { + assert.Contains(t, run.agentOut, svc+"_SC=RUNNING", + "%s is boot-start and must be running", svc) + } + + // QEMU's `max` CPU emulates EL2/VHE, so the hypervisor + // launches even under TCG (proven 2026-08-22); still + // reported rather than asserted for odd hosts. + t.Logf("hypervisor: %s", extractMarker(run.agentOut, "HYPERVISOR_PRESENT=")) + }) + + // pass3 is the runtime proof the offline checks and pass2 + // cannot give: the transplanted stack actually HOSTS a VM. + // hcsboot.exe creates a diskless Gen2 VM through HCS — the + // WSL2 path — and the guest UEFI (vmfirmware.dll) booting to + // its screen is the "boot screen" milestone. + // The only test that proves a booted devcell.wim answers + // SSH from the host. Every other SSH test in this package + // asserts over generated script text; this one exercises the + // whole chain — NetKVM bound, network initialised, firewall + // down, gosshd listening, credentials accepted — by completing + // an authenticated session and running a command whose output + // has to come back. + // + // It is also the regression test for the server itself. WinPE + // cannot run Win32-OpenSSH: that server spawns a pre-auth + // child as an LSA virtual account and authenticates with an + // S4U logon, and WinPE supports no user logons, so sessions + // closed right after KEXINIT with the child dead before its + // first log line. Reintroducing an account-coupled server here + // would fail exactly this way again. + t.Run("ssh", func(t *testing.T) { + files := map[string][]byte{ + "/" + KeepAliveProbeFile: []byte("devcell-ssh\n"), + "/" + KeepAliveScriptName: GenerateKeepAliveScript(), + } + for name, data := range keepAliveSSHFiles(t) { + files[name] = data + } + + var sshOut string + var sshErr error + run := runWimBuilder(t, accel, winpe.WimPrepConfig{}, 30*time.Minute, 4, + &wimSourceOverride{ + name: "devcell.wim", + data: artifact, + asBootMedia: true, + patchBCD: true, + agentCommand: KeepAliveProbeCommand(), + extraFiles: files, + readyMarker: KeepAliveBanner + " READY", + onGuestReady: func(t *testing.T, sshPort uint16) { + t.Logf("guest ssh forwarded to 127.0.0.1:%d", sshPort) + sshOut, sshErr = sshExecInGuest(t, sshPort, + "echo "+sshProbeToken) + }, + }) + + // The guest's own markers name the failing leg when the + // session does not come up, so report them either way. + for _, m := range []string{"GUEST_IP=", "FIREWALL_SSH=", "GOSSHD_PROC="} { + t.Logf(" %s%s", m, extractMarker(run.agentOut, m)) + } + + // The server's own log outlives the guest on the shared + // volume. It is the only side that says why a session was + // refused, so surface it before asserting rather than + // leaving a bare "Connection closed" as the whole story. + if logData, err := ReadFileFromFATQcow2(run.sharedImg, "/"+GoSSHDLogFile); err != nil { + t.Logf("no gosshd log on the shared volume: %v", err) + } else { + saved := filepath.Join(run.resultsDir, GoSSHDLogFile) + if err := os.WriteFile(saved, logData, 0644); err == nil { + t.Logf("gosshd log: %s (%d bytes)", saved, len(logData)) + } + lines := strings.Split(strings.TrimSpace(string(logData)), "\n") + if len(lines) > 40 { + lines = lines[len(lines)-40:] + } + for _, l := range lines { + t.Logf(" gosshd| %s", strings.TrimRight(l, "\r")) + } + } + + require.NoError(t, sshErr, "an SSH session must complete against the booted image") + assert.Contains(t, sshOut, sshProbeToken, + "the command must run in the guest and its output reach the host") + assert.NotEqual(t, "NONE", extractMarker(run.agentOut, "GOSSHD_PROC="), + "the ssh server must be running in the guest") + }) + t.Run("pass3-hcs", func(t *testing.T) { + run := runWimBuilder(t, accel, winpe.WimPrepConfig{}, 60*time.Minute, 4, + &wimSourceOverride{ + name: "devcell.wim", + data: artifact, + asBootMedia: true, + patchBCD: true, + secureWorld: true, + agentCommand: winpe.HCSBootScriptCommand(), + extraFiles: map[string][]byte{ + "/" + winpe.HCSBootScriptName: winpe.GenerateHCSBootScript(), + "/" + winpe.HCSBootExeName: buildHCSBootExe(t), + }, + }) + + require.Contains(t, run.agentOut, winpe.HCSBootBanner, + "hcs-boot script did not start") + require.Contains(t, run.agentOut, winpe.HCSBootComplete, + "hcs-boot script did not run to completion") + + assert.Contains(t, run.agentOut, "VMCOMPUTE_START=OK", + "the Host Compute Service must start — without it no VM API exists") + + // The whole point of the transplant: a nested VM running + // under the hypervisor we booted. + assert.Contains(t, run.agentOut, "HCSBOOT_STATE=Running", + "nested HCS VM did not reach Running; first failure usually names a missing vmwp DLL") + + // The vmms/thumbnail leg is best-effort — vmms is outside + // VMP and may refuse WinPE. Report, don't fail. + for _, m := range []string{"VMMS_START=", "MOFCOMP_EXIT=", "THUMBNAIL=", "HCSBOOT_EXIT="} { + t.Logf(" %s%s", m, extractMarker(run.agentOut, m)) + } + + if data, err := ReadFileFromFATQcow2(run.sharedImg, "/"+HCSThumbnailName); err == nil && len(data) > 0 { + pngPath := filepath.Join(run.resultsDir, "hcs-boot-screen.png") + if err := writeRGB565PNG(data, 640, 480, pngPath); err != nil { + t.Logf(" thumbnail conversion failed: %v", err) + } else { + t.Logf(" nested VM boot screen: %s", pngPath) + } + } + }) + + // Same boot as pass2 in every respect, except the VM is left + // running at the end so it can be driven through QMP. The + // probe file proves the host->guest file channel (FAT qcow) + // and the echo proves the command channel (agent shell). + // Opt in with DEVCELL_KEEP_ALIVE=1: without it this is skipped + // so a normal run behaves exactly as before. + t.Run("pass2-boot_noteardown", func(t *testing.T) { + if os.Getenv("DEVCELL_KEEP_ALIVE") == "" { + t.Skip("set DEVCELL_KEEP_ALIVE=1 to hold the guest for in-place troubleshooting") + } + + probe := []byte("devcell-probe-" + t.Name() + "\n") + files := map[string][]byte{ + "/" + KeepAliveProbeFile: probe, + "/" + KeepAliveScriptName: GenerateKeepAliveScript(), + } + for name, data := range keepAliveSSHFiles(t) { + files[name] = data + } + + run := runWimBuilder(t, accel, winpe.WimPrepConfig{}, 4*time.Hour, 4, + &wimSourceOverride{ + name: "devcell.wim", + data: artifact, + asBootMedia: true, + patchBCD: true, + agentCommand: KeepAliveProbeCommand(), + extraFiles: files, + keepAlive: true, + }) + + // 4.3 — the file crossed on the FAT volume and the guest + // read back its exact contents. + assert.Contains(t, run.agentOut, "PROBE_FILE="+strings.TrimSpace(string(probe)), + "host->guest file channel (FAT qcow) is broken") + + // 4.4 — the agent shell executed and reported. + assert.Contains(t, run.agentOut, "PROBE_SHELL=OK", + "guest command channel (agent shell) is broken") + }) + + // Boots the transplanted artifact on the EL3/secure machine + // (secure=on + kernel firmware) so Windows' hypervisor can + // launch. Gated by DEVCELL_KEEP_ALIVE=1. + t.Run("interactive-machine-secure", func(t *testing.T) { + if os.Getenv("DEVCELL_KEEP_ALIVE") == "" { + t.Skip("set DEVCELL_KEEP_ALIVE=1 to hold the guest on the secure machine") + } + + files := map[string][]byte{ + "/" + KeepAliveProbeFile: []byte("devcell-secure\n"), + "/" + KeepAliveScriptName: GenerateKeepAliveScript(), + } + for name, data := range keepAliveSSHFiles(t) { + files[name] = data + } + + run := runWimBuilder(t, accel, winpe.WimPrepConfig{}, 4*time.Hour, 4, + &wimSourceOverride{ + name: "devcell.wim", + data: artifact, + asBootMedia: true, + patchBCD: true, + agentCommand: KeepAliveProbeCommand(), + extraFiles: files, + keepAlive: true, + secureWorld: true, + }) + + assert.Contains(t, run.agentOut, "PROBE_FILE=devcell-secure", + "host->guest file channel (FAT qcow) is broken") + assert.Contains(t, run.agentOut, "PROBE_SHELL=OK", + "guest command channel (agent shell) is broken") + }) + + // pass4 registers the transplanted WSL engine and makes first + // contact. The transplant only laid files down (they are inert + // until registered), so this is where the MSI-less install + // either works or names what is still missing. + t.Run("pass4-wsl", func(t *testing.T) { + run := runWimBuilder(t, accel, winpe.WimPrepConfig{}, 45*time.Minute, 4, + &wimSourceOverride{ + name: "devcell.wim", + data: artifact, + asBootMedia: true, + patchBCD: true, + secureWorld: true, + agentCommand: winpe.WSLBootScriptCommand(), + extraFiles: wslBootVolumeFiles(t), + }) + + require.Contains(t, run.agentOut, winpe.WSLBootBanner, + "wsl-boot script did not start") + require.Contains(t, run.agentOut, winpe.WSLBootComplete, + "wsl-boot script did not run to completion") + + // If the engine files are absent the artifact predates the + // WSL transplant — rebuild pass 1, don't debug this pass. + require.NotContains(t, run.agentOut, "WSLSERVICE_REGISTER=Cannot find path", + "wslservice.exe missing from the artifact; rebuild pass 1 with the WSL transplant") + + assert.Contains(t, run.agentOut, "VMCOMPUTE_START=OK", + "vmcompute must start — WSL2 utility VMs go through HCS") + assert.Contains(t, run.agentOut, "WSLSERVICE_REGISTER=OK", + "New-Service must accept the MSI-declared WSLService definition") + assert.Contains(t, run.agentOut, "REGSVR32_PROXYSTUB=0", + "the COM proxy stub must self-register") + + // First integration run: report the runtime legs before + // hard-asserting them — their failure modes name the next + // missing piece. + for _, m := range []string{"WSLSERVICE_START=", "WSL_STATUS_EXIT="} { + t.Logf(" %s%s", m, extractMarker(run.agentOut, m)) + } + }) + }) + + // A hands-on session against a pre-built devcell.wim: boots the + // artifact, brings up sshd, then parks on an interactive cmd.exe + // so the guest can be driven either over SSH or through QMP + // keystrokes. Before handing the VM over it asserts the one thing + // a hands-on session cannot live without: an interactive shell + // that executes typed lines. It only runs when asked for. + t.Run("interactive", func(t *testing.T) { + artifactPath := os.Getenv("DEVCELL_VMP_ARTIFACT") + if artifactPath == "" { + t.Skip("set DEVCELL_VMP_ARTIFACT= to open an interactive session") + } + artifact, err := os.ReadFile(artifactPath) + require.NoError(t, err, "reading DEVCELL_VMP_ARTIFACT") + t.Logf("interactive session on %s (%d bytes)", artifactPath, len(artifact)) + + files := map[string][]byte{ + "/" + KeepAliveProbeFile: []byte("devcell-interactive\n"), + "/" + KeepAliveScriptName: GenerateKeepAliveScript(), + } + for name, data := range keepAliveSSHFiles(t) { + files[name] = data + } + + run := runWimBuilder(t, accel, winpe.WimPrepConfig{}, 4*time.Hour, 4, + &wimSourceOverride{ + name: "devcell.wim", + data: artifact, + asBootMedia: true, + patchBCD: true, + agentCommand: InteractiveShellCommand(), + extraFiles: files, + readyMarker: KeepAliveBanner + " READY", + onGuestReady: func(t *testing.T, sshPort uint16) { + t.Logf("guest ssh forwarded to 127.0.0.1:%d", sshPort) + assert.NoError(t, sshInteractiveShellInGuest(t, sshPort), + "an interactive shell must execute typed lines before the VM is handed over") + }, + keepAlive: true, + }) + + t.Logf("ssh server: %s", extractMarker(run.agentOut, "GOSSHD_PROC=")) + t.Logf("guest ip: %s", extractMarker(run.agentOut, "GUEST_IP=")) + t.Logf("log in with: user %q password %q on the forwarded port above", + gosshd.DefaultUser, gosshd.DefaultPassword) + }) + + t.Run("hyperv", func(t *testing.T) { + var devcellWimData []byte + + t.Run("pass1-drivers", func(t *testing.T) { + driverCfg := winpe.WimPrepConfig{Ops: winpe.VirtIODriverPrepOps()} + run := runWimBuilder(t, accel, driverCfg, 10*time.Minute, 3, nil) + + require.Equal(t, "SUCCESS", run.doneMarker, "pass 1 (drivers) must succeed") + assert.NotContains(t, run.agentOut, "Mounting install.wim", + "pass 1 must not touch install.wim") + + var err error + devcellWimData, err = ReadFileFromFATQcow2(run.sharedImg, "/devcell.wim") + require.NoError(t, err, "reading devcell.wim from pass 1") + require.NotEmpty(t, devcellWimData) + t.Logf("devcell.wim: %d bytes (%.1f MB)", len(devcellWimData), float64(len(devcellWimData))/(1024*1024)) + }) + + require.NotEmpty(t, devcellWimData, "pass 1 must produce devcell.wim") + + t.Run("pass2-features", func(t *testing.T) { + hypervCfg := winpe.WimPrepConfig{ + Ops: append(winpe.HyperVPrepOps(), winpe.WSL2PrepOps()...), + SourceWim: "devcell.wim", + TargetWim: "devcell.wim", + } + run := runWimBuilder(t, accel, hypervCfg, 25*time.Minute, 5, &wimSourceOverride{ + name: "devcell.wim", + data: devcellWimData, + }) + + require.NotEmpty(t, run.doneMarker, "pass 2 (hyperv) never completed") + assert.Contains(t, run.agentOut, "Mounting install.wim") + assert.Contains(t, run.agentOut, "Discovering packages for Microsoft-Hyper-V") + assert.Contains(t, run.agentOut, "OK: Enable-Feature Microsoft-Hyper-V") + assert.Contains(t, run.agentOut, "Discovering packages for Microsoft-Windows-Subsystem-Linux") + assert.Contains(t, run.agentOut, "OK: Enable-Feature Microsoft-Windows-Subsystem-Linux") + + if run.doneMarker != "SUCCESS" { + t.Skipf("pass 2 reported %s; skipping WIM verification", run.doneMarker) + } + + finalWimData, err := ReadFileFromFATQcow2(run.sharedImg, "/devcell.wim") + require.NoError(t, err, "reading devcell.wim from pass 2") + require.NotEmpty(t, finalWimData) + t.Logf("devcell.wim: %d bytes (%.1f MB)", len(finalWimData), float64(len(finalWimData))/(1024*1024)) + + devcellWimPath := filepath.Join(run.resultsDir, "devcell.wim") + require.NoError(t, os.WriteFile(devcellWimPath, finalWimData, 0644)) + + wim, err := wimlib.OpenWIM(devcellWimPath) + require.NoError(t, err, "opening devcell.wim") + defer wim.Close() + + extractDir := filepath.Join(run.tmpDir, "devcell-extracted") + require.NoError(t, os.MkdirAll(extractDir, 0755)) + require.NoError(t, wim.ExtractImage(2, extractDir, nil)) + + // Hyper-V binaries from pass 2 + for _, f := range []string{ + "Windows/System32/vmms.exe", + "Windows/System32/vmwp.exe", + "Windows/System32/vmcompute.exe", + "Windows/System32/drivers/Vid.sys", + "Windows/System32/drivers/vmswitch.sys", + } { + fullPath := filepath.Join(extractDir, filepath.FromSlash(f)) + if info, err := os.Stat(fullPath); err == nil { + t.Logf(" Hyper-V OK: %s (%d bytes)", f, info.Size()) + } else { + t.Errorf(" Hyper-V MISSING: %s", f) + } + } + + // VirtIO drivers from pass 1 must still be present + driverStoreDir := filepath.Join(extractDir, "Windows", "System32", "DriverStore", "FileRepository") + for _, drv := range []struct { + name string + sys string + }{ + {"NetKVM", "netkvm.sys"}, + {"vioserial", "vioser.sys"}, + {"vioscsi", "vioscsi.sys"}, + } { + matches, _ := filepath.Glob(filepath.Join(driverStoreDir, "*", drv.sys)) + if len(matches) > 0 { + t.Logf(" VirtIO OK (pass 1 survived): %s", drv.name) + } else { + t.Errorf(" VirtIO MISSING after pass 2: %s", drv.name) + } + } + }) + }) + }) + } +} + +// requirePwshFiles returns the extracted PowerShell 7 files (volume-path -> +// content) needed by SharedVolumeFiles. Resolution order: +// 1. DEVCELL_TEST_PWSH_ZIP env var pointing to a pre-downloaded zip +// 2. Cached zip under ~/.devcell/cache/qemu/ +// +// Skips the test if neither source is available. +func requirePwshFiles(t *testing.T) map[string][]byte { + t.Helper() + + var zipPath string + + if p := os.Getenv("DEVCELL_TEST_PWSH_ZIP"); p != "" { + if _, err := os.Stat(p); err != nil { + t.Fatalf("DEVCELL_TEST_PWSH_ZIP=%s: %v", p, err) + } + zipPath = p + } else { + home, err := os.UserHomeDir() + require.NoError(t, err) + p, err := DownloadPwsh(t.Context(), home, false, NopObserver{}) + if err != nil { + t.Skipf("could not obtain pwsh zip: %v", err) + } + zipPath = p + } + + files, err := winpe.ExtractPwshFiles(zipPath) + require.NoError(t, err, "extracting pwsh files from %s", zipPath) + require.NotEmpty(t, files, "pwsh zip contained no files") + t.Logf("pwsh: %d files extracted from %s", len(files), filepath.Base(zipPath)) + return files +} + +func progressLogContains(path, token string) bool { + data, err := os.ReadFile(path) + if err != nil { + return false + } + return strings.Contains(string(data), token) +} + +// buildHCSBootExe cross-compiles the nested-VM smoke test for the WinPE +// guest. Building at test time keeps the binary in lockstep with +// internal/hcsvm instead of a stale checked-in artifact. +func buildHCSBootExe(t *testing.T) []byte { + t.Helper() + + out := filepath.Join(t.TempDir(), winpe.HCSBootExeName) + cmd := exec.Command("go", "build", "-o", out, + "github.com/devcell-sh/go-winkit/hcsvm/hcsboot") + cmd.Env = append(os.Environ(), "GOOS=windows", "GOARCH=arm64", "CGO_ENABLED=0") + if b, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("cross-compiling hcsboot: %v\n%s", err, b) + } + data, err := os.ReadFile(out) + require.NoError(t, err) + return data +} + +// writeRGB565PNG converts the raw thumbnail frame the vmms WMI API returns +// (RGB565 little-endian, row-major) into a viewable PNG. +func writeRGB565PNG(raw []byte, width, height int, path string) error { + if len(raw) < width*height*2 { + return fmt.Errorf("thumbnail too small: %d bytes for %dx%d", len(raw), width, height) + } + img := image.NewRGBA(image.Rect(0, 0, width, height)) + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + px := binary.LittleEndian.Uint16(raw[2*(y*width+x):]) + r := uint8((px >> 11) & 0x1f << 3) + g := uint8((px >> 5) & 0x3f << 2) + b := uint8(px & 0x1f << 3) + img.SetRGBA(x, y, color.RGBA{R: r, G: g, B: b, A: 0xff}) + } + } + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + return png.Encode(f, img) +} + +// wslBootVolumeFiles assembles the pass4 agent volume: the script always, +// the alpine rootfs when obtainable. Without the rootfs the script still +// proves registration and reports the distro leg as SKIPPED. +func wslBootVolumeFiles(t *testing.T) map[string][]byte { + t.Helper() + + files := map[string][]byte{ + "/" + winpe.WSLBootScriptName: winpe.GenerateWSLBootScript(), + } + + home, err := os.UserHomeDir() + require.NoError(t, err) + tarPath, err := DownloadAlpineRootfs(t.Context(), home, false, NopObserver{}) + if err != nil { + t.Logf("alpine rootfs unavailable, distro leg will be skipped: %v", err) + return files + } + data, err := os.ReadFile(tarPath) + require.NoError(t, err) + files["/"+winpe.WSLRootfsVolName] = data + t.Logf("alpine rootfs on volume: %s (%d bytes)", winpe.WSLRootfsVolName, len(data)) + return files +} + +// keepAliveSSHFiles stages what the guest needs to serve SSH: the gosshd +// payload, cross-compiled here for windows/arm64. +// +// There is no keypair to stage. gosshd authenticates against its own +// credentials rather than a Windows account, which is the whole reason it +// replaced Win32-OpenSSH: WinPE cannot mint the virtual-account logon that +// server's privsep child needs, so its sessions died before authentication. +func keepAliveSSHFiles(t *testing.T) map[string][]byte { + t.Helper() + + path, err := BuildGoSSHDPayload(t.TempDir()) + require.NoError(t, err, "cross-compiling the gosshd payload") + data, err := os.ReadFile(path) + require.NoError(t, err) + t.Logf("gosshd payload: %d bytes (windows/arm64)", len(data)) + + return map[string][]byte{"/" + GoSSHDPayloadName: data} +} + +// sshProbeToken is echoed by the guest and matched on the host: seeing it +// proves the command ran there and its output travelled back, which a +// successful exit code alone would not. +const sshProbeToken = "DEVCELL_SSH_OK" + +// sshExecInGuest runs one command in the guest over SSH. +// +// The Go client rather than the ssh binary: password auth through the CLI +// would need sshpass or an askpass helper on PATH, and this container's PATH +// omits the profile that carries them. It also reports protocol-stage errors +// directly instead of only through a verbose trace. +// +// It retries: the guest logs its ready marker from the agent script, but the +// server is a separate process that may not be accepting connections in the +// same instant, so a single attempt would fail on that race rather than on +// anything real. +func sshExecInGuest(t *testing.T, port uint16, cmd string) (string, error) { + t.Helper() + + cfg := &cryptossh.ClientConfig{ + User: gosshd.DefaultUser, + Auth: []cryptossh.AuthMethod{cryptossh.Password(gosshd.DefaultPassword)}, + // The guest generates a host key on each boot and is reached over a + // per-run forwarded port on loopback, so there is no identity to pin. + HostKeyCallback: cryptossh.InsecureIgnoreHostKey(), + Timeout: 20 * time.Second, + } + + var lastErr error + deadline := time.Now().Add(3 * time.Minute) + for attempt := 1; time.Now().Before(deadline); attempt++ { + out, err := sshExecOnce(fmt.Sprintf("127.0.0.1:%d", port), cfg, cmd) + if err == nil { + t.Logf("ssh succeeded on attempt %d", attempt) + return out, nil + } + lastErr = fmt.Errorf("attempt %d: %w", attempt, err) + t.Logf("ssh not ready: %v", lastErr) + time.Sleep(15 * time.Second) + } + return "", lastErr +} + +// sshInteractiveToken appears alone on an output line only when cmd.exe +// expanded and executed a typed line. The guest echoes every piped command +// back with its prompt prefixed, so the token by itself cannot come from the +// echo of the `set` line that defines it or the `echo %..%` line that +// expands it — only from execution. +const sshInteractiveToken = "DEVCELL_INTERACTIVE_OK" + +// sshInteractiveShellInGuest drives the guest the way a person at a terminal +// does: request a PTY, start a shell, type lines, and prove one executed. +// +// The PTY request must be refused. The server has no terminal to put behind +// it — sessions only get pipes — and granting it anyway flips the client's +// terminal into raw mode (no local echo, Enter sends \r) while cmd.exe waits +// for a \n that never arrives: typing lands in a void. Refusal makes every +// ssh client fall back to cooked line mode, which the pipe handles. +// +// Same retry rationale as sshExecInGuest: the ready marker and the server +// accepting connections are separate events. +func sshInteractiveShellInGuest(t *testing.T, port uint16) error { + t.Helper() + + cfg := &cryptossh.ClientConfig{ + User: gosshd.DefaultUser, + Auth: []cryptossh.AuthMethod{cryptossh.Password(gosshd.DefaultPassword)}, + HostKeyCallback: cryptossh.InsecureIgnoreHostKey(), + Timeout: 20 * time.Second, + } + + var lastErr error + deadline := time.Now().Add(3 * time.Minute) + for attempt := 1; time.Now().Before(deadline); attempt++ { + err := sshInteractiveShellOnce(fmt.Sprintf("127.0.0.1:%d", port), cfg) + if err == nil { + t.Logf("interactive shell succeeded on attempt %d", attempt) + return nil + } + lastErr = fmt.Errorf("attempt %d: %w", attempt, err) + t.Logf("interactive shell not ready: %v", lastErr) + time.Sleep(15 * time.Second) + } + return lastErr +} + +func sshInteractiveShellOnce(addr string, cfg *cryptossh.ClientConfig) error { + client, err := cryptossh.Dial("tcp", addr, cfg) + if err != nil { + return fmt.Errorf("dial %s: %w", addr, err) + } + defer client.Close() + + sess, err := client.NewSession() + if err != nil { + return fmt.Errorf("session: %w", err) + } + defer sess.Close() + + if err := sess.RequestPty("xterm-256color", 40, 80, cryptossh.TerminalModes{}); err == nil { + return fmt.Errorf("pty-req was granted; the server has no terminal to back one") + } + + stdin, err := sess.StdinPipe() + if err != nil { + return fmt.Errorf("stdin pipe: %w", err) + } + stdout, err := sess.StdoutPipe() + if err != nil { + return fmt.Errorf("stdout pipe: %w", err) + } + if err := sess.Shell(); err != nil { + return fmt.Errorf("shell: %w", err) + } + + // \n line endings, exactly what a cooked-mode terminal sends. + fmt.Fprint(stdin, "set DEVCELL_TOK="+sshInteractiveToken+"\n") + fmt.Fprint(stdin, "echo %DEVCELL_TOK%\n") + fmt.Fprint(stdin, "exit\n") + + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + if strings.TrimSpace(scanner.Text()) == sshInteractiveToken { + return nil + } + } + return fmt.Errorf("shell closed without executing the typed line (read err: %v)", scanner.Err()) +} + +// sshExecOnce is one dial-authenticate-run cycle, kept separate so every +// connection is closed even when the command itself fails. +func sshExecOnce(addr string, cfg *cryptossh.ClientConfig, cmd string) (string, error) { + client, err := cryptossh.Dial("tcp", addr, cfg) + if err != nil { + return "", fmt.Errorf("dial %s: %w", addr, err) + } + defer client.Close() + + sess, err := client.NewSession() + if err != nil { + return "", fmt.Errorf("session: %w", err) + } + defer sess.Close() + + out, err := sess.CombinedOutput(cmd) + if err != nil { + return string(out), fmt.Errorf("run %q: %w", cmd, err) + } + return string(out), nil +} diff --git a/internal/vm/qemu/build_windows_test.go b/internal/vm/qemu/build_windows_test.go new file mode 100644 index 0000000..32ebbf8 --- /dev/null +++ b/internal/vm/qemu/build_windows_test.go @@ -0,0 +1,777 @@ +//go:build wimlib + +package qemu + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "hash/fnv" + "net" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/devcell-sh/go-wimlib" + "github.com/devcell-sh/go-winkit/isokit" + "github.com/devcell-sh/go-winkit/winpe" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestBuildWindows drives the full Windows build pipeline through library APIs: +// +// 1. Prep WIM — boot WinPE, run DISM offline servicing, verify devcell.wim +// contents (Hyper-V binaries + registry, WSL2 feature, OpenSSH binaries, +// VirtIO drivers in DriverStore) +// 2. Install — boot Windows installer with devcell.wim, wait for SSH, +// assert bootstrap steps +// +// The WIM verification runs during the WinPE phase — before we commit to the +// multi-hour install — so a broken DISM pipeline fails in minutes, not hours. +// +// go test -tags wimlib -run TestBuildWindows/tcg -timeout 8h -v ./internal/vm/qemu/ +// go test -tags wimlib -run TestBuildWindows/hvf -timeout 8h -v ./internal/vm/qemu/ +func TestBuildWindows(t *testing.T) { + if testing.Short() { + t.Skip("long: full unattended Windows install") + } + if os.Getenv("DEVCELL_TEST_INSTALL") == "" { + t.Skip("set DEVCELL_TEST_INSTALL=1 to run the multi-hour unattended install") + } + + qemuBin := requireQEMUBin(t) + fwPath := requireFirmware(t) + winISO := requireWindowsISO(t) + virtioISO := requireVirtioISO(t) + pwshFiles := requirePwshFiles(t) + + if fwData, err := os.ReadFile(fwPath); err == nil { + h := sha256.Sum256(fwData) + t.Logf("firmware: %s (%d bytes, sha256=%s)", fwPath, len(fwData), hex.EncodeToString(h[:8])) + } + + for _, accel := range []string{"tcg", "hvf"} { + t.Run(accel, func(t *testing.T) { + if accel == "hvf" && runtime.GOOS != "darwin" { + t.Skip("hvf requires macOS") + } + + resultsDir := testResultsDir(t) + tmpDir := t.TempDir() + + qemuAccel := "tcg,thread=multi" + if accel == "hvf" { + qemuAccel = "hvf" + } + + // ══════════════════════════════════════════════════════════ + // Phase 1: Prep WIM — DISM offline servicing in WinPE + // ══════════════════════════════════════════════════════════ + devcellWimPath := buildAndVerifyDevcellWim(t, qemuBin, fwPath, winISO, virtioISO, qemuAccel, tmpDir, resultsDir) + + // ══════════════════════════════════════════════════════════ + // Phase 2: Install Windows + // ══════════════════════════════════════════════════════════ + + // --- Disk --- + diskPath := filepath.Join(tmpDir, "disk.qcow2") + out, err := exec.Command(qemuBin+"-img", "create", "-f", "qcow2", diskPath, "64G").CombinedOutput() + if err != nil { + out, err = exec.Command("qemu-img", "create", "-f", "qcow2", diskPath, "64G").CombinedOutput() + } + require.NoError(t, err, "qemu-img create: %s", out) + + // --- Firmware vars --- + varsPath := filepath.Join(tmpDir, "vars.fd") + require.NoError(t, PrepareVarsFile(fwPath, varsPath)) + + // --- SSH key --- + sshKeyDir := filepath.Join(tmpDir, "ssh") + require.NoError(t, os.MkdirAll(sshKeyDir, 0o700)) + privKey := filepath.Join(sshKeyDir, "id_ed25519") + keygen := exec.Command("ssh-keygen", "-t", "ed25519", "-N", "", "-f", privKey) + keyOut, err := keygen.CombinedOutput() + require.NoError(t, err, "ssh-keygen: %s", keyOut) + pubKeyBytes, err := os.ReadFile(privKey + ".pub") + require.NoError(t, err) + pubKey := strings.TrimSpace(string(pubKeyBytes)) + + // --- devcell.wim volume --- + // Package the patched devcell.wim as a FAT image Windows Setup + // can read via X:\devcell-install.wim. + devcellWimData, err := os.ReadFile(devcellWimPath) + require.NoError(t, err, "reading verified devcell.wim") + devcellWimFiles := map[string][]byte{ + "/devcell-install.wim": devcellWimData, + } + devcellWimImg := filepath.Join(tmpDir, "devcell-wim.qcow2") + require.NoError(t, CreateFATQcow2(devcellWimImg, devcellWimFiles, 20*1024*1024*1024)) + t.Logf("devcell WIM volume: %s (%.1f MB WIM inside)", devcellWimImg, float64(len(devcellWimData))/(1024*1024)) + + // --- Answer volume --- + cfg := DefaultAutounattendConfig() + cfg.SSHPubKey = pubKey + cfg.VirtIODrivers = NetKVMDriverPaths() + cfg.EnableRDP = true + cfg.InstallWimPath = `X:\devcell-install.wim` + + drivers, err := winpe.LoadWinPEStorageDrivers(virtioISO) + require.NoError(t, err, "extracting vioscsi drivers from virtio ISO") + cfg.AnswerDrivers = drivers + + bootloader, err := winpe.InstallerBootloader(winISO) + require.NoError(t, err, "extracting BOOTAA64.EFI from Windows ISO") + blInfo, err := winpe.ValidateBootloaderPE(bootloader) + require.NoError(t, err, "validating BOOTAA64.EFI") + cfg.EFIBootLoader = bootloader + t.Logf("embedded BOOTAA64.EFI (%d bytes, arch=%s) on answer volume", blInfo.Size, blInfo.Arch) + + homeDir, err := os.UserHomeDir() + require.NoError(t, err, "resolving home dir for OpenSSH cache") + opensshPath, err := DownloadOpenSSH(t.Context(), homeDir, false, NopObserver{}) + require.NoError(t, err, "downloading OpenSSH payload") + opensshData, err := os.ReadFile(opensshPath) + require.NoError(t, err, "reading OpenSSH payload") + cfg.OpenSSHPayload = OpenSSHPayloadName + cfg.OpenSSHPayloadData = opensshData + cfg.OpenSSHPayloadSize = len(opensshData) + t.Logf("embedded OpenSSH payload (%d bytes) on answer volume", len(opensshData)) + + answerImg := filepath.Join(tmpDir, "autounattend.img") + require.NoError(t, BuildAnswerVolume(cfg, answerImg)) + + // --- Budget --- + var memoryGB uint64 = 4 + var sshDeadline = 45 * time.Minute + var diskCacheMode string + if accel == "tcg" { + memoryGB = 8 + sshDeadline = 5 * time.Hour + diskCacheMode = "unsafe" + qemuAccel += ",tb-size=512" + } + + // --- Spec --- + sshPort := findFreePort(t) + + serialLog := filepath.Join(resultsDir, "serial.log") + guestProgressLog := filepath.Join(resultsDir, "guest-progress.log") + spec := Spec{ + VMName: "build-windows-test", + CPUs: 4, + MemoryGB: memoryGB, + DiskCacheMode: diskCacheMode, + DiskPath: diskPath, + FirmwarePath: fwPath, + VarsPath: varsPath, + SerialLogPath: serialLog, + GuestProgressLogPath: guestProgressLog, + NestedVirt: true, + VirtioISO: virtioISO, + CDBus: "scsi", + SSHPort: sshPort, + SSHHost: "127.0.0.1", + SSHUser: SessionUsername(), + SSHKeyPath: privKey, + MACAddr: DeterministicMAC("build-test-" + accel), + DisplayType: "none", + QMPSocketDir: tmpDir, + Accel: qemuAccel, + NoReboot: false, + } + spec.DevcellWimImg = devcellWimImg + spec.ApplyDefaults() + require.NoError(t, spec.Validate()) + + qmpSock := QMPSocketPath(spec) + argv := BuildInstallCommand(spec, winISO, answerImg) + argv[0] = qemuBin + + t.Logf("install command: %s", strings.Join(argv, " ")) + updateRunJSON(t, resultsDir, map[string]any{ + "test": t.Name(), "qemu-args": strings.Join(argv, " "), + }) + + // --- Launch QEMU --- + require.NoError(t, EnsureScreenshotDir(resultsDir, ScreenSourceQMP)) + + exclusiveQEMU(t) + cmd := exec.Command(argv[0], argv[1:]...) + qemuLog := qemuOutput(t, resultsDir, argv) + cmd.Stdout = qemuLog + cmd.Stderr = qemuLog + require.NoError(t, cmd.Start(), "starting QEMU") + qemuDone := make(chan error, 1) + go func() { qemuDone <- cmd.Wait() }() + defer func() { + cmd.Process.Kill() + <-qemuDone + }() + + waitForSocket(t, qmpSock, 30*time.Second, qemuLog) + assertAccel(t, qmpSock, accel, resultsDir) + + if qtree, err := QMPHumanMonitor(qmpSock, "info qtree"); err == nil { + os.WriteFile(filepath.Join(resultsDir, "qtree.txt"), []byte(qtree), 0o644) + } + + // --- Serial log watchers --- + stop := make(chan struct{}) + defer close(stop) + efiShellCh := WatchSerialForEFIShell(serialLog, stop) + nshFailCh := WatchSerialForStartupNSHFail(serialLog, stop) + + // --- Poll loop: screenshots + stall detection + SSH probe --- + const ( + pollInterval = 15 * time.Second + stallBudget = 10 * time.Minute + ) + stallLimit := StallPollsFor(int(stallBudget.Seconds()), int(pollInterval.Seconds())) + var stall StallTracker + + ppmPath := filepath.Join(tmpDir, "screen.ppm") + start := time.Now() + frame := 0 + sshReady := false + + for time.Since(start) < sshDeadline { + time.Sleep(pollInterval) + frame++ + + var pollHash uint64 + var pollRead int64 + var pollPC string + + os.Remove(ppmPath) + if err := QMPScreendump(qmpSock, ppmPath); err == nil { + if ppmData, err := os.ReadFile(ppmPath); err == nil { + h := fnv.New64a() + h.Write(ppmData) + pollHash = h.Sum64() + } + pngPath := ScreenshotPath(resultsDir, ScreenSourceQMP, time.Now(), + "none", frame, frame, "png") + if err := ConvertPPMtoPNG(ppmPath, pngPath); err == nil { + t.Logf("[frame %d] saved %s", frame, filepath.Base(pngPath)) + } + } + + if stats, err := QMPBlockStats(qmpSock); err == nil { + for _, s := range stats { + pollRead += s.ReadBytes + } + } + + if regs, err := QMPHumanMonitor(qmpSock, "info registers"); err == nil { + pollPC = ExtractRegister(regs, "PC=") + } + + select { + case err := <-qemuDone: + dumpSerialLog(t, serialLog, resultsDir) + t.Fatalf("QEMU exited unexpectedly after %s (frame %d): %v", + time.Since(start).Round(time.Second), frame, err) + default: + } + + n := stall.Observe(StallSignal{ScreenHash: pollHash, ReadBytes: pollRead, PC: pollPC}) + t.Logf("[frame %d] hash=%016x rd=%d PC=%s stall=%d/%d elapsed=%s", + frame, pollHash, pollRead, pollPC, n, stallLimit, time.Since(start).Round(time.Second)) + + if stall.Stalled(stallLimit) { + if _, err := os.Stat(ppmPath); err == nil { + ConvertPPMtoPNG(ppmPath, filepath.Join(resultsDir, "stalled-last.png")) + } + dumpStallDiagnostics(t, qmpSock, resultsDir, "") + dumpSerialLog(t, serialLog, resultsDir) + t.Fatalf("guest stalled: screen and disk IO unchanged for %d consecutive polls (%v)", + stall.Consecutive(), time.Duration(stall.Consecutive())*pollInterval) + } + + select { + case reason := <-nshFailCh: + dumpSerialLog(t, serialLog, resultsDir) + t.Fatalf("startup.nsh could not chainload BOOTAA64.EFI: %s", reason) + default: + } + select { + case reason := <-efiShellCh: + t.Logf("EFI shell appeared — startup.nsh should recover: %s", reason) + efiShellCh = nil + default: + } + + if probeSSH(spec.SSHHost, spec.SSHPort) { + t.Logf("SSH ready after %s (%d frames)", time.Since(start).Round(time.Second), frame) + sshReady = true + break + } + } + + // --- Final screenshot --- + os.Remove(ppmPath) + if err := QMPScreendump(qmpSock, ppmPath); err == nil { + frame++ + pngPath := ScreenshotPath(resultsDir, ScreenSourceQMP, time.Now(), + "final", frame, frame, "png") + ConvertPPMtoPNG(ppmPath, pngPath) + } + + // --- Collect guest logs --- + dumpSerialLog(t, serialLog, resultsDir) + + for _, l := range winpe.CollectGuestLogs(answerImg) { + if l.Err != nil { + t.Logf("%s: %v", l.Name, l.Err) + continue + } + writeArtifact(t, resultsDir, l.Name, string(l.Content)) + t.Logf("%s: %d bytes saved", l.Name, len(l.Content)) + } + + // --- Bootstrap assertions --- + if transcript, err := readGuestLog(answerImg, BootstrapLogName); err == nil { + steps := winpe.ParseBootstrapSteps(transcript) + t.Logf("bootstrap: %d ok, %d failed, %d unfinished", len(steps.OK), len(steps.Failed), len(steps.Unfinished)) + assert.Empty(t, steps.Failed, "bootstrap steps failed in the guest") + assert.Empty(t, steps.Unfinished, "bootstrap steps started but never reported — the guest died mid-step") + assert.True(t, steps.SSHReady(), + "bootstrap never installed and started sshd; ok steps: %v", steps.OK) + } else { + t.Errorf("no bootstrap transcript on the answer volume: %v", err) + } + + require.True(t, sshReady, "SSH never became available — install did not complete; artifacts in %s", resultsDir) + + // --- Post-SSH: Hyper-V installed assertion --- + hypervScript := ` +$feature = Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V +if ($feature.State -ne 'Enabled') { + Write-Output "FAIL: Hyper-V state is $($feature.State)" + exit 1 +} +Write-Output "OK: Hyper-V state is Enabled" + +$svc = Get-Service vmcompute -ErrorAction SilentlyContinue +if (-not $svc) { + Write-Output "FAIL: vmcompute service not found" + exit 1 +} +Write-Output "OK: vmcompute service exists (status: $($svc.Status))" +` + hypervArgv := BuildSSHExecArgv(spec.SSHHost, spec.SSHPort, spec.SSHUser, privKey, PowerShellEncodedCommand(hypervScript)) + t.Logf("Hyper-V check: %s", strings.Join(hypervArgv, " ")) + hypervOut, hypervErr := exec.Command(hypervArgv[0], hypervArgv[1:]...).CombinedOutput() + t.Logf("Hyper-V check output:\n%s", hypervOut) + assert.NoError(t, hypervErr, "Hyper-V post-install check failed") + assert.Contains(t, string(hypervOut), "OK: Hyper-V state is Enabled") + assert.Contains(t, string(hypervOut), "OK: vmcompute service exists") + }) + } +} + +// buildAndVerifyDevcellWim boots a WinPE builder VM, runs DISM offline +// servicing against boot.wim, and verifies the output devcell.wim contains +// Hyper-V, WSL2, OpenSSH, and VirtIO drivers — all before the install phase +// starts. Returns the path to the verified devcell.wim. +func buildAndVerifyDevcellWim(t *testing.T, qemuBin, fwPath, winISO, virtioISO, accel, tmpDir, resultsDir string) string { + t.Helper() + wimDir := filepath.Join(tmpDir, "wim-builder") + require.NoError(t, os.MkdirAll(wimDir, 0755)) + + // ── 1. Extract boot.wim and EFI boot files ── + stageDir := filepath.Join(wimDir, "stage") + require.NoError(t, ExtractWinPEStage(winISO, stageDir)) + + // ── 2. Extract vioserial drivers ── + vioserialDrivers, err := winpe.LoadWinPEVioserialDrivers(virtioISO) + require.NoError(t, err) + + // ── 3. Create shared FAT volume with boot.wim + builder script ── + bootWimPath := filepath.Join(stageDir, "sources", "boot.wim") + bootWimData, err := os.ReadFile(bootWimPath) + require.NoError(t, err) + t.Logf("boot.wim: %d bytes (%.1f MB)", len(bootWimData), float64(len(bootWimData))/(1024*1024)) + + var ops []WimPrepOp + ops = append(ops, HyperVPrepOps()...) + ops = append(ops, WSL2PrepOps()...) + ops = append(ops, OpenSSHPrepOps()...) + ops = append(ops, VirtIODriverPrepOps()...) + prepCfg := WimPrepConfig{Ops: ops} + var efiBootLoader []byte + if bl, err := winpe.InstallerBootloader(winISO); err == nil { + if _, err := winpe.ValidateBootloaderPE(bl); err == nil { + efiBootLoader = bl + t.Logf("BOOTAA64.EFI: %d bytes", len(bl)) + } + } + sharedFiles := winpe.SharedVolumeFiles(prepCfg, efiBootLoader, pwshFiles) + sharedFiles["/boot.wim"] = bootWimData + + sharedImg := filepath.Join(wimDir, "shared.qcow2") + require.NoError(t, CreateFATQcow2(sharedImg, sharedFiles, 20*1024*1024*1024)) + t.Logf("shared volume: %s", sharedImg) + + // ── 4. Inject agent into boot.wim ── + injectDir := filepath.Join(wimDir, "inject") + require.NoError(t, os.MkdirAll(injectDir, 0755)) + + for answerPath, data := range vioserialDrivers { + hostPath := filepath.Join(injectDir, filepath.FromSlash(answerPath)) + require.NoError(t, os.MkdirAll(filepath.Dir(hostPath), 0755)) + require.NoError(t, os.WriteFile(hostPath, data, 0644)) + } + + payloadCfg := WinPEPayloadConfig{ + WPEInit: true, + ProgressPort: `\\.\Global\` + ProgressPortName, + PollSeconds: 5, + SyncAgent: true, + } + if len(vioserialDrivers) > 0 { + payloadCfg.DriverINFs = []string{`X:\devcell\drivers\vioserial\vioser.inf`} + } + + require.NoError(t, os.WriteFile( + filepath.Join(injectDir, "winpeshl.ini"), + GenerateWinPEShellINI_NoSetup(), 0644)) + require.NoError(t, os.WriteFile( + filepath.Join(injectDir, "bootstrap.cmd"), + GenerateWinPEBootstrapCmd(), 0644)) + require.NoError(t, os.WriteFile( + filepath.Join(injectDir, "bootstrap.ps1"), + GenerateWinPEBootstrap(payloadCfg), 0644)) + require.NoError(t, os.WriteFile( + filepath.Join(injectDir, "agent.ps1"), + GenerateWinPEAgent(payloadCfg), 0644)) + + require.NoError(t, InjectWinPEPayload(bootWimPath, injectDir)) + + // ── 5. Create WinPE ISO ── + winpeISO := filepath.Join(wimDir, "winpe-builder.iso") + require.NoError(t, isokit.CreateWindowsISO(winpeISO, stageDir, "WINPE")) + + // ── 6. Build QEMU command ── + scratchDisk := filepath.Join(wimDir, "scratch.qcow2") + out, err := exec.Command(qemuBin+"-img", "create", "-f", "qcow2", scratchDisk, "4G").CombinedOutput() + if err != nil { + out, err = exec.Command("qemu-img", "create", "-f", "qcow2", scratchDisk, "4G").CombinedOutput() + } + require.NoError(t, err, "qemu-img create: %s", out) + + fwInfo, err := os.Stat(fwPath) + require.NoError(t, err) + kernelMode := fwInfo.Size() < 64*1024*1024 + + var varsPath string + if !kernelMode { + varsPath = filepath.Join(wimDir, "vars.fd") + require.NoError(t, PrepareVarsFile(fwPath, varsPath)) + } + + wimSerialLog := filepath.Join(resultsDir, "wim-serial.log") + wimProgressLog := filepath.Join(resultsDir, "wim-guest-progress.log") + spec := Spec{ + VMName: "wim-builder-test", + CPUs: 4, + MemoryGB: 4, + DiskPath: scratchDisk, + FirmwarePath: fwPath, + VarsPath: varsPath, + FirmwareKernel: kernelMode, + QMPSocketDir: wimDir, + DisplayType: "none", + Accel: accel, + MachineType: "virt", + SerialLogPath: wimSerialLog, + GuestProgressLogPath: wimProgressLog, + NoReboot: true, + } + spec.ApplyDefaults() + require.NoError(t, spec.Validate()) + + qmpSock := QMPSocketPath(spec) + + wbs := WimBuilderSpec{ + Spec: spec, + WinPEISO: winpeISO, + SharedImg: sharedImg, + WindowsISO: winISO, + VirtIOISO: virtioISO, + } + argv := BuildWimBuilderArgv(wbs) + argv[0] = qemuBin + updateRunJSON(t, resultsDir, map[string]any{ + "test": t.Name(), "qemu-args": strings.Join(argv, " "), + }) + + require.NoError(t, EnsureScreenshotDir(resultsDir, ScreenSourceQMP)) + + // ── 7. Boot and poll ── + exclusiveQEMU(t) + cmd := exec.Command(argv[0], argv[1:]...) + wimLog := qemuOutput(t, resultsDir, argv) + cmd.Stdout = wimLog + cmd.Stderr = wimLog + require.NoError(t, cmd.Start(), "starting WIM builder QEMU") + defer func() { + cmd.Process.Kill() + cmd.Wait() + }() + + waitForSocket(t, qmpSock, 30*time.Second, wimLog) + assertAccel(t, qmpSock, strings.SplitN(accel, ",", 2)[0], resultsDir) + + stop := make(chan struct{}) + defer close(stop) + efiShellCh := WatchSerialForEFIShell(wimSerialLog, stop) + syncExCh := WatchSerialForSyncException(wimSerialLog, stop) + + const ( + wimOverallDeadline = 15 * time.Minute + wimPollInterval = 15 * time.Second + wimStallBudget = 90 * time.Second + ) + wimStallLimit := StallPollsFor(int(wimStallBudget.Seconds()), int(wimPollInterval.Seconds())) + var wimStall StallTracker + + ppmPath := filepath.Join(wimDir, "screen.ppm") + start := time.Now() + frame := 0 + for time.Since(start) < wimOverallDeadline { + time.Sleep(wimPollInterval) + frame++ + + var pollHash uint64 + var pollRead int64 + var pollPC string + + os.Remove(ppmPath) + if err := QMPScreendump(qmpSock, ppmPath); err == nil { + if ppmData, err := os.ReadFile(ppmPath); err == nil { + h := fnv.New64a() + h.Write(ppmData) + pollHash = h.Sum64() + } + pngPath := ScreenshotPath(resultsDir, ScreenSourceQMP, time.Now(), + "none", frame, frame, "png") + if err := ConvertPPMtoPNG(ppmPath, pngPath); err == nil { + t.Logf("[wim frame %d] saved %s", frame, filepath.Base(pngPath)) + } + } + + if stats, err := QMPBlockStats(qmpSock); err == nil { + for _, s := range stats { + pollRead += s.ReadBytes + } + } + if regs, err := QMPHumanMonitor(qmpSock, "info registers"); err == nil { + pollPC = ExtractRegister(regs, "PC=") + } + + n := wimStall.Observe(StallSignal{ScreenHash: pollHash, ReadBytes: pollRead, PC: pollPC}) + t.Logf("[wim frame %d] hash=%016x rd=%d PC=%s stall=%d/%d", + frame, pollHash, pollRead, pollPC, n, wimStallLimit) + + if wimStall.Stalled(wimStallLimit) { + dumpSerialLog(t, wimSerialLog, resultsDir) + t.Fatalf("WIM builder stalled: screen, disk IO, and PC unchanged for %d consecutive polls", + wimStall.Consecutive()) + } + + select { + case reason := <-syncExCh: + dumpSerialLog(t, wimSerialLog, resultsDir) + t.Fatalf("Synchronous Exception during WIM builder: %s", reason) + case reason := <-efiShellCh: + dumpSerialLog(t, wimSerialLog, resultsDir) + t.Fatalf("firmware dropped to EFI shell during WIM builder: %s", reason) + default: + } + + doneMarker := readAnswerVolumeFile(t, sharedImg, "/"+WimBuilderDoneFile) + if doneMarker != "" { + t.Logf("WIM builder done: %q (after %s, %d frames)", + strings.TrimSpace(doneMarker), time.Since(start).Round(time.Second), frame) + break + } + } + + // ── 8. Capture results ── + cmd.Process.Kill() + cmd.Wait() + + agentOut := readAnswerVolumeFile(t, sharedImg, "/"+AgentResultFile) + t.Logf("=== WIM builder output ===\n%s", agentOut) + os.WriteFile(filepath.Join(resultsDir, "wim-builder-output.txt"), []byte(agentOut), 0644) + dumpSerialLog(t, wimSerialLog, resultsDir) + + // ══════════════════════════════════════════════════════════ + // WIM builder assertions + // ══════════════════════════════════════════════════════════ + + doneMarker := readAnswerVolumeFile(t, sharedImg, "/"+WimBuilderDoneFile) + require.NotEmpty(t, doneMarker, "WIM builder never completed") + + assert.Contains(t, agentOut, "DEVCELL WIM BUILDER", + "builder script header must appear in output") + assert.Contains(t, agentOut, "Found Windows ISO", + "builder must find install.wim on the Windows ISO drive") + assert.Contains(t, agentOut, "Found virtio-win ISO", + "builder must find virtio-win drivers ISO") + assert.Contains(t, agentOut, "Mounting boot.wim", + "builder must attempt to mount boot.wim") + + result := strings.TrimSpace(doneMarker) + t.Logf("WIM builder result: %s", result) + require.Equal(t, "SUCCESS", result, + "DISM offline servicing must succeed in WinPE — cannot install from a broken devcell.wim") + + assert.Contains(t, agentOut, "boot.wim committed successfully") + assert.Contains(t, agentOut, "devcell.wim created") + + // ── 9. Verify devcell.wim contents with wimlib ── + devcellWimData, err := ReadFileFromFATQcow2(sharedImg, "/devcell.wim") + require.NoError(t, err, "reading devcell.wim from shared volume") + require.NotEmpty(t, devcellWimData, "devcell.wim is empty") + t.Logf("devcell.wim: %d bytes (%.1f MB)", len(devcellWimData), float64(len(devcellWimData))/(1024*1024)) + + devcellWimPath := filepath.Join(resultsDir, "devcell.wim") + require.NoError(t, os.WriteFile(devcellWimPath, devcellWimData, 0644)) + + wim, err := wimlib.OpenWIM(devcellWimPath) + require.NoError(t, err, "opening devcell.wim") + defer wim.Close() + + count, err := wim.ImageCount() + require.NoError(t, err) + require.GreaterOrEqual(t, count, 2, "devcell.wim must still have at least 2 images") + + extractDir := filepath.Join(wimDir, "devcell-extracted") + require.NoError(t, os.MkdirAll(extractDir, 0755)) + require.NoError(t, wim.ExtractImage(2, extractDir, nil)) + + // ── 9a. Hyper-V: DISM output, binaries, and registry ── + assert.Contains(t, agentOut, "OK: Enable-Feature Microsoft-Hyper-V", + "DISM must report Hyper-V feature enabled") + assert.Contains(t, agentOut, "OK: Enable-Feature VirtualMachinePlatform", + "DISM must report VirtualMachinePlatform feature enabled") + + for _, f := range []string{ + "Windows/System32/vmms.exe", + "Windows/System32/vmwp.exe", + "Windows/System32/vmcompute.exe", + "Windows/System32/drivers/Vid.sys", + "Windows/System32/drivers/vmswitch.sys", + "Windows/System32/drivers/storvsp.sys", + } { + fullPath := filepath.Join(extractDir, filepath.FromSlash(f)) + if info, err := os.Stat(fullPath); err == nil { + t.Logf(" Hyper-V OK: %s (%d bytes)", f, info.Size()) + } else { + t.Errorf(" Hyper-V MISSING: %s", f) + } + } + + // ── 9b. WSL2 ── + assert.Contains(t, agentOut, "OK: Enable-Feature Microsoft-Windows-Subsystem-Linux", + "DISM must report WSL feature enabled") + + // ── 9c. OpenSSH ── + for _, f := range []string{ + "Windows/System32/OpenSSH/sshd.exe", + "Windows/System32/OpenSSH/ssh.exe", + "Windows/System32/OpenSSH/ssh-keygen.exe", + } { + fullPath := filepath.Join(extractDir, filepath.FromSlash(f)) + if info, err := os.Stat(fullPath); err == nil { + t.Logf(" OpenSSH OK: %s (%d bytes)", f, info.Size()) + } else { + t.Errorf(" OpenSSH MISSING: %s", f) + } + } + + // ── 9d. VirtIO drivers ── + assert.Contains(t, agentOut, `OK: Add-Driver NetKVM\w11\ARM64`, + "DISM must report NetKVM driver added") + assert.Contains(t, agentOut, `OK: Add-Driver vioserial\w11\ARM64`, + "DISM must report vioserial driver added") + assert.Contains(t, agentOut, `OK: Add-Driver vioscsi\w11\ARM64`, + "DISM must report vioscsi driver added") + + driverStoreDir := filepath.Join(extractDir, "Windows", "System32", "DriverStore", "FileRepository") + for _, drv := range []struct { + name string + sys string + }{ + {"NetKVM", "netkvm.sys"}, + {"vioserial", "vioser.sys"}, + {"vioscsi", "vioscsi.sys"}, + } { + matches, _ := filepath.Glob(filepath.Join(driverStoreDir, "*", drv.sys)) + if len(matches) > 0 { + info, _ := os.Stat(matches[0]) + t.Logf(" VirtIO OK: %s → %s (%d bytes)", drv.name, filepath.Base(filepath.Dir(matches[0])), info.Size()) + } else { + t.Errorf(" VirtIO MISSING: %s (%s not found in DriverStore/FileRepository)", drv.name, drv.sys) + } + } + + // ── 10. Apply registry patches, then verify ── + // PatchDevcellWim sets correct Start values for Hyper-V services — + // DISM creates them with Start=3/4, the patch sets Start=0 (boot). + // Verify AFTER patching: that's the devcell.wim Windows boots from. + wim.Close() + if err := PatchDevcellWim(devcellWimPath, 2, HyperVBootPatches()); err != nil { + t.Logf("post-DISM registry patching failed: %v", err) + } else { + t.Log(" Hyper-V boot patches applied to devcell.wim") + } + + wim2, err := wimlib.OpenWIM(devcellWimPath) + require.NoError(t, err, "reopening devcell.wim after patching") + defer wim2.Close() + + if err := VerifyWimRegistry(wim2, 2, `\Windows\System32\config\SYSTEM`, HyperVBootChecks()); err != nil { + t.Errorf("Hyper-V registry verification failed (after patching): %v", err) + } else { + t.Log(" Hyper-V registry boot patches verified") + } + + t.Logf("WIM builder phase complete — devcell.wim verified at %s", devcellWimPath) + return devcellWimPath +} + +func findFreePort(t *testing.T) uint16 { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + port := l.Addr().(*net.TCPAddr).Port + l.Close() + return uint16(port) +} + +// probeSSH checks whether an SSH server is listening: connects and reads the +// banner line (e.g. "SSH-2.0-OpenSSH_9.8"). A bare TCP accept (QEMU's +// hostfwd) is not enough — the guest's sshd must be answering. +func probeSSH(host string, port uint16) bool { + addr := net.JoinHostPort(host, fmt.Sprintf("%d", port)) + conn, err := (&net.Dialer{Timeout: 3 * time.Second}).Dial("tcp", addr) + if err != nil { + return false + } + defer conn.Close() + conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + buf := make([]byte, 64) + n, err := conn.Read(buf) + if err != nil || n < 4 { + return false + } + return strings.HasPrefix(string(buf[:n]), "SSH-") +} diff --git a/internal/vm/qemu/cachedir_test.go b/internal/vm/qemu/cachedir_test.go new file mode 100644 index 0000000..e70a5ba --- /dev/null +++ b/internal/vm/qemu/cachedir_test.go @@ -0,0 +1,94 @@ +package qemu + +import ( + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// Inside a cell, $HOME is itself a per-cell directory, so the cache renders as +// ~/.devcell//.devcell/cache/qemu and every cell re-downloads the same +// 6 GB of immutable media. There is no way to reach the real host home from +// inside the container, so the cache location has to be pointable. +func TestCacheDir_HonoursAnExplicitOverride(t *testing.T) { + shared := t.TempDir() + t.Setenv("DEVCELL_QEMU_CACHE_DIR", shared) + + require.Equal(t, shared, CacheDir("/home/anyone")) + require.Equal(t, filepath.Join(shared, "virtio-win.iso"), VirtioISOPath("/home/anyone")) +} + +func TestCacheDir_DefaultsUnderHome(t *testing.T) { + t.Setenv("DEVCELL_QEMU_CACHE_DIR", "") + + require.Equal(t, filepath.Join("/home/x", ".devcell", "cache", "qemu"), CacheDir("/home/x")) +} + +// A download must never write through to a file it did not create. +// +// This is not hypothetical: seeding a test cache by hard-linking the host's +// ISOs, then letting the downloader treat the unmarked file as a partial, +// truncated the real 789 MB virtio-win.iso to a 300 MB stub on 2026-07-31. +// Writing to a temp file and renaming makes that impossible — rename replaces +// the directory entry instead of writing through the shared inode — and has the +// second benefit that a killed download can never leave a half-file that looks +// complete. +func TestDownloadFile_DoesNotWriteThroughToAHardLink(t *testing.T) { + // Deliberately NOT t.TempDir(): TMPDIR points at the lima bind mount, whose + // hard-link semantics are broken. Replacing one link via rename there is + // observable through the *other* link even though the inodes differ — + // verified across filesystems: + // + // /tmp, /var/tmp, /dev/shm a="AAAA" b="BBBB" correct + // /home/dmitry/tmp (bind) a="BBBB" b="BBBB" wrong + // + // Testing link isolation on that mount measures the filesystem, not this + // code. (Worth knowing separately: anything in this repo relying on hard + // links across that mount — cache seeding, disk promotion — is on sand.) + dir, err := os.MkdirTemp("/tmp", "downloadfile") + if err != nil { + t.Skipf("no local filesystem for a hard-link test: %v", err) + } + t.Cleanup(func() { os.RemoveAll(dir) }) + original := filepath.Join(dir, "original.iso") + require.NoError(t, os.WriteFile(original, []byte(strings.Repeat("original payload", 64)), 0o644)) + link := filepath.Join(dir, "linked.iso") + require.NoError(t, os.Link(original, link)) + + srv := staticFileServer(t, "replacement") + require.NoError(t, downloadFile(t.Context(), srv, link, discardObserver{})) + + // The download landed... + got, err := os.ReadFile(link) + require.NoError(t, err) + require.Equal(t, "replacement", string(got)) + + // ...without touching the file it was linked to. + untouched, err := os.ReadFile(original) + require.NoError(t, err) + require.Equal(t, strings.Repeat("original payload", 64), string(untouched), + "the original must survive: a download may not write through a shared inode") +} + +// staticFileServer serves body at a URL and returns that URL. +func staticFileServer(t *testing.T, body string) string { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Length", strconv.Itoa(len(body))) + _, _ = io.WriteString(w, body) + })) + t.Cleanup(srv.Close) + return srv.URL +} + +type discardObserver struct{} + +func (discardObserver) Logf(string, ...any) {} +func (discardObserver) Progress(float64, string) {} diff --git a/internal/vm/qemu/cellbuild_test.go b/internal/vm/qemu/cellbuild_test.go new file mode 100644 index 0000000..9557841 --- /dev/null +++ b/internal/vm/qemu/cellbuild_test.go @@ -0,0 +1,453 @@ +package qemu + +import ( + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/devcell-sh/go-winkit/unattend" + "github.com/devcell-sh/go-winkit/winpe" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// errTimedOut marks a CLI invocation the test killed rather than one the CLI +// itself failed — the distinction matters when reading the failure. +var errTimedOut = errors.New("cell command exceeded the test's own timeout") + +// TestCellBuildWindows_QEMU drives the real CLI end to end: +// +// setup — `cell init --engine=qemu` scaffolds config and SSH keys +// test — `cell build --engine=qemu --debug` installs Windows +// winddown — destroy the VM (deliberately disabled, see below) +// +// Subtests by accelerator mirror TestWinPECDVisibility: +// +// go test -run TestCellBuildWindows_QEMU/tcg -timeout 8h -v ./internal/vm/qemu/ +// go test -run TestCellBuildWindows_QEMU/hvf -timeout 8h -v ./internal/vm/qemu/ +// +// Long test — a TCG install ran 2h42m on this host. Run explicitly: +// +// DEVCELL_TEST_INSTALL=1 go test -run TestCellBuildWindows_QEMU -timeout 8h -v ./internal/vm/qemu/ +func TestCellBuildWindows_QEMU(t *testing.T) { + if testing.Short() { + t.Skip("long: full unattended Windows install driven through the CLI") + } + if os.Getenv("DEVCELL_TEST_INSTALL") == "" { + t.Skip("set DEVCELL_TEST_INSTALL=1 to run the multi-hour unattended install") + } + + requireQEMUBin(t) + isoPath := requireWindowsISO(t) + + // A HOME of the test's own — `cell init` writes keys under $HOME/.devcell + // and `cell build` puts the template under $HOME/.devcell/windows/, + // so a real HOME would mean competing with the user's own cells — but a + // *stable* one, not t.TempDir(). A temp HOME is deleted when the test ends, + // which silently threw away a 16GB template and made every run pay the full + // 2h47m install again, winddown or no winddown. + home := filepath.Join(repoRoot(t), "test", "testdata", "cellhome") + require.NoError(t, os.MkdirAll(home, 0o755)) + cellBin := buildCellCLI(t) + + seedMediaCache(t, home, requireVirtioISO(t), isoPath) + + // `cell init` is accel-independent (scaffolds SSH keys + config). + // Run once at the top level so subtests share the same keys. + initProjectDir := t.TempDir() + initResultsDir := testResultsDir(t) + initEnv := append(os.Environ(), + "HOME="+home, + "DEVCELL_CELL_NAME=main", + "DEVCELL_QEMU_WINDOWS_ISO="+isoPath, + ) + setupOut := runCellCommand(t, cellBin, initProjectDir, initResultsDir, initEnv, 90*time.Minute, + "init", "--engine=qemu") + writeArtifact(t, initResultsDir, "cell-init.log", setupOut) + require.FileExists(t, filepath.Join(home, ".devcell", "main", "qemu", "id_ed25519"), + "`cell init --engine=qemu` must leave an SSH key for the build to bake into the guest") + + for _, accel := range []string{"tcg", "hvf"} { + t.Run(accel, func(t *testing.T) { + if accel == "hvf" && runtime.GOOS != "darwin" { + t.Skip("hvf requires macOS") + } + + projectDir := t.TempDir() + resultsDir := testResultsDir(t) + + qemuAccel := "tcg,thread=multi" + if accel == "hvf" { + qemuAccel = "hvf" + } + + env := append(os.Environ(), + "HOME="+home, + "DEVCELL_CELL_NAME=main", + "DEVCELL_QEMU_WINDOWS_ISO="+isoPath, + "DEVCELL_QEMU_ACCEL="+qemuAccel, + ) + + templateDir := TemplateDir(home, "base", nil) + answerImg := filepath.Join(templateDir, "autounattend.img") + marker := ProvisionedMarker(home, "base", nil) + templateDisk := filepath.Join(templateDir, ImageName("base", nil)) + + if _, err := os.Stat(marker); err == nil && os.Getenv("DEVCELL_TEST_REBUILD") == "" { + t.Logf("template already provisioned (%s) — set DEVCELL_TEST_REBUILD=1 to reinstall", marker) + info, statErr := os.Stat(templateDisk) + require.NoError(t, statErr, "provisioned marker without a template disk") + require.Greater(t, info.Size(), int64(1<<30), + "a provisioned template must hold an installed Windows, got %d bytes", info.Size()) + return + } + + buildArgs := []string{"build", "--engine=qemu", "--debug"} + if _, err := os.Stat(templateDisk); err == nil { + t.Log("template from an earlier run — rebuilding with --force") + buildArgs = append(buildArgs, "--force") + } + + buildDone := make(chan buildResult, 1) + go func() { + out := runCellCommandNoFail(t, cellBin, projectDir, resultsDir, env, 8*time.Hour, buildArgs...) + buildDone <- out + }() + + qmpSock := QMPSocketPath(Spec{VMName: "devcell-qemu-build", QMPSocketDir: templateDir}) + stopWatch := make(chan struct{}) + watchDone := make(chan struct{}) + go func() { + defer close(watchDone) + watchGuest(t, qmpSock, resultsDir, stopWatch) + }() + + result := <-buildDone + close(stopWatch) + <-watchDone + + writeArtifact(t, resultsDir, "cell-build.log", result.output) + + if ports := extractLine(result.output, "Ports:"); ports != "" { + writeArtifact(t, resultsDir, "ports.txt", ports+"\n") + t.Log(ports) + } else { + t.Error("the build must report the ports it allocated") + } + + for _, name := range []string{"serial.log", "guest-progress.log"} { + src := filepath.Join(projectDir, ".context", "debug", name) + data, err := os.ReadFile(src) + if err != nil { + t.Logf("%s: not captured (%v)", name, err) + continue + } + writeArtifact(t, resultsDir, name, string(data)) + t.Logf("%s: %d bytes saved", name, len(data)) + } + + progressSrc := filepath.Join(projectDir, ".context", "debug", "guest-progress.log") + if progressData, err := os.ReadFile(progressSrc); err == nil { + assert.Contains(t, string(progressData), "devcell-bootstrap:", + "guest-progress.log must contain bootstrap progress lines — "+ + "if empty, the vioserial driver is not installed or Send-Progress is broken (CELL-436)") + } + + serialSrc := filepath.Join(projectDir, ".context", "debug", "serial.log") + if serialData, err := os.ReadFile(serialSrc); err == nil { + assert.Greater(t, len(serialData), 0, + "serial.log must not be empty — UEFI firmware writes to it from the first instruction") + assert.Contains(t, string(serialData), "BdsDxe:", + "serial.log must contain firmware boot manager output") + } + + for _, l := range winpe.CollectGuestLogs(answerImg) { + if l.Err != nil { + t.Logf("%s: %v", l.Name, l.Err) + continue + } + writeArtifact(t, resultsDir, l.Name, string(l.Content)) + t.Logf("%s: %d bytes saved", l.Name, len(l.Content)) + } + if _, err := os.Stat(qemuImgTool(t)); err == nil { + collectPantherLogs(t, qemuImgToolBase(t), filepath.Join(templateDir, ImageName("base", nil)), resultsDir) + } + + if transcript, err := readGuestLog(answerImg, unattend.BootstrapLogName); err == nil { + steps := winpe.ParseBootstrapSteps(transcript) + t.Logf("bootstrap: %d ok, %d failed, %d unfinished", len(steps.OK), len(steps.Failed), len(steps.Unfinished)) + require.Empty(t, steps.Failed, "bootstrap steps failed in the guest") + require.Empty(t, steps.Unfinished, "bootstrap steps started but never reported — the guest died mid-step") + require.True(t, steps.SSHReady(), + "bootstrap never got sshd installed and started; ok steps: %v", steps.OK) + } else { + t.Errorf("no bootstrap transcript on the answer volume: %v", err) + } + + require.NoError(t, result.err, + "`cell build --engine=qemu --debug` failed — guest logs above and artifacts in %s", resultsDir) + require.FileExists(t, ProvisionedMarker(home, "base", nil), + "a successful build must stamp the provisioned marker") + }) + } +} + +// --- helpers --------------------------------------------------------------- + +type buildResult struct { + output string + err error +} + +// buildCellCLI compiles the CLI under test. Building it rather than assuming an +// installed `cell` guarantees the binary matches the working tree. +func buildCellCLI(t *testing.T) string { + t.Helper() + bin := filepath.Join(t.TempDir(), "cell") + cmd := exec.Command("go", "build", "-o", bin, "./cmd") + cmd.Dir = repoRoot(t) + out, err := cmd.CombinedOutput() + require.NoError(t, err, "building the cell CLI: %s", out) + return bin +} + +func runCellCommand(t *testing.T, bin, dir, resultsDir string, env []string, timeout time.Duration, args ...string) string { + t.Helper() + r := runCellCommandNoFail(t, bin, dir, resultsDir, env, timeout, args...) + require.NoError(t, r.err, "cell %s failed:\n%s", strings.Join(args, " "), r.output) + return r.output +} + +// teeToFile returns a writer that mirrors everything into path and into mem. +// +// The file is what makes a running build observable: `tail -f` it while the +// install grinds, instead of waiting hours for the process to exit and only +// then learning why it failed. +func teeToFile(path string, mem io.Writer) (io.Writer, func() error, error) { + f, err := os.Create(path) + if err != nil { + return nil, nil, fmt.Errorf("opening live log %s: %w", path, err) + } + return io.MultiWriter(f, mem), f.Close, nil +} + +func runCellCommandNoFail(t *testing.T, bin, dir, resultsDir string, env []string, timeout time.Duration, args ...string) buildResult { + t.Helper() + cmd := exec.Command(bin, args...) + cmd.Dir = dir + cmd.Env = env + + // Stream to disk as it happens. Buffering with CombinedOutput meant a + // multi-hour build revealed nothing until it exited — the single biggest + // diagnostic gap of 2026-07-31. + var mem strings.Builder + live := liveLogPath(resultsDir, args) + w, closeLog, err := teeToFile(live, io.MultiWriter(&mem, os.Stdout)) + if err != nil { + t.Logf("live log unavailable (%v) — falling back to buffered output", err) + w, closeLog = io.MultiWriter(&mem, os.Stdout), func() error { return nil } + } else { + t.Logf("live log: tail -f %s", live) + } + cmd.Stdout, cmd.Stderr = w, w + + done := make(chan buildResult, 1) + go func() { + runErr := cmd.Run() + _ = closeLog() + done <- buildResult{output: mem.String(), err: runErr} + }() + select { + case r := <-done: + return r + case <-time.After(timeout): + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + // Keep whatever was streamed: a killed command's output is exactly + // what explains why it had to be killed. + return buildResult{output: mem.String(), err: errTimedOut} + } +} + +// watchGuest polls the CLI's QMP socket for screenshots and block-io stats. +// The socket only exists once the CLI has launched QEMU, so a missing socket +// early on is normal rather than a failure. +func watchGuest(t *testing.T, qmpSock, resultsDir string, stop <-chan struct{}) { + const pollInterval = 60 * time.Second + ppmPath := filepath.Join(t.TempDir(), "screen.ppm") + prevStats := map[string]BlockDeviceStats{} + attempt := 0 + for { + select { + case <-stop: + return + case <-time.After(pollInterval): + } + if _, err := os.Stat(qmpSock); err != nil { + continue + } + attempt++ + logInstallProgress(t, attempt, qmpSock, ppmPath, resultsDir, &prevStats) + } +} + +// seedMediaCache links already-downloaded media into a temp HOME's cache, so a +// test HOME does not mean a fresh download. +func seedMediaCache(t *testing.T, home, virtioISO, windowsISO string) { + t.Helper() + require.NoError(t, os.MkdirAll(CacheDir(home), 0o755)) + seedCachedFile(t, virtioISO, VirtioISOPath(home)) + seedCachedFile(t, windowsISO, WindowsISOPath(home, "en-us")) +} + +// seedCachedFile links src to dest and writes the .done marker beside it. +// +// The marker is not optional. The downloader treats a bare file as a partial +// download and re-fetches it — and because dest is a hard link, that download +// writes through to the shared inode and truncates the host's real cached ISO. +// That happened: a 789MB virtio-win.iso came back as a 300MB stub. The marker +// makes it a cache hit, so nothing ever opens the file for writing. +// +// The corollary is that no `cell` invocation here may pass a flag that clears +// the marker — `cell init --force` maps force onto noCache and would re-download +// straight through the link. +func seedCachedFile(t *testing.T, src, dest string) { + t.Helper() + needsLink := true + if fi, err := os.Lstat(dest); err == nil { + if fi.Mode()&os.ModeSymlink != 0 { + // Broken symlink (stale nix store path) — replace it. + // Stat follows the symlink; Lstat doesn't. A broken symlink + // makes Stat fail while the inode still blocks Symlink. + if _, statErr := os.Stat(dest); statErr != nil { + os.Remove(dest) + } else { + needsLink = false + } + } else { + needsLink = false + } + } + if needsLink { + if linkErr := os.Link(src, dest); linkErr != nil { + require.NoError(t, os.Symlink(src, dest)) + } + } + require.NoError(t, os.WriteFile(dest+".done", nil, 0o644)) +} + +func writeArtifact(t *testing.T, dir, name, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil { + t.Logf("saving %s: %v", name, err) + } +} + +func qemuImgToolBase(t *testing.T) string { return requireQEMUBin(t) } +func qemuImgTool(t *testing.T) string { return requireQEMUBin(t) + "-img" } + +// extractLine returns the first line containing marker, trimmed. Used to lift +// facts the CLI reports (ports, accelerator) out of its output so they can be +// stored beside the run they belong to. +func extractLine(out, marker string) string { + for _, line := range strings.Split(out, "\n") { + if strings.Contains(line, marker) { + return strings.TrimSpace(line) + } + } + return "" +} + +// readGuestLog pulls one log off the answer volume by name. +func readGuestLog(answerImg, name string) (string, error) { + for _, l := range winpe.CollectGuestLogs(answerImg) { + if l.Name == name { + if l.Err != nil { + return "", l.Err + } + return string(l.Content), nil + } + } + return "", winpe.ErrNoSuchGuestLog +} + +// A three-hour build that reveals nothing until it exits is how a failure went +// undiagnosed for most of 2026-07-31: the CLI's output was buffered by +// CombinedOutput, the guest's FAT transcript had not flushed since 07:29, and +// the only live view was a screenshot. The log has to be on disk while the +// build runs, not after it. +func TestTeeWriter_MakesOutputReadableWhileTheCommandRuns(t *testing.T) { + path := filepath.Join(t.TempDir(), "live.log") + var buf strings.Builder + + w, closeFn, err := teeToFile(path, &buf) + require.NoError(t, err) + + _, err = w.Write([]byte("first line\n")) + require.NoError(t, err) + + // The point of the exercise: readable now, before the command ends. + onDisk, readErr := os.ReadFile(path) + require.NoError(t, readErr, "the log must exist while the command is still running") + require.Equal(t, "first line\n", string(onDisk)) + + _, err = w.Write([]byte("second line\n")) + require.NoError(t, err) + require.NoError(t, closeFn()) + + // And the in-memory copy still holds everything, for assertions. + require.Equal(t, "first line\nsecond line\n", buf.String()) +} + +// A path that cannot be created must not silently disable the live log. +func TestTeeWriter_ReportsAnUnusablePath(t *testing.T) { + _, _, err := teeToFile(filepath.Join(t.TempDir(), "nope", "live.log"), &strings.Builder{}) + require.Error(t, err, "an unwritable log path is a setup error, not something to swallow") +} + +// liveLogPath places the live log inside the run's own results directory. +// +// It takes resultsDir rather than calling testResultsDir: that helper mints a +// fresh timestamped directory on every call, so calling it again from here +// scattered one run's artifacts across two directories a second apart. Named +// after the subcommand so init and build do not overwrite each other. +func liveLogPath(resultsDir string, args []string) string { + name := "cell.live.log" + if len(args) > 0 { + name = "cell-" + args[0] + ".live.log" + } + return filepath.Join(resultsDir, name) +} + +// testResultsDir mints a fresh timestamped directory on every call, so calling +// it twice in one test scatters that run's artifacts across two directories — +// which is exactly what happened when the live log was first wired in: the +// build's own artifacts landed in one dir and its live logs in another, a +// second apart. A run's artifacts must live together or they cannot be read +// together. +func TestLiveLogPath_StaysInTheRunsOwnResultsDir(t *testing.T) { + results := t.TempDir() + + got := liveLogPath(results, []string{"build", "--engine=qemu"}) + + require.Equal(t, filepath.Join(results, "cell-build.live.log"), got) +} + +// init and build must not overwrite each other's live log. +func TestLiveLogPath_NamesTheSubcommand(t *testing.T) { + results := t.TempDir() + + require.NotEqual(t, + liveLogPath(results, []string{"init"}), + liveLogPath(results, []string{"build"})) + require.Equal(t, filepath.Join(results, "cell.live.log"), liveLogPath(results, nil)) +} diff --git a/internal/vm/qemu/command.go b/internal/vm/qemu/command.go new file mode 100644 index 0000000..ee4e724 --- /dev/null +++ b/internal/vm/qemu/command.go @@ -0,0 +1,416 @@ +package qemu + +import ( + "fmt" + "runtime" + "strings" +) + +// InstallerCDDeviceID is the qdev id of the installer CD. QMP addresses +// devices by qdev id, not drive id, so ejecting requires this name. +const InstallerCDDeviceID = "installer-cd" + +// USBBusID names the xhci controller so every storage device can state its +// bus explicitly. Leaving the bus implicit lets QEMU pick, which is how a +// device ends up somewhere the firmware never enumerates. +const USBBusID = "usb-bus" + +// ProgressPortName is the virtio-serial port name used for guest→host +// progress reporting. The guest writes to \\.\Global\, the host +// reads from a chardev file wired to GuestProgressLogPath. +const ProgressPortName = `devcell.progress.0` + +// StructuredPortName is a second virtio-serial port for structured JSONL +// logging. The guest writes JSON lines to \\.\Global\, the host +// reads from a chardev file wired to GuestStructuredLogPath. +const StructuredPortName = `devcell.structured.0` + +// BuildInstallCommand constructs the QEMU argv for initial Windows installation. +// windowsISO and any virtioISO are attached as USB CD-ROMs. autounattendImage +// is attached as a further CD-ROM when it is an .iso, or as a removable +// usb-storage disk for a raw FAT image; Windows Setup searches both kinds of +// removable media for autounattend.xml. +// CDBusID names the virtio-scsi controller used for CD-ROM devices when +// CDBus is "scsi". Separate from the disk's NVMe controller. +const CDBusID = "cd-scsi-bus" + +func BuildInstallCommand(spec Spec, windowsISO, autounattendImage string) []string { + argv := baseCommand(spec) + + if spec.CDBus == "scsi" { + argv = appendSCSICDs(argv, spec, windowsISO, autounattendImage) + } else { + argv = appendUSBCDs(argv, spec, windowsISO, autounattendImage) + } + + if spec.DevcellWimImg != "" { + driveFormat := "raw" + if strings.HasSuffix(spec.DevcellWimImg, ".qcow2") { + driveFormat = "qcow2" + } + argv = append(argv, + "-drive", fmt.Sprintf("file=%s,format=%s,if=none,id=devcellwim0", spec.DevcellWimImg, driveFormat), + "-device", fmt.Sprintf("usb-storage,drive=devcellwim0,removable=true,bus=%s.0", USBBusID)) + } + + return argv +} + +func appendUSBCDs(argv []string, spec Spec, windowsISO, autounattendImage string) []string { + bootIdx := 1 + nextIdx := 0 + + argv = append(argv, + "-drive", fmt.Sprintf("file=%s,media=cdrom,if=none,id=cdrom0", windowsISO), + "-device", fmt.Sprintf("usb-storage,drive=cdrom0,removable=true,bus=%s.0,id=%s,bootindex=%d", + USBBusID, InstallerCDDeviceID, bootIdx)) + bootIdx++ + nextIdx = 1 + + if spec.VirtioISO != "" { + argv = append(argv, + "-drive", fmt.Sprintf("file=%s,media=cdrom,if=none,id=cdrom%d", spec.VirtioISO, nextIdx), + "-device", fmt.Sprintf("usb-storage,drive=cdrom%d,removable=true,bus=%s.0,bootindex=%d", + nextIdx, USBBusID, bootIdx)) + bootIdx++ + nextIdx++ + } + + switch { + case autounattendImage == "": + case strings.HasSuffix(autounattendImage, ".iso"): + argv = append(argv, + "-drive", fmt.Sprintf("file=%s,media=cdrom,if=none,id=cdrom%d", autounattendImage, nextIdx), + "-device", fmt.Sprintf("usb-storage,drive=cdrom%d,removable=true,bus=%s.0", nextIdx, USBBusID)) + default: + argv = append(argv, + "-drive", fmt.Sprintf("file=%s,format=raw,if=none,id=usbfat0", autounattendImage), + "-device", fmt.Sprintf("usb-storage,drive=usbfat0,removable=true,bus=%s.0,bootindex=%d", USBBusID, bootIdx)) + } + + return argv +} + +func appendSCSICDs(argv []string, spec Spec, windowsISO, autounattendImage string) []string { + bootIdx := 1 + nextIdx := 0 + + argv = append(argv, "-device", fmt.Sprintf("virtio-scsi-pci,id=%s", CDBusID)) + + argv = append(argv, + "-drive", fmt.Sprintf("file=%s,media=cdrom,if=none,id=cdrom0", windowsISO), + "-device", fmt.Sprintf("scsi-cd,drive=cdrom0,bus=%s.0,id=%s,bootindex=%d", + CDBusID, InstallerCDDeviceID, bootIdx)) + bootIdx++ + nextIdx = 1 + + if spec.VirtioISO != "" { + argv = append(argv, + "-drive", fmt.Sprintf("file=%s,media=cdrom,if=none,id=cdrom%d", spec.VirtioISO, nextIdx), + "-device", fmt.Sprintf("scsi-cd,drive=cdrom%d,bus=%s.0,bootindex=%d", + nextIdx, CDBusID, bootIdx)) + bootIdx++ + nextIdx++ + } + + // Answer volume always on usb-storage — it's a FAT image, not a CD, + // and Windows needs it as removable media. + switch { + case autounattendImage == "": + case strings.HasSuffix(autounattendImage, ".iso"): + argv = append(argv, + "-drive", fmt.Sprintf("file=%s,media=cdrom,if=none,id=cdrom%d", autounattendImage, nextIdx), + "-device", fmt.Sprintf("scsi-cd,drive=cdrom%d,bus=%s.0", nextIdx, CDBusID)) + default: + argv = append(argv, + "-drive", fmt.Sprintf("file=%s,format=raw,if=none,id=usbfat0", autounattendImage), + "-device", fmt.Sprintf("usb-storage,drive=usbfat0,removable=true,bus=%s.0,bootindex=%d", USBBusID, bootIdx)) + } + + return argv +} + +// BuildWinPECommand constructs the QEMU argv for booting WinPE from a custom +// ISO (CELL-430). Only one CD (the WinPE ISO) plus an answer volume on +// usb-storage. No Windows installer ISO, no virtio ISO. +func BuildWinPECommand(spec Spec, winpeISO, answerImage string) []string { + argv := baseCommand(spec) + + argv = append(argv, + "-drive", fmt.Sprintf("file=%s,media=cdrom,if=none,id=cdrom0", winpeISO), + "-device", fmt.Sprintf("usb-storage,drive=cdrom0,removable=true,bus=%s.0,id=%s,bootindex=1", + USBBusID, InstallerCDDeviceID)) + + if answerImage != "" { + driveFormat := "raw" + if strings.HasSuffix(answerImage, ".qcow2") { + driveFormat = "qcow2" + } + argv = append(argv, + "-drive", fmt.Sprintf("file=%s,format=%s,if=none,id=usbfat0", answerImage, driveFormat), + "-device", fmt.Sprintf("usb-storage,drive=usbfat0,removable=true,bus=%s.0", USBBusID)) + } + + return argv +} + +// BuildRunCommand constructs the QEMU argv for normal VM operation (post-install). +func BuildRunCommand(spec Spec) []string { + argv := baseCommand(spec) + + // Boot from disk (default order) + argv = append(argv, "-boot", "c") + + // Driver ISO: post-install driver work (pnputil the ARM64 INFs, run the + // guest-agent MSI) reads it from a normal running VM, not only during + // install. Same usb-storage attachment as the install — see + // BuildInstallCommand. + if spec.VirtioISO != "" { + argv = append(argv, + "-drive", fmt.Sprintf("file=%s,media=cdrom,if=none,id=cdrom1", spec.VirtioISO), + "-device", fmt.Sprintf("usb-storage,drive=cdrom1,removable=true,bus=%s.0", USBBusID)) + } + + // Guest-written log volume — see Spec.LogVolumePath. Raw FAT on + // usb-storage, removable (Windows only mounts a partition-table-less + // volume from a removable device — same constraint as the answer file). + if spec.LogVolumePath != "" { + argv = append(argv, + "-drive", fmt.Sprintf("file=%s,format=raw,if=none,id=usbfat0", spec.LogVolumePath), + "-device", "usb-storage,drive=usbfat0,removable=true") + } + + if spec.GuestAgentSocketPath != "" { + argv = append(argv, + "-chardev", fmt.Sprintf("socket,id=qga0,path=%s,server=on,wait=off", spec.GuestAgentSocketPath), + "-device", "virtserialport,bus=virtio-serial0.0,chardev=qga0,name=org.qemu.guest_agent.0") + } + + // virtio-fs — see Spec.VirtioFSSocketPath. vhost-user devices refuse to + // start without shareable guest memory, hence the memfd backend + numa + // node binding all of RAM to it. + if spec.VirtioFSSocketPath != "" && spec.VirtioFSTag != "" { + argv = append(argv, + "-chardev", fmt.Sprintf("socket,id=virtiofs0,path=%s", spec.VirtioFSSocketPath), + "-device", fmt.Sprintf("vhost-user-fs-pci,queue-size=1024,chardev=virtiofs0,tag=%s", spec.VirtioFSTag), + "-object", fmt.Sprintf("memory-backend-memfd,id=mem,size=%dG,share=on", spec.MemoryGB), + "-numa", "node,memdev=mem") + } + + return argv +} + +func baseCommand(spec Spec) []string { + qemuBin := "qemu-system-aarch64" + + machine := machineType(spec) + if spec.MachineType != "" { + machine = spec.MachineType + } + + argv := []string{ + qemuBin, + "-machine", machine, + "-cpu", cpuType(spec), + "-accel", spec.effectiveAccel(), + "-smp", fmt.Sprintf("%d", spec.CPUs), + "-m", fmt.Sprintf("%dG", spec.MemoryGB), + } + + // UEFI firmware. Two loading modes — see Spec.FirmwareKernel: pflash is + // the normal one (keeps an NVRAM vars store); -kernel is what the proven + // secure-world config uses, because QEMU's boot stub then handles the EL3 + // entry that a normal-world EDK2 cannot. + if spec.FirmwareKernel { + argv = append(argv, "-kernel", spec.FirmwarePath) + } else { + argv = append(argv, + "-drive", fmt.Sprintf("if=pflash,format=raw,readonly=on,file=%s", spec.FirmwarePath)) + if spec.VarsPath != "" { + argv = append(argv, + "-drive", fmt.Sprintf("if=pflash,format=raw,file=%s", spec.VarsPath)) + } + } + + // Main disk on NVMe: Windows ARM64 has inbox stornvme.sys but no virtio + // drivers, so a virtio disk is invisible to WinPE/Windows (CELL-359). + argv = append(argv, + "-drive", diskDriveArg(spec), + ) + argv = append(argv, diskDeviceArgs(spec)...) + + // Network with SSH port forwarding (+ optional RDP) + netdev := fmt.Sprintf("user,id=net0,hostfwd=tcp:%s:%d-:22", spec.SSHHost, spec.SSHPort) + if spec.RDPPort > 0 { + netdev += fmt.Sprintf(",hostfwd=tcp:%s:%d-:3389", spec.SSHHost, spec.RDPPort) + } + if spec.MACAddr != "" { + argv = append(argv, + "-netdev", netdev, + "-device", fmt.Sprintf("virtio-net-pci,netdev=net0,mac=%s", spec.MACAddr)) + } else { + argv = append(argv, + "-netdev", netdev, + "-device", "virtio-net-pci,netdev=net0") + } + + // Display + argv = append(argv, "-display", spec.DisplayType) + + // VNC server (independent of -display) + if spec.VNCPort > 0 { + display := int(spec.VNCPort) - 5900 + argv = append(argv, "-vnc", fmt.Sprintf("localhost:%d", display)) + } + + // Display: ramfb is the only aarch64 device with a linear framebuffer. + // virtio-gpu-pci reports FrameBufferBase=0 and Windows bootmgr dead-loops + // blitting to NULL (CELL-352 root cause, 2026-07-29). + argv = append(argv, "-device", "ramfb") + + // USB input (keyboard + tablet for absolute pointing). + // p2=8: install attaches kbd+tablet+3 USB storage devices (Windows ISO, + // VirtIO ISO, autounattend FAT); default p2=4 puts overflow behind a hub + // and UEFI can't mount FS on hub-connected devices. + argv = append(argv, + "-device", fmt.Sprintf("qemu-xhci,id=%s,p2=8", USBBusID), + "-device", "usb-kbd", + "-device", "usb-tablet") + + // Serial console to a file (boot/EMS diagnostics) + if spec.SerialLogPath != "" { + argv = append(argv, "-serial", "file:"+spec.SerialLogPath) + } + + // Shared virtio-serial bus for guest-agent and/or progress/structured ports. + needVirtioSerial := spec.GuestAgentSocketPath != "" || spec.GuestProgressLogPath != "" || spec.GuestStructuredLogPath != "" + if needVirtioSerial { + argv = append(argv, "-device", "virtio-serial-pci,id=virtio-serial0") + } + + // Guest-writable progress port — see Spec.GuestProgressLogPath. + // Uses a virtio-serial port: the guest writes to + // \\.\Global\devcell.progress.0, the host reads from the chardev file. + if spec.GuestProgressLogPath != "" { + argv = append(argv, + "-chardev", "file,id=guestprog,path="+spec.GuestProgressLogPath, + "-device", "virtserialport,bus=virtio-serial0.0,chardev=guestprog,name="+ProgressPortName) + } + + // Structured JSONL port — see Spec.GuestStructuredLogPath. + if spec.GuestStructuredLogPath != "" { + argv = append(argv, + "-chardev", "file,id=gueststruct,path="+spec.GuestStructuredLogPath, + "-device", "virtserialport,bus=virtio-serial0.0,chardev=gueststruct,name="+StructuredPortName) + } + + // QMP monitor (machine protocol for programmatic control) + argv = append(argv, + "-qmp", "unix:"+QMPSocketPath(spec)+",server,nowait") + + if spec.NoReboot { + argv = append(argv, "-no-reboot") + } + + // VM name + if spec.VMName != "" { + argv = append(argv, "-name", spec.VMName) + } + + return argv +} + +// diskDriveArg builds the -drive argument for the main disk, appending the +// cache policy when one is configured. +// diskDeviceArg selects the system disk controller. NVMe is our default +// (Windows ARM64 has an inbox driver); virtio-scsi is what the proven +// Hyper-V config uses. +// diskDeviceArgs attaches the system disk. NVMe is our default (Windows ARM64 +// has an inbox driver and needs one device); virtio-scsi is what the proven +// Hyper-V config uses and needs TWO — the controller and the drive bound to +// it. Emitting only the controller leaves drive=disk0 orphaned and the guest +// with no disk at all. +func diskDeviceArgs(spec Spec) []string { + if spec.DiskBus == "scsi" { + return []string{ + "-device", "virtio-scsi-pci", + "-device", "scsi-hd,drive=disk0,serial=devcell0,bootindex=0", + } + } + return []string{"-device", "nvme,drive=disk0,serial=devcell0,bootindex=0"} +} + +func diskDriveArg(spec Spec) string { + arg := fmt.Sprintf("if=none,format=qcow2,file=%s,id=disk0", spec.DiskPath) + if spec.DiskCacheMode != "" { + arg += ",cache=" + spec.DiskCacheMode + } + return arg +} + +func machineType(spec Spec) string { + if spec.SecureWorld { + return secureMachineType(spec) + } + if spec.usesTCG() { + // virtualization=true gives the guest EL2, which Windows ARM64 expects. + if spec.NestedVirt { + // EL2 alone is not enough for Windows to start its own hypervisor: + // it also wants GICv3 virtual interrupts (with ITS) and a secure + // world. This is the configuration the community reports booting + // Hyper-V/WSL2 on ARM64 — under TCG specifically; the same setup is + // reported to fail under KVM. + // secure=on is deliberately absent: it needs firmware built with + // secure-world support, and the aarch64 EDK2 we ship + // (edk2-aarch64-code.fd) is the non-secure build — only the i386 + // tree has a *-secure-code.fd. Asking for it left an installed + // Windows unable to boot at all (run 20260801T131920: 116 SSH + // polls, no stage ever started). + return "virt,virtualization=true,gic-version=3,its=on" + } + return "virt,virtualization=true" + } + if runtime.GOOS == "darwin" { + return "virt,highmem=on" + } + return "virt" +} + +// secureMachineType is the machine Windows' hypervisor is reported to need +// (see CELL-392): EL2 plus GICv3/ITS plus a secure world. Whether an +// installed Windows — or even the installer — can boot on it is what +// TestWindowsInstall_SecureBoot measures. +func secureMachineType(spec Spec) string { + // Exactly the machine of the config reported working for Hyper-V/WSL2 on + // ARM64 (Vogtinator gist, "tested with Build 25931"): no its=on, which was + // our own addition from a different source. + base := "virt,virtualization=on,gic-version=3,its=on,secure=on" + if !spec.usesTCG() && runtime.GOOS == "darwin" { + return base + ",highmem=on" + } + return base +} + +func cpuType(spec Spec) string { + if spec.CPU != "" { + return spec.CPU + } + if spec.SecureWorld { + // A real CPU model, not max: with -cpu max the firmware boots but + // Windows itself never writes a byte on the secure=on machine + // (run 20260802T065846) — max enables every TCG feature and Windows + // trips on one of them when EL3 is present. neoverse-n1 is the model + // the whole WSL2 chain was proven on. + return "neoverse-n1" + } + if spec.usesTCG() { + // pauth-impdef=on swaps architectural pointer authentication for a + // cheap implementation-defined one — emulating the real algorithm + // under TCG is punishingly slow. + return "max,pauth-impdef=on" + } + if runtime.GOOS == "darwin" { + return "host" + } + return "max" +} diff --git a/internal/vm/qemu/command_test.go b/internal/vm/qemu/command_test.go new file mode 100644 index 0000000..3057639 --- /dev/null +++ b/internal/vm/qemu/command_test.go @@ -0,0 +1,755 @@ +package qemu + +import ( + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testSpec() Spec { + return Spec{ + VMName: "test-vm", + CPUs: 4, + MemoryGB: 8, + DiskPath: "/tmp/disk.qcow2", + FirmwarePath: "/tmp/efi.fd", + VarsPath: "/tmp/vars.fd", + VirtioISO: "/tmp/virtio.iso", + SSHPort: 2222, + SSHHost: "127.0.0.1", + MACAddr: "02:ab:cd:ef:01:23", + DisplayType: "none", + } +} + +func TestBuildRunCommand_ContainsQEMU(t *testing.T) { + argv := BuildRunCommand(testSpec()) + require.NotEmpty(t, argv) + assert.Equal(t, "qemu-system-aarch64", argv[0]) +} + +func TestBuildRunCommand_CPU(t *testing.T) { + argv := BuildRunCommand(testSpec()) + joined := strings.Join(argv, " ") + assert.Contains(t, joined, "-smp 4") + assert.Contains(t, joined, "-m 8G") +} + +func TestBuildRunCommand_SSHForward(t *testing.T) { + argv := BuildRunCommand(testSpec()) + joined := strings.Join(argv, " ") + assert.Contains(t, joined, "hostfwd=tcp:127.0.0.1:2222-:22") +} + +func TestBuildRunCommand_MACAddress(t *testing.T) { + argv := BuildRunCommand(testSpec()) + joined := strings.Join(argv, " ") + assert.Contains(t, joined, "mac=02:ab:cd:ef:01:23") +} + +func TestBuildRunCommand_UEFI(t *testing.T) { + argv := BuildRunCommand(testSpec()) + joined := strings.Join(argv, " ") + assert.Contains(t, joined, "pflash") + assert.Contains(t, joined, "/tmp/efi.fd") + assert.Contains(t, joined, "/tmp/vars.fd") +} + +func TestBuildRunCommand_Disk(t *testing.T) { + // NVMe, not virtio: Windows ARM64 has inbox stornvme.sys but no virtio + // drivers — WinPE/Windows can't see a virtio disk (CELL-359). + argv := BuildRunCommand(testSpec()) + joined := strings.Join(argv, " ") + assert.Contains(t, joined, "if=none,format=qcow2,file=/tmp/disk.qcow2,id=disk0") + assert.Contains(t, joined, "nvme,drive=disk0") + assert.NotContains(t, joined, "if=virtio") +} + +func TestBuildRunCommand_RamfbDisplay(t *testing.T) { + // ramfb, not virtio-gpu-pci: VirtioGpuDxe exposes no linear framebuffer + // (FrameBufferBase=0); Windows bootmgr blits to it and dead-loops on a + // NULL-dest data abort (CELL-352 root cause, 2026-07-29). + argv := BuildRunCommand(testSpec()) + joined := strings.Join(argv, " ") + assert.Contains(t, joined, "-device ramfb") + assert.NotContains(t, joined, "virtio-gpu-pci") +} + +func TestBuildInstallCommand_CDIsRealCDROM(t *testing.T) { + // The drive carries media=cdrom so QEMU presents optical media (2048-byte + // sectors) — that is what makes it a CD, not the device model. The device + // is usb-storage with removable=true, the wiring UTM ships for aarch64 + // virt. An earlier note here claimed usb-storage "always instantiates a + // scsi-DISK" and blamed it for a cdboot data abort; that conflated it + // with usb-bot, and UTM ships this exact config at scale. + argv := BuildInstallCommand(testSpec(), "/tmp/win11.iso", "/tmp/autounattend.iso") + joined := strings.Join(argv, " ") + assert.Contains(t, joined, "file=/tmp/win11.iso,media=cdrom") + assert.Contains(t, joined, "usb-storage,drive=cdrom0,removable=true,bus=usb-bus.0,id=installer-cd,bootindex=1") + assert.NotContains(t, joined, "usb-bot") +} + +func TestBuildInstallCommand_NVMeDisk(t *testing.T) { + argv := BuildInstallCommand(testSpec(), "/tmp/win11.iso", "/tmp/autounattend.iso") + joined := strings.Join(argv, " ") + assert.Contains(t, joined, "nvme,drive=disk0") + assert.NotContains(t, joined, "if=virtio") +} + +func TestBuildRunCommand_Display(t *testing.T) { + argv := BuildRunCommand(testSpec()) + joined := strings.Join(argv, " ") + assert.Contains(t, joined, "-display none") +} + +func TestBuildRunCommand_BootDisk(t *testing.T) { + argv := BuildRunCommand(testSpec()) + joined := strings.Join(argv, " ") + assert.Contains(t, joined, "-boot c") +} + +func TestBuildRunCommand_QMP(t *testing.T) { + argv := BuildRunCommand(testSpec()) + joined := strings.Join(argv, " ") + assert.Contains(t, joined, "-qmp") + assert.Contains(t, joined, "test-vm-qmp.sock") +} + +func TestBuildInstallCommand_WindowsISO(t *testing.T) { + argv := BuildInstallCommand(testSpec(), "/tmp/win11.iso", "/tmp/autounattend.iso") + joined := strings.Join(argv, " ") + assert.Contains(t, joined, "file=/tmp/win11.iso,media=cdrom,if=none,id=cdrom0") + assert.Contains(t, joined, "usb-storage,drive=cdrom0,removable=true,bus=usb-bus.0,id=installer-cd,bootindex=1") +} + +func TestBuildInstallCommand_VirtioISO(t *testing.T) { + argv := BuildInstallCommand(testSpec(), "/tmp/win11.iso", "/tmp/autounattend.iso") + joined := strings.Join(argv, " ") + assert.Contains(t, joined, "file=/tmp/virtio.iso,media=cdrom,if=none,id=cdrom1") + assert.Contains(t, joined, "usb-storage,drive=cdrom1,removable=true,bus=usb-bus.0,bootindex=2") +} + +func TestBuildInstallCommand_BootIndex(t *testing.T) { + argv := BuildInstallCommand(testSpec(), "/tmp/win11.iso", "/tmp/autounattend.img") + joined := strings.Join(argv, " ") + // Disk is bootindex=0 (empty on first boot ⇒ no UEFI entry ⇒ falls through + // to the CD); installer CD 1, VirtIO CD 2. + assert.Contains(t, joined, "nvme,drive=disk0,serial=devcell0,bootindex=0") + assert.Contains(t, joined, "drive=cdrom0,removable=true,bus=usb-bus.0,id=installer-cd,bootindex=1") + assert.Contains(t, joined, "drive=cdrom1,removable=true,bus=usb-bus.0,bootindex=2") +} + +// Every bootable device must have an explicit bootindex, the answer volume +// included. It is a partition-table-less FAT superfloppy with no bootloader, so +// when the firmware happens to try it first the VM parks at +// +// BdsDxe: starting Boot0001 "UEFI QEMU QEMU USB HARDDRIVE ..." +// Start boot option +// +// forever. Leaving one device unordered makes the boot order firmware-dependent +// and therefore intermittent: run 20260730T222409 installed fine and run +// 20260731T011818, same argv, sat in firmware for the full 5-hour deadline. +func TestBuildInstallCommand_AnswerVolumeBootsLastNotByChance(t *testing.T) { + joined := strings.Join(BuildInstallCommand(testSpec(), "/tmp/win11.iso", "/tmp/autounattend.img"), " ") + + assert.Contains(t, joined, "usb-storage,drive=usbfat0,removable=true,bus=usb-bus.0,bootindex=3", + "the answer volume must be ordered explicitly, after the disk and both CDs") +} + +func TestBuildInstallCommand_BootIndex_NoVirtio(t *testing.T) { + s := testSpec() + s.VirtioISO = "" + argv := BuildInstallCommand(s, "/tmp/win11.iso", "/tmp/autounattend.img") + joined := strings.Join(argv, " ") + assert.Contains(t, joined, "drive=cdrom0,removable=true,bus=usb-bus.0,id=installer-cd,bootindex=1") + // The answer volume takes the slot the VirtIO CD would have had — it is + // ordered explicitly either way, so nothing is left to the firmware. + assert.Contains(t, joined, "usb-storage,drive=usbfat0,removable=true,bus=usb-bus.0,bootindex=2") + count := strings.Count(joined, "bootindex=") + assert.Equal(t, 3, count, "disk, Windows ISO and answer volume — every bootable device ordered") +} + +func TestBuildInstallCommand_RemovableMedia(t *testing.T) { + // removable=true is required: Windows only mounts a partition-table-less + // volume, and only presents optical media correctly, from a removable + // device. media=cdrom on the drive is what makes it optical. + argv := BuildInstallCommand(testSpec(), "/tmp/win11.iso", "/tmp/autounattend.img") + joined := strings.Join(argv, " ") + assert.Contains(t, joined, "media=cdrom", "installer media must be presented as a CD-ROM for UEFI El Torito boot") + assert.Contains(t, joined, "usb-storage,drive=cdrom0,removable=true") +} + +func TestBuildInstallCommand_AutounattendFAT(t *testing.T) { + argv := BuildInstallCommand(testSpec(), "/tmp/win11.iso", "/tmp/autounattend.img") + joined := strings.Join(argv, " ") + assert.Contains(t, joined, "file=/tmp/autounattend.img,format=raw,if=none,id=usbfat0") + assert.Contains(t, joined, "usb-storage,drive=usbfat0") + assert.NotContains(t, joined, "autounattend.img,media=cdrom", "autounattend must NOT be cdrom") +} + +func TestBuildInstallCommand_NoVirtio_AutounattendIndex(t *testing.T) { + s := testSpec() + s.VirtioISO = "" + argv := BuildInstallCommand(s, "/tmp/win11.iso", "/tmp/autounattend.img") + joined := strings.Join(argv, " ") + assert.NotContains(t, joined, "virtio.iso") + assert.Contains(t, joined, "file=/tmp/autounattend.img,format=raw,if=none,id=usbfat0") + assert.Contains(t, joined, "usb-storage,drive=usbfat0") +} + +func TestBuildInstallCommand_NoBIOSBootFlag(t *testing.T) { + argv := BuildInstallCommand(testSpec(), "/tmp/win11.iso", "/tmp/autounattend.img") + joined := strings.Join(argv, " ") + assert.NotContains(t, joined, "-boot d", "UEFI uses startup.nsh, not BIOS -boot d") +} + +func TestBuildRunCommand_NoMAC(t *testing.T) { + s := testSpec() + s.MACAddr = "" + argv := BuildRunCommand(s) + joined := strings.Join(argv, " ") + assert.Contains(t, joined, "virtio-net-pci,netdev=net0") + assert.NotContains(t, joined, "mac=") +} + +func TestBuildRunCommand_InputDevices(t *testing.T) { + argv := BuildRunCommand(testSpec()) + joined := strings.Join(argv, " ") + assert.Contains(t, joined, "usb-kbd") + assert.Contains(t, joined, "usb-tablet") + assert.Contains(t, joined, "qemu-xhci") +} + +func TestBaseCommand_XHCIPortCount(t *testing.T) { + argv := BuildRunCommand(testSpec()) + joined := strings.Join(argv, " ") + assert.Contains(t, joined, "qemu-xhci,id=usb-bus,p2=8", + "XHCI must have p2=8 USB 2.0 ports — default p2=4 causes hub spillover "+ + "when install attaches kbd+tablet+3 storage devices, and UEFI can't mount "+ + "FS on hub-connected devices (no FS alias → startup.nsh not found)") +} + +func TestBuildRunCommand_VMName(t *testing.T) { + argv := BuildRunCommand(testSpec()) + joined := strings.Join(argv, " ") + assert.Contains(t, joined, "-name test-vm") +} + +// --- CELL-352: VNC and RDP port forwarding --- + +func TestBuildRunCommand_VNCDisplay(t *testing.T) { + s := testSpec() + s.VNCPort = 15050 + argv := BuildRunCommand(s) + joined := strings.Join(argv, " ") + // VNC display number = port - 5900 + assert.Contains(t, joined, "-vnc localhost:9150") +} + +func TestBuildRunCommand_VNCNotPresentWhenZero(t *testing.T) { + s := testSpec() + s.VNCPort = 0 + argv := BuildRunCommand(s) + joined := strings.Join(argv, " ") + assert.NotContains(t, joined, "-vnc") +} + +func TestBuildRunCommand_RDPHostfwd(t *testing.T) { + s := testSpec() + s.RDPPort = 15089 + argv := BuildRunCommand(s) + joined := strings.Join(argv, " ") + assert.Contains(t, joined, "hostfwd=tcp:127.0.0.1:15089-:3389") +} + +func TestBuildRunCommand_RDPNotPresentWhenZero(t *testing.T) { + s := testSpec() + s.RDPPort = 0 + argv := BuildRunCommand(s) + joined := strings.Join(argv, " ") + assert.NotContains(t, joined, "3389") +} + +func TestBuildRunCommand_BothVNCAndRDP(t *testing.T) { + s := testSpec() + s.VNCPort = 25650 + s.RDPPort = 25689 + argv := BuildRunCommand(s) + joined := strings.Join(argv, " ") + // VNC display = 25650 - 5900 = 19750 + assert.Contains(t, joined, "-vnc localhost:19750") + assert.Contains(t, joined, "hostfwd=tcp:127.0.0.1:25689-:3389") + // SSH hostfwd still present + assert.Contains(t, joined, "hostfwd=tcp:127.0.0.1:2222-:22") +} + +func TestBuildInstallCommand_VNCDisplay(t *testing.T) { + s := testSpec() + s.VNCPort = 15050 + argv := BuildInstallCommand(s, "/tmp/win11.iso", "/tmp/autounattend.iso") + joined := strings.Join(argv, " ") + assert.Contains(t, joined, "-vnc localhost:9150") +} + +func TestBuildInstallCommand_RDPHostfwd(t *testing.T) { + s := testSpec() + s.RDPPort = 15089 + argv := BuildInstallCommand(s, "/tmp/win11.iso", "/tmp/autounattend.iso") + joined := strings.Join(argv, " ") + assert.Contains(t, joined, "hostfwd=tcp:127.0.0.1:15089-:3389") +} + +// --- Spec-driven argv knobs (single source of truth for QEMU args) --- + +func TestBaseCommand_ExplicitAccel(t *testing.T) { + s := testSpec() + s.Accel = "tcg,thread=multi" + joined := strings.Join(BuildRunCommand(s), " ") + assert.Contains(t, joined, "-accel tcg,thread=multi") +} + +func TestBaseCommand_TCGUsesVirtualizationAndPauth(t *testing.T) { + // Under TCG the aarch64 guest needs virtualization=true (EL2) and + // pauth-impdef=on — real pointer auth emulation is punishingly slow. + s := testSpec() + s.Accel = "tcg,thread=multi" + joined := strings.Join(BuildRunCommand(s), " ") + assert.Contains(t, joined, "-machine virt,virtualization=true") + assert.Contains(t, joined, "-cpu max,pauth-impdef=on") +} + +func TestBaseCommand_SerialLogPath(t *testing.T) { + s := testSpec() + s.SerialLogPath = "/tmp/serial.log" + joined := strings.Join(BuildRunCommand(s), " ") + assert.Contains(t, joined, "-serial file:/tmp/serial.log") +} + +func TestBaseCommand_NoSerialByDefault(t *testing.T) { + joined := strings.Join(BuildRunCommand(testSpec()), " ") + assert.NotContains(t, joined, "-serial") +} + +func TestBaseCommand_NoReboot(t *testing.T) { + s := testSpec() + s.NoReboot = true + joined := strings.Join(BuildRunCommand(s), " ") + assert.Contains(t, joined, "-no-reboot") +} + +func TestBaseCommand_NoRebootOmittedByDefault(t *testing.T) { + joined := strings.Join(BuildRunCommand(testSpec()), " ") + assert.NotContains(t, joined, "-no-reboot") +} + +func TestBuildInstallCommand_WithoutAutounattend(t *testing.T) { + // Boot-only validation: no autounattend image to attach. + argv := BuildInstallCommand(testSpec(), "/tmp/win11.iso", "") + joined := strings.Join(argv, " ") + assert.Contains(t, joined, "usb-storage,drive=cdrom0,removable=true,bus=usb-bus.0,id=installer-cd,bootindex=1") + assert.NotContains(t, joined, "usbfat0") +} + +func TestBaseCommand_GuestProgressVirtioSerial(t *testing.T) { + // pci-serial (16550 on PCI) shows up as COM1 on x86 but NOT on ARM64 — + // guest-progress.log was always empty (CELL-430). virtio-serial with the + // vioserial driver exposes a named port the guest writes to via + // \\.\Global\, which works on both architectures. + s := testSpec() + s.GuestProgressLogPath = "/tmp/guest-progress.log" + joined := strings.Join(BuildRunCommand(s), " ") + assert.Contains(t, joined, "-chardev file,id=guestprog,path=/tmp/guest-progress.log") + assert.Contains(t, joined, "-device virtio-serial-pci") + assert.Contains(t, joined, "-device virtserialport,bus=virtio-serial0.0,chardev=guestprog,name="+ProgressPortName) + assert.NotContains(t, joined, "pci-serial") +} + +func TestBaseCommand_GuestProgressSharesBusWithGuestAgent(t *testing.T) { + s := testSpec() + s.GuestProgressLogPath = "/tmp/guest-progress.log" + s.GuestAgentSocketPath = "/tmp/qga.sock" + joined := strings.Join(BuildRunCommand(s), " ") + // Only one virtio-serial-pci bus, shared by both ports. + assert.Equal(t, 1, strings.Count(joined, "virtio-serial-pci")) +} + +func TestBaseCommand_NoGuestProgressByDefault(t *testing.T) { + joined := strings.Join(BuildRunCommand(testSpec()), " ") + assert.NotContains(t, joined, "pci-serial") + assert.NotContains(t, joined, "guestprog") +} + +func TestBuildInstallCommand_AutounattendIsRemovable(t *testing.T) { + // CreateFATImage writes a superfloppy (no partition table). Windows only + // mounts such a volume when the device reports removable media; as a fixed + // disk it stays RAW and Setup never finds autounattend.xml — observed + // live: guest mounted nothing and sat on the language screen (CELL-362). + argv := BuildInstallCommand(testSpec(), "/tmp/win11.iso", "/tmp/autounattend.img") + joined := strings.Join(argv, " ") + assert.Contains(t, joined, "usb-storage,drive=usbfat0,removable=true") +} + +func TestBuildInstallCommand_AutounattendISOAsCDROM(t *testing.T) { + // An .iso answer file is attached as a second usb-bot CD-ROM. A FAT + // superfloppy on usb-storage alongside the installer made cdboot take a + // data abort during boot; CD-ROMs are the device type this firmware path + // handles reliably, and Windows Setup searches CD/DVD drives for + // autounattend.xml too (CELL-362). + argv := BuildInstallCommand(testSpec(), "/tmp/win11.iso", "/tmp/autounattend.iso") + joined := strings.Join(argv, " ") + assert.Contains(t, joined, "file=/tmp/autounattend.iso,media=cdrom,if=none,id=cdrom2") + assert.NotContains(t, joined, "usb-bot") + assert.Contains(t, joined, "usb-storage,drive=cdrom2,removable=true,bus=usb-bus.0") + assert.NotContains(t, joined, "usbfat0") +} + +func TestBuildInstallCommand_AutounattendImgStaysUSBStorage(t *testing.T) { + // Raw FAT images keep the removable usb-storage path for callers that + // still want a writable answer-file volume. + argv := BuildInstallCommand(testSpec(), "/tmp/win11.iso", "/tmp/autounattend.img") + joined := strings.Join(argv, " ") + assert.Contains(t, joined, "usb-storage,drive=usbfat0,removable=true") +} + +func TestBuildInstallCommand_BootOrderDiskBeforeCD(t *testing.T) { + // Disk first, CD second. An empty disk exposes no UEFI boot entry, so the + // firmware falls through to the CD and installs; once Windows is on the + // disk it has an ESP and wins every subsequent boot. Self-correcting — + // no eject and no timing heuristic required. + // + // With the CD at bootindex=0 the post-install reboot booted the installer + // again and Setup stopped on "you started an upgrade and booted from + // installation media" (observed twice). + argv := BuildInstallCommand(testSpec(), "/tmp/win11.iso", "/tmp/autounattend.img") + joined := strings.Join(argv, " ") + + assert.Contains(t, joined, "nvme,drive=disk0,serial=devcell0,bootindex=0") + assert.Contains(t, joined, "usb-storage,drive=cdrom0,removable=true,bus=usb-bus.0,id=installer-cd,bootindex=1") + + diskIdx := strings.Index(joined, "bootindex=0") + cdIdx := strings.Index(joined, "bootindex=1") + assert.Positive(t, diskIdx) + assert.Positive(t, cdIdx) +} + +func TestBuildRunCommand_DiskIsBootable(t *testing.T) { + joined := strings.Join(BuildRunCommand(testSpec()), " ") + assert.Contains(t, joined, "nvme,drive=disk0,serial=devcell0,bootindex=0") +} + +func TestBuildInstallCommand_CDsHaveQdevIDs(t *testing.T) { + // Secondary safety net: QMP `eject` addresses devices by qdev id, not by + // drive id — ejecting "cdrom0" fails with DeviceNotFound. + argv := BuildInstallCommand(testSpec(), "/tmp/win11.iso", "/tmp/autounattend.img") + joined := strings.Join(argv, " ") + assert.Contains(t, joined, "id="+InstallerCDDeviceID) +} + +func TestBaseCommand_DiskCacheMode(t *testing.T) { + // Under TCG every guest flush becomes a real host fsync, and Windows + // Setup flushes constantly. cache=unsafe drops them — the disk is + // garbage if the host dies mid-run, which is fine for a disposable + // install VM but must stay opt-in. + s := testSpec() + s.DiskCacheMode = "unsafe" + joined := strings.Join(BuildRunCommand(s), " ") + assert.Contains(t, joined, "file=/tmp/disk.qcow2,id=disk0,cache=unsafe") +} + +func TestBaseCommand_NoDiskCacheModeByDefault(t *testing.T) { + // Production VMs keep QEMU's safe default. + joined := strings.Join(BuildRunCommand(testSpec()), " ") + assert.NotContains(t, joined, "cache=") +} + +// --- dev-env wiring: guest agent channel, virtio-fs, driver ISO at run time --- + +// The qemu-ga channel (VIRTIO.md "idiomatic host side"): a virtio-serial port +// named org.qemu.guest_agent.0 — the exact name the agent looks for. On ARM64 +// the agent itself is the x64 MSI under emulation, but the channel is the same. +func TestBuildRunCommand_GuestAgentChannel(t *testing.T) { + spec := testSpec() + spec.GuestAgentSocketPath = "/tmp/qga.sock" + + joined := strings.Join(BuildRunCommand(spec), " ") + + assert.Contains(t, joined, "-chardev socket,id=qga0,path=/tmp/qga.sock,server=on,wait=off") + assert.Contains(t, joined, "-device virtio-serial-pci") + assert.Contains(t, joined, "-device virtserialport,bus=virtio-serial0.0,chardev=qga0,name=org.qemu.guest_agent.0") +} + +func TestBuildRunCommand_NoGuestAgentChannelByDefault(t *testing.T) { + joined := strings.Join(BuildRunCommand(testSpec()), " ") + assert.NotContains(t, joined, "guest_agent") +} + +// virtio-fs needs three things wired together: the vhost-user socket, the +// device with the tag the guest mounts by, and shareable guest RAM — without +// memory-backend + numa, QEMU rejects vhost-user-fs outright. +func TestBuildRunCommand_VirtioFS(t *testing.T) { + spec := testSpec() + spec.VirtioFSSocketPath = "/tmp/vfs.sock" + spec.VirtioFSTag = "devcell" + + joined := strings.Join(BuildRunCommand(spec), " ") + + assert.Contains(t, joined, "-chardev socket,id=virtiofs0,path=/tmp/vfs.sock") + assert.Contains(t, joined, "-device vhost-user-fs-pci,queue-size=1024,chardev=virtiofs0,tag=devcell") + assert.Contains(t, joined, "-object memory-backend-memfd,id=mem,size=8G,share=on") + assert.Contains(t, joined, "-numa node,memdev=mem") +} + +func TestBuildRunCommand_NoVirtioFSByDefault(t *testing.T) { + joined := strings.Join(BuildRunCommand(testSpec()), " ") + assert.NotContains(t, joined, "vhost-user-fs") + assert.NotContains(t, joined, "memory-backend") +} + +// Post-install driver work (pnputil from the virtio ISO) needs the ISO +// attached to a *running* VM, not only during install. +func TestBuildRunCommand_AttachesVirtioISO(t *testing.T) { + joined := strings.Join(BuildRunCommand(testSpec()), " ") + + assert.Contains(t, joined, "file=/tmp/virtio.iso,media=cdrom") + assert.Contains(t, joined, "usb-storage,drive=cdrom1,removable=true,bus=usb-bus.0") +} + +func TestBuildRunCommand_NoCDWithoutVirtioISO(t *testing.T) { + spec := testSpec() + spec.VirtioISO = "" + joined := strings.Join(BuildRunCommand(spec), " ") + assert.NotContains(t, joined, "media=cdrom") +} + +// Windows' own hypervisor needs more than EL2 to launch: the community +// configuration that boots Hyper-V/WSL2 on ARM64 under TCG also asks for a +// GICv3 with ITS and a secure world +// (https://gist.github.com/Vogtinator/293c4f90c5e92838f7e72610725905fd — +// "WSL2/Hyper-V support (TCG only, failing under KVM)"). With only +// virtualization=true, run 20260801T123644 had the feature installed and +// hypervisorlaunchtype=Auto while HCS still answered HYPERV_NOT_INSTALLED. +func TestMachineType_NestedVirtAddsGICv3WithoutSecureWorld(t *testing.T) { + spec := testSpec() + spec.Accel = "tcg,thread=multi" + spec.NestedVirt = true + + joined := strings.Join(BuildRunCommand(spec), " ") + + assert.Contains(t, joined, "virtualization=true", "EL2 for the guest hypervisor") + assert.Contains(t, joined, "gic-version=3", "a hypervisor needs GICv3 virtual interrupts") + assert.Contains(t, joined, "its=on", "ITS pairs with GICv3") + assert.NotContains(t, joined, "secure=on", + "secure=on needs secure-world firmware we do not ship — it stopped an installed Windows from booting") +} + +// The install path is proven working with the plain machine line; changing the +// boot environment underneath it is not something a dev-env experiment gets to +// do implicitly. +func TestMachineType_NestedVirtIsOptIn(t *testing.T) { + spec := testSpec() + spec.Accel = "tcg,thread=multi" + + joined := strings.Join(BuildRunCommand(spec), " ") + + assert.Contains(t, joined, "virtualization=true") + assert.NotContains(t, joined, "secure=on") + assert.NotContains(t, joined, "gic-version=3") +} + +// secure=on is its own axis: it hands the pflash firmware the secure world and +// an EL3 entry. Kept separate from NestedVirt so a test can enable one without +// the other and attribute a boot failure to the right change. +func TestMachineType_SecureWorldIsSeparateFromNestedVirt(t *testing.T) { + spec := testSpec() + spec.Accel = "tcg,thread=multi" + + spec.SecureWorld = true + secure := strings.Join(BuildRunCommand(spec), " ") + assert.Contains(t, secure, "secure=on") + assert.Contains(t, secure, "gic-version=3") + // The proven config spells it virtualization=on (QEMU accepts on/true). + assert.Contains(t, secure, "virtualization=on") + + spec.SecureWorld = false + spec.NestedVirt = true + nested := strings.Join(BuildRunCommand(spec), " ") + assert.NotContains(t, nested, "secure=on", "nested virt must not drag in the secure world") +} + +// A controller with no drive bound to it is a machine with no disk. The scsi +// path needs both devices; emitting only virtio-scsi-pci left drive=disk0 +// orphaned and the guest diskless. +// --- CELL-427: CDBus selects the CD-ROM controller --- + +// --- CELL-429: CDs ride usb-storage, the way UTM does it --- +// +// UTM's ARM64 rule (UTMQemuConfigurationDrive.swift:125) sends every CD on +// the `virt` machine to USB and only non-CDs to virtio, rendering +// `usb-storage,removable=true,bus=usb-bus.0` on a `id=usb-bus` xhci +// controller. That is the config a very large Windows-on-Apple-Silicon user +// base runs, and both halves are already proven in our own logs: EDK2 on +// QEMU 11/HVF boots usb-storage (the answer volume's BOOTAA64.EFI +// chainloaded that way, run 20260812T091924) and ARM64 WinPE reads +// usb-storage with inbox usbstor (the answer volume is the C: diskpart +// lists and Setup evaluates as a media location, run 20260812T150644). +// +// This removes the vioscsi dependency entirely — no driver, no drvload, no +// PnP-settle race against EarlyF6DriverInstall. Note usb-storage is NOT +// usb-bot: usb-bot is what killed USB enumeration on QEMU 11/HVF (run +// 20260812T122950). +func TestBuildInstallCommand_CDsOnUSBStorage(t *testing.T) { + s := testSpec() + argv := BuildInstallCommand(s, "/tmp/win11.iso", "/tmp/autounattend.img") + joined := strings.Join(argv, " ") + + assert.Contains(t, joined, "qemu-xhci,id=usb-bus", + "the xhci controller needs an id so drives can name their bus") + assert.Contains(t, joined, "file=/tmp/win11.iso,media=cdrom,if=none,id=cdrom0") + assert.Contains(t, joined, + fmt.Sprintf("usb-storage,drive=cdrom0,removable=true,bus=usb-bus.0,id=%s,bootindex=1", InstallerCDDeviceID)) + assert.NotContains(t, joined, "usb-bot", "usb-bot is invisible to EDK2 on QEMU 11/HVF (CELL-427)") + assert.NotContains(t, joined, "virtio-scsi", "WinPE has no inbox vioscsi (CELL-429)") +} + +func TestBuildInstallCommand_VirtioISOOnUSBStorage(t *testing.T) { + s := testSpec() + joined := strings.Join(BuildInstallCommand(s, "/tmp/win11.iso", "/tmp/autounattend.img"), " ") + + assert.Contains(t, joined, "file=/tmp/virtio.iso,media=cdrom,if=none,id=cdrom1") + assert.Contains(t, joined, "usb-storage,drive=cdrom1,removable=true,bus=usb-bus.0,bootindex=2") + assert.Equal(t, 1, strings.Count(joined, "qemu-xhci"), "one USB controller for every device") +} + +func TestBuildRunCommand_VirtioISOOnUSBStorage(t *testing.T) { + s := testSpec() + joined := strings.Join(BuildRunCommand(s), " ") + + assert.Contains(t, joined, "usb-storage,drive=cdrom1,removable=true,bus=usb-bus.0") + assert.NotContains(t, joined, "usb-bot") + assert.NotContains(t, joined, "virtio-scsi") +} + +func TestBuildInstallCommand_CDsOnSCSI(t *testing.T) { + s := testSpec() + s.CDBus = "scsi" + argv := BuildInstallCommand(s, "/tmp/win11.iso", "/tmp/autounattend.img") + joined := strings.Join(argv, " ") + + assert.Contains(t, joined, fmt.Sprintf("virtio-scsi-pci,id=%s", CDBusID), + "scsi CDs need a dedicated virtio-scsi controller") + assert.Contains(t, joined, "file=/tmp/win11.iso,media=cdrom,if=none,id=cdrom0") + assert.Contains(t, joined, + fmt.Sprintf("scsi-cd,drive=cdrom0,bus=%s.0,id=%s,bootindex=1", CDBusID, InstallerCDDeviceID)) + assert.Contains(t, joined, + fmt.Sprintf("scsi-cd,drive=cdrom1,bus=%s.0,bootindex=2", CDBusID), + "virtio ISO also on scsi-cd") + assert.Contains(t, joined, "usb-storage,drive=usbfat0", + "answer FAT image still on usb-storage regardless of CD bus") +} + +// --- CELL-430: BuildWinPECommand — WinPE-only boot from custom ISO --- + +func TestBuildWinPECommand_BootsFromCustomISO(t *testing.T) { + s := testSpec() + s.VirtioISO = "" // WinPE-only, no virtio ISO + argv := BuildWinPECommand(s, "/tmp/winpe.iso", "/tmp/answer.img") + joined := strings.Join(argv, " ") + + assert.Contains(t, joined, "file=/tmp/winpe.iso,media=cdrom,if=none,id=cdrom0") + assert.Contains(t, joined, "usb-storage,drive=cdrom0,removable=true,bus=usb-bus.0,id=installer-cd,bootindex=1") +} + +func TestBuildWinPECommand_AnswerVolumeOnUSBStorage(t *testing.T) { + s := testSpec() + s.VirtioISO = "" + argv := BuildWinPECommand(s, "/tmp/winpe.iso", "/tmp/answer.img") + joined := strings.Join(argv, " ") + + assert.Contains(t, joined, "file=/tmp/answer.img,format=raw,if=none,id=usbfat0") + assert.Contains(t, joined, "usb-storage,drive=usbfat0,removable=true,bus=usb-bus.0") + assert.NotContains(t, joined, "usbfat0,removable=true,bus=usb-bus.0,bootindex=", + "answer volume must not be a boot candidate — ARM64 kernel firmware crashes on non-bootable FAT") +} + +func TestBuildWinPECommand_NoVirtioISO(t *testing.T) { + s := testSpec() + s.VirtioISO = "" + argv := BuildWinPECommand(s, "/tmp/winpe.iso", "/tmp/answer.img") + joined := strings.Join(argv, " ") + + assert.NotContains(t, joined, "virtio.iso") + count := strings.Count(joined, "media=cdrom") + assert.Equal(t, 1, count, "only one CD: the custom WinPE ISO") +} + +func TestBuildWinPECommand_HasBaseElements(t *testing.T) { + s := testSpec() + s.VirtioISO = "" + argv := BuildWinPECommand(s, "/tmp/winpe.iso", "/tmp/answer.img") + joined := strings.Join(argv, " ") + + assert.Contains(t, joined, "qemu-system-aarch64") + assert.Contains(t, joined, "nvme,drive=disk0") + assert.Contains(t, joined, "qemu-xhci") + assert.Contains(t, joined, "-qmp") +} + +func TestBuildWinPECommand_Qcow2AnswerVolume(t *testing.T) { + s := testSpec() + s.VirtioISO = "" + argv := BuildWinPECommand(s, "/tmp/winpe.iso", "/tmp/shared.qcow2") + joined := strings.Join(argv, " ") + + assert.Contains(t, joined, "file=/tmp/shared.qcow2,format=qcow2,if=none,id=usbfat0", + "qcow2 extension must produce format=qcow2") +} + +func TestBuildWinPECommand_RawAnswerVolumeStaysRaw(t *testing.T) { + s := testSpec() + s.VirtioISO = "" + argv := BuildWinPECommand(s, "/tmp/winpe.iso", "/tmp/answer.img") + joined := strings.Join(argv, " ") + + assert.Contains(t, joined, "format=raw", + "non-qcow2 extension must remain format=raw") +} + +func TestBuildRunCommand_ScsiDiskIsActuallyAttached(t *testing.T) { + spec := testSpec() + spec.DiskBus = "scsi" + + joined := strings.Join(BuildRunCommand(spec), " ") + + assert.Contains(t, joined, "id=disk0", "the drive must exist") + assert.Contains(t, joined, "virtio-scsi-pci", "and its controller") + assert.Contains(t, joined, "scsi-hd,drive=disk0", "and the drive must be bound to it") +} + +func TestBuildInstallCommand_DevcellWimImg(t *testing.T) { + spec := testSpec() + spec.DevcellWimImg = "/tmp/devcell-wim.img" + argv := BuildInstallCommand(spec, "/tmp/win.iso", "/tmp/answer.img") + joined := strings.Join(argv, " ") + assert.Contains(t, joined, "devcellwim0") + assert.Contains(t, joined, "/tmp/devcell-wim.img") + assert.Contains(t, joined, "usb-storage,drive=devcellwim0,removable=true") +} + +func TestBuildInstallCommand_DevcellWimImg_Qcow2(t *testing.T) { + spec := testSpec() + spec.DevcellWimImg = "/tmp/devcell-wim.qcow2" + argv := BuildInstallCommand(spec, "/tmp/win.iso", "/tmp/answer.img") + joined := strings.Join(argv, " ") + assert.Contains(t, joined, "format=qcow2") + assert.Contains(t, joined, "devcellwim0") +} + +func TestBuildInstallCommand_NoDevcellWimImg(t *testing.T) { + spec := testSpec() + argv := BuildInstallCommand(spec, "/tmp/win.iso", "/tmp/answer.img") + joined := strings.Join(argv, " ") + assert.NotContains(t, joined, "devcellwim0") +} diff --git a/internal/vm/qemu/controlvolume_e2e_test.go b/internal/vm/qemu/controlvolume_e2e_test.go new file mode 100644 index 0000000..af3649e --- /dev/null +++ b/internal/vm/qemu/controlvolume_e2e_test.go @@ -0,0 +1,142 @@ +package qemu + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/devcell-sh/go-winkit/unattend" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestControlVolume_RoundTrip is the gate for CELL-402: it proves the FAT +// control volume works in BOTH directions on a real guest before 17 stages +// are bet on it. +// +// IN — a file written by the host is readable inside the guest +// OUT — a log line written mid-stage reaches the host BEFORE the stage ends +// +// The second half is the unproven one: every run so far produced empty +// volume logs and said nothing about why (the old wrapper swallowed both +// failure modes). If OUT fails here, CELL-402 keeps inlining and adopts only +// the module structure — that decision is what this test exists to inform. +func TestControlVolume_RoundTrip(t *testing.T) { + if testing.Short() { + t.Skip("long: boots a Windows guest to prove the control volume round-trip") + } + if os.Getenv("DEVCELL_TEST_CONTROLVOL") == "" { + t.Skip("set DEVCELL_TEST_CONTROLVOL=1 to run the control-volume round-trip proof") + } + requireQEMUBin(t) + + kernelFW, err := KernelFirmwarePath() + if err != nil { + t.Skipf("no kernel-bootable firmware: %v", err) + } + baseImage, err := LatestNixReadyTestImage(testdataDir(t)) + if err != nil { + t.Skipf("no nix-ready checkpoint image: %v", err) + } + + resultsDir := testResultsDir(t) + workDir := t.TempDir() + home := filepath.Join(repoRoot(t), "test", "testdata", "cellhome") + keyPath := filepath.Join(home, ".devcell", "main", "qemu", "id_ed25519") + user := unattend.SessionUsername() + + overlay := filepath.Join(workDir, "roundtrip.qcow2") + require.NoError(t, CloneDisk(baseImage, overlay)) + + // IN: a payload the guest must be able to read. + const payloadPath = "/devcell/roundtrip-in.txt" + const payloadText = "devcell-control-volume-delivery-ok" + volume := filepath.Join(workDir, "control.img") + require.NoError(t, BuildControlVolume(volume, map[string][]byte{ + payloadPath: []byte(payloadText + "\r\n"), + })) + + spec := Spec{ + VMName: "devcell-qemu-controlvol", + CPUs: 6, + MemoryGB: 6, + DiskPath: overlay, + SerialLogPath: filepath.Join(resultsDir, "serial.log"), + FirmwarePath: kernelFW, + FirmwareKernel: true, + SecureWorld: true, + SSHHost: "127.0.0.1", + SSHPort: freeTCPPort(10222), + MACAddr: DeterministicMAC("devcell-qemu-controlvol"), + QMPSocketDir: workDir, + DiskCacheMode: "unsafe", + LogVolumePath: volume, + } + spec.ApplyDefaults() + require.NoError(t, spec.Validate()) + + vmDone := startVM(t, spec) + defer vmDone.stop() + require.NoError(t, + WaitForSSH(spec.SSHHost, spec.SSHPort, time.Hour, 5*time.Second, + testLogObserver{t}, vmStateFn(QMPSocketPath(spec))), + "guest must boot before the volume can be read") + + // One stage, wrapped by the production logging path: it reports the + // resolved drive letter, reads the delivered file, and logs a line the + // host will look for on the volume. + const outMarker = "ROUNDTRIP-OUT-OK" + body := `$vol = $script:DevcellLogVol +Write-DevcellLog ('resolved volume: ' + $vol) +if (-not $vol) { throw 'control volume not visible in the guest' } +$inFile = ($vol + ':` + strings.ReplaceAll(payloadPath, "/", `\`) + `') +Write-DevcellLog ('reading delivered file: ' + $inFile) +$content = (Get-Content $inFile -Raw).Trim() +Write-DevcellLog ('delivered content: ' + $content) +if ($content -ne '` + payloadText + `') { throw ('delivery mismatch: ' + $content) } +Write-DevcellLog '` + outMarker + `' +Start-Sleep -Seconds 20` + + stages := withStageLogging([]GuestStage{{ + Component: "roundtrip", Name: "control volume round trip", Script: body, + }}) + logNames := StageLogNames(stages) + + // Read the volume WHILE the stage is still running: streaming is the + // property under test, so a log that only appears at the end is a fail. + streamed := make(chan bool, 1) + go func() { + deadline := time.Now().Add(4 * time.Minute) + for time.Now().Before(deadline) { + time.Sleep(10 * time.Second) + for _, l := range CollectVolumeLogs(volume, logNames) { + if l.Err == nil && strings.Contains(string(l.Content), outMarker) { + streamed <- true + return + } + } + } + streamed <- false + }() + + require.NoError(t, RunGuestStages(context.Background(), spec, stages, StageRunOptions{ + SSHUser: user, SSHKeyPath: keyPath, LogDir: resultsDir, Observer: testLogObserver{t}, + }), "the round-trip stage must pass: delivery-in is what CELL-402 depends on") + + // Persist whatever the volume holds, pass or fail — this is the evidence. + for _, l := range CollectVolumeLogs(volume, logNames) { + if l.Err != nil { + t.Logf("volume log %s: %v", l.Name, l.Err) + continue + } + writeArtifact(t, resultsDir, "volume-"+l.Name, string(l.Content)) + } + + assert.True(t, <-streamed, + "a log line written mid-stage must reach the host BEFORE the stage ends — "+ + "if this fails, CELL-402 must keep inlining and adopt only the module structure") +} diff --git a/internal/vm/qemu/controlvolume_test.go b/internal/vm/qemu/controlvolume_test.go new file mode 100644 index 0000000..e2142f9 --- /dev/null +++ b/internal/vm/qemu/controlvolume_test.go @@ -0,0 +1,44 @@ +package qemu + +import ( + "path/filepath" + "testing" + + "github.com/devcell-sh/go-winkit/isokit" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The control volume carries work INTO the guest (module, stage scripts) and +// logs back OUT. Delivery-in is the half CELL-402 depends on; it must be +// proven before 17 stages are bet on it. Building it is host-side and cheap +// to test — the guest-side half is the E2E's job. +func TestBuildControlVolume_CarriesMarkerAndPayload(t *testing.T) { + img := filepath.Join(t.TempDir(), "control.img") + payload := map[string][]byte{ + "/devcell/Devcell.psm1": []byte("function Write-DevcellLog {}\r\n"), + "/devcell/stages/wsl2-enable.ps1": []byte("param()\r\n"), + } + require.NoError(t, BuildControlVolume(img, payload)) + + // The marker is what the guest resolves the drive letter by. + marker, err := isokit.ReadFileFromFAT(img, "/"+GuestLogVolumeMarker) + require.NoError(t, err, "the marker must be present or the guest cannot find the volume") + assert.NotEmpty(t, marker) + + for name, want := range payload { + got, err := isokit.ReadFileFromFAT(img, name) + require.NoError(t, err, "payload %s must be readable off the image", name) + assert.Contains(t, string(got), string(want), + "payload %s must round-trip through the FAT image", name) + } +} + +// A volume with no payload is still valid: the log-only case (today's usage) +// must keep working while CELL-402 is in flight. +func TestBuildControlVolume_PayloadIsOptional(t *testing.T) { + img := filepath.Join(t.TempDir(), "logs-only.img") + require.NoError(t, BuildControlVolume(img, nil)) + _, err := isokit.ReadFileFromFAT(img, "/"+GuestLogVolumeMarker) + require.NoError(t, err) +} diff --git a/internal/vm/qemu/devenv.go b/internal/vm/qemu/devenv.go new file mode 100644 index 0000000..c4de00a --- /dev/null +++ b/internal/vm/qemu/devenv.go @@ -0,0 +1,274 @@ +package qemu + +import "github.com/devcell-sh/go-winkit/templates" + +// Dev-env provisioning: the scripts that turn a verified ssh-able image into +// a development VM — virtio drivers + guest agent, project passthrough over +// virtio-fs, WSL2 + NixOS-WSL, and the repo's nixhome home-manager profile. +// +// All of these travel through PowerShellEncodedCommand, so quoting inside is +// written for PowerShell alone with no transport escaping. +// +// Grounding (see .scratch/VIRTIO.md): +// - qemu-ga has no ARM64 build; the x64 MSI under Win11's emulation is the +// confirmed-working path, and it needs the ARM64 vioserial driver first. +// - Every driver here has a native w11/ARM64 build on the virtio-win ISO, +// including virtiofs.exe (the service half of viofs). +// - NixOS-WSL requires WSL2 (WSL1 unsupported); whether the WSL2 utility VM +// boots under our accelerators is measured, not assumed. + +// Script bodies live under templates/devenv/ — see templates.go for why they +// are files rather than Go raw strings. + +// NixOSWSLDistro is the distro name NixOS-WSL's own documentation uses. +const NixOSWSLDistro = "NixOS" + +// WSLDistroUser is the account the WSL side runs as — a DIFFERENT identity +// from the Windows session user (SessionUsername(), the host's $USER, which +// autounattend creates and SSH lands as). +// +// It must equal the username nixhome's WSL home-manager config was built for +// (`wslUser` in nixhome/flake.nix). home-manager refuses to activate a config +// whose username differs from the invoking user: +// +// Error: USER is set to "dmitry" but we expect "nixos" +// +// Conflating the two identities is why home-manager activation had never +// completed: the stages renamed the distro to the Windows user while nix was +// asked for a config built for "nixos". +// +// Parameterizing this per-user is CELL-404; until then the two identities stay +// explicitly separate rather than silently equal. +const WSLDistroUser = "nixos" + +type distroData struct{ Distro string } + +// GenerateDriverTrustScript prepares the guest to accept the Dev-signed viofs +// driver: the signer certificates go into the MACHINE Root and TrustedPublisher +// stores (read back afterwards — exit codes lied twice), and testsigning is +// switched on. Iteration 8 proved the stores and the token were right and +// pnputil still refused: Win11's code-integrity policy rejects non-Microsoft +// kernel packages until testsigning is LIVE, which takes a reboot — so this +// runs as its own stage, before one. +func GenerateDriverTrustScript() string { + return templates.Render("devenv/driver-trust.ps1.tmpl", nil) +} + +// GenerateVirtioAgentInstallScript installs the ARM64 virtio drivers Windows +// did not need during setup (vioserial, viofs, balloon, rng) and then the qemu +// guest agent — the x64 MSI under Win11's emulation, since no ARM64 agent +// build exists (see .scratch/VIRTIO.md). +func GenerateVirtioAgentInstallScript() string { + return templates.Render("devenv/virtio-agent-install.ps1.tmpl", nil) +} + +// GenerateWinFspInstallScript fetches and installs WinFsp, the userspace +// filesystem layer virtiofs.exe requires. +func GenerateWinFspInstallScript() string { + return templates.Render("devenv/winfsp-install.ps1.tmpl", nil) +} + +// GenerateVirtioFSMountScript registers virtiofs.exe (from the driver CD) as a +// service mounting the given tag at the given drive, then proves the mount by +// reading it. Service manager output is kept and dependencies are probed — the +// first version piped sc.exe to Out-Null and a silent failure explained nothing. +func GenerateVirtioFSMountScript(tag, drive string) string { + return templates.Render("devenv/virtiofs-mount.ps1.tmpl", struct { + Tag string + Drive string + }{tag, drive}) +} + +// GenerateVirtualizationProbeScript records whether this guest can host a +// hypervisor — the question that decides WSL1 vs WSL2. Observation only: the +// probe never enables a feature, so a run can report "WSL2 was impossible" +// without having changed the guest to find out. +func GenerateVirtualizationProbeScript() string { + return templates.Render("devenv/virtualization-probe.ps1.tmpl", nil) +} + +// GenerateWSL2EnableScript enables both features WSL2 needs. NixOS-WSL does +// not support WSL1 (https://nix-community.github.io/NixOS-WSL/install.html), +// so VirtualMachinePlatform is required rather than optional. The reboot +// belongs to the caller, which can watch SSH drop and come back. +func GenerateWSL2EnableScript() string { + return templates.Render("devenv/wsl2-enable.ps1.tmpl", nil) +} + +// GenerateWSLEngineInstallScript installs the WSL engine MSI. The inbox +// wsl.exe on current Win11 is a stub — the engine is a separate MSI from the +// microsoft/WSL releases. Installing it tears down the SSH session, so the +// stage runs disconnect-tolerant and reboot-terminated. +func GenerateWSLEngineInstallScript() string { + return templates.Render("devenv/wsl-engine-install.ps1.tmpl", nil) +} + +// GenerateHyperVEnableScript asks Windows to install and launch its +// hypervisor — what the WSL2 utility VM is actually created on. +func GenerateHyperVEnableScript() string { + return templates.Render("devenv/hyperv-enable.ps1.tmpl", nil) +} + +// GenerateHyperVVerifyScript asserts the two independent facts the WSL2 +// utility VM depends on: the hypervisor is INSTALLED, and it is STARTED. They +// fail for different reasons — a missing payload versus a hypervisor that +// cannot launch on emulated EL2 — so they are reported and thrown separately. +func GenerateHyperVVerifyScript() string { + return templates.Render("devenv/hyperv-verify.ps1.tmpl", nil) +} + +// GenerateNixOSWSLImportScript installs the official NixOS-WSL image as a WSL2 +// distro, following the project's own instructions: fetch nixos.wsl from the +// latest release and `wsl --install --from-file` it (WSL 2.4.4+), falling back +// to `wsl --import … --version 2` on older engines. +func GenerateNixOSWSLImportScript() string { + return templates.Render("devenv/nixos-wsl-import.ps1.tmpl", distroData{NixOSWSLDistro}) +} + +// GenerateWSLUserScript renames the distro's default user to the cell's +// session user, following NixOS-WSL's documented procedure. Without it the +// distro runs as "nixos" while every path the cell uses is /home/. +func GenerateWSLUserScript(user string) string { + return templates.Render("devenv/wsl-user.ps1.tmpl", struct { + User string + Distro string + }{user, NixOSWSLDistro}) +} + +// GenerateNixVerifyScript proves the toolchain the NixOS-WSL image already +// carries. NixOS *is* nix — running the upstream installer inside it would be +// both redundant and non-idiomatic. +func GenerateNixVerifyScript() string { + return templates.Render("devenv/nix-verify.ps1.tmpl", distroData{NixOSWSLDistro}) +} + +// GenerateHomeManagerScript links the mounted project share to the agreed repo +// path inside WSL and activates the repo's nixhome via home-manager. +func GenerateHomeManagerScript(user, drive string) string { + return templates.Render("devenv/home-manager.ps1.tmpl", struct { + User string + Mount string + Drive string + Distro string + }{user, "/mnt/" + driveLetterLower(drive), drive, NixOSWSLDistro}) +} + +func driveLetterLower(drive string) string { + if drive == "" { + return "z" + } + c := drive[0] + if c >= 'A' && c <= 'Z' { + c += 'a' - 'A' + } + return string(c) +} + +// GuestStage is one unit of guest-side work: a PowerShell script run over SSH, +// plus the contract it imposes on its caller. It is the single stage type for +// everything the host asks a Windows guest to do — build provisioning and +// dev-env setup alike — so every such pipeline is one table, named and logged +// by the same rules. +type GuestStage struct { + Name string + // Component groups stages that belong to the same subsystem (provisioning, + // drivers, virtiofs, WSL, nix…). All stages of a component share one log, + // so "what happened with WSL" is one file rather than three. + Component string + // Script runs in the guest over SSH (already transport-safe once wrapped + // in PowerShellEncodedCommand). Legacy path: Go-rendered PowerShell. + Script string + // ScriptFile names a real PowerShell file in the embedded guest tree + // (e.g. "wsl2-enable.ps1"), delivered on the control volume and invoked + // by path. Preferred over Script: real files are lintable, runnable + // standalone on a guest, and carry no Go interpolation (CELL-402). + // Args are passed as PowerShell parameters, not string-substituted. + ScriptFile string + Args map[string]string + // Retries is how many extra attempts the caller should make. Zero means + // one attempt. + Retries int + // RebootAfter: the caller must reboot the guest and wait for SSH to come + // back before the next stage. + RebootAfter bool + // ToleratesDisconnect: the stage's work is expected to tear down the SSH + // session (e.g. the WSL engine MSI). A "closed by remote host" failure is + // not a verdict; the next stage verifies the outcome. + ToleratesDisconnect bool +} + +// DevEnvStages returns the ordered dev-env provisioning pipeline. Every +// stage transcripts itself onto the FAT log volume (see BuildDevEnvLogVolume) +// in addition to its SSH output. +func DevEnvStages(user, tag, drive string) []GuestStage { + return withStageLogging(devEnvStages(user, tag, drive)) +} + +func devEnvStages(user, tag, drive string) []GuestStage { + return []GuestStage{ + // Trust must be a separate, reboot-terminated stage: testsigning is + // read at boot, so drivers installed in the same session it was + // enabled in are still rejected by code integrity (iteration 8). + {Component: "drivers", Name: "trust driver signers", Script: GenerateDriverTrustScript(), RebootAfter: true}, + // RebootAfter: pnputil can stage a driver without binding it to the + // live device; the viofs service (VirtioFsDrv) only exists once the + // driver is bound. A reboot makes binding deterministic before + // anything depends on it. + {Component: "drivers", Name: "install virtio drivers and guest agent", Script: GenerateVirtioAgentInstallScript(), RebootAfter: true}, + {Component: "virtiofs", Name: "install WinFsp", Script: GenerateWinFspInstallScript()}, + {Component: "virtiofs", Name: "mount project share", Script: GenerateVirtioFSMountScript(tag, drive)}, + // Before committing to a WSL flavour, record what this nested guest + // can actually host: WSL2 needs a hypervisor, and ours may be absent + // or unusably slow. Observation only — no feature is enabled here. + {Component: "virtualization", Name: "probe virtualization support", Script: GenerateVirtualizationProbeScript()}, + {Component: "WSL", Name: "enable Hyper-V hypervisor", Script: GenerateHyperVEnableScript(), RebootAfter: true}, + {Component: "WSL", Name: "verify Hyper-V running", Script: GenerateHyperVVerifyScript()}, + // PILOT for CELL-402: file-backed. The script is a real .ps1 on the + // control volume, invoked with parameters — no Go-rendered + // PowerShell. The remaining stages convert one at a time behind it. + {Component: "WSL", Name: "enable WSL2 features", + ScriptFile: "wsl2-enable.ps1", RebootAfter: true}, + {Component: "WSL", Name: "install WSL engine", + ScriptFile: "wsl-engine-install.ps1", RebootAfter: true, ToleratesDisconnect: true}, + // Retries: WSL utility-VM starts abort transiently under TCG + // (CreateVm/E_ABORT, run 20260802T103055) and both stages are + // idempotent, so a retry is safe and usually sufficient. + {Component: "WSL", Name: "import NixOS-WSL distro", + ScriptFile: "nixos-import.ps1", + Args: map[string]string{"Distro": NixOSWSLDistro}, Retries: 2}, + // Part of the WSL component, not a separate "nix" phase: NixOS-WSL + // *ships* nix, so proving nix runs is proving the distro imported and + // booted. There is nothing to install. + // The distro must run as the user nixhome's config was built for + // before anything activates into its home — WSLDistroUser, NOT the + // Windows session user (see WSLDistroUser). + {Component: "WSL", Name: "set WSL default user", + ScriptFile: "wsl-user.ps1", + Args: map[string]string{"User": WSLDistroUser, "Distro": NixOSWSLDistro}, Retries: 1}, + {Component: "WSL", Name: "verify nix in NixOS-WSL", + ScriptFile: "nix-verify.ps1", + Args: map[string]string{"Distro": NixOSWSLDistro}, Retries: 2}, + // Retries: the activation downloads and builds inside the WSL2 VM; + // a transient fetch failure should not sink a 40-minute pipeline. + {Component: "home-manager", Name: "activate nixhome home-manager", + ScriptFile: "home-manager.ps1", + Args: map[string]string{ + "User": WSLDistroUser, + "Drive": drive, + "Mount": "/mnt/" + driveLetterLower(drive), + "Distro": NixOSWSLDistro, + }, Retries: 1}, + // Last, and only last: home-manager's activation guard compares $USER + // against the name baked in from nixhome's wslUser ("nixos"), so the + // cell cannot wear the host's identity until activation is done. This + // is the WSL analogue of the Docker entrypoint's session-user step — + // without it `whoami` inside the distro answers "nixos". + {Component: "home-manager", Name: "adopt the host user in the distro", + ScriptFile: "wsl-adopt-user.ps1", + Args: map[string]string{ + "User": user, + "From": WSLDistroUser, + "Distro": NixOSWSLDistro, + }, Retries: 1}, + } +} diff --git a/internal/vm/qemu/devenv_test.go b/internal/vm/qemu/devenv_test.go new file mode 100644 index 0000000..a2fdacc --- /dev/null +++ b/internal/vm/qemu/devenv_test.go @@ -0,0 +1,1233 @@ +package qemu + +import ( + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "testing" + "time" + + "github.com/devcell-sh/go-winkit/unattend" + + "github.com/devcell-sh/go-winkit/isokit" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- dev-env provisioning scripts (Test B: agent, passthrough, WSL, nix) ---- +// +// Every script here travels through PowerShellEncodedCommand, so quoting is +// transport-safe by construction; these tests pin the *commands* — the tools +// invoked and the arguments that matter — not incidental wording. + +func TestGenerateVirtioAgentInstallScript_InstallsARM64DriversAndX64Agent(t *testing.T) { + s := GenerateVirtioAgentInstallScript() + + // Drive letter must be probed, never hardcoded: CD letters move. + assert.NotContains(t, s, "E:\\", "no hardcoded CD drive letter") + assert.Contains(t, s, "pnputil", "drivers install via the inbox tool (VIRTIO.md)") + assert.Contains(t, s, `vioserial\w11\ARM64`, "vioserial is the qemu-ga channel prerequisite") + assert.Contains(t, s, `viofs\w11\ARM64`, "viofs is the passthrough prerequisite") + // No ARM64 agent build exists (VIRTIO.md) — the x64 MSI under Win11's + // emulation is the sanctioned path. + assert.Contains(t, s, "qemu-ga-x86_64.msi") + assert.Contains(t, s, "msiexec") + assert.Contains(t, s, "/qn", "agent MSI must install unattended") + assert.Contains(t, s, "QEMU-GA", "script must report the agent service state") +} + +// The driver-trust story, distilled from dev-env iterations 3–8: viofs is +// Dev-signed (CN=Red Hat Inc., OU=Dev), so its signers must land in the +// MACHINE Root and TrustedPublisher stores (.NET X509Store — Import-Certificate +// throws over SSH and certutil falls back silently), every certificate must +// come from the signatures themselves (chain.Build without a trusted root +// omits the root), the .cat counts as much as the .sys, the stores must be +// read back (exit codes lied twice), and testsigning must be enabled — it is +// read at boot, hence this stage ends in a reboot. +func TestGenerateDriverTrustScript_TrustsSignersIntoMachineStores(t *testing.T) { + s := GenerateDriverTrustScript() + + assert.Contains(t, s, "X509Store", + "machine store writes go through the .NET API — cmdlet and certutil both mislead over SSH") + assert.Contains(t, s, "'LocalMachine'", + "driver trust reads the MACHINE stores; a user-store write is a silent no-op") + assert.Contains(t, s, "machine ", + "the store must be read back after writing — exit codes have lied twice") + assert.Contains(t, s, "TrustedPublisher", + "the signer certificate must be trusted for driver installation") + assert.Contains(t, s, "*.cat", + "driver install trusts the catalog's publisher — trust the .cat signer too") + assert.Contains(t, s, "X509Certificate2Collection", + "import ALL embedded signature certs — a chain built without its root omits the root") + assert.Contains(t, s, "testsigning", + "a Dev-signed kernel driver cannot be installed or loaded under enforced code integrity") +} + +// Iteration 3: the script printed "driver installed" over pnputil's failure. +// Native tools only speak through exit codes; a rejected driver must fail +// the stage. +func TestGenerateVirtioAgentInstallScript_FailsOnRejectedDriver(t *testing.T) { + s := GenerateVirtioAgentInstallScript() + + assert.Contains(t, s, "$LASTEXITCODE", + "pnputil is native; only its exit code says whether the add worked") + assert.Contains(t, s, "throw", + "a rejected driver must fail the stage, not print 'installed'") + assert.Contains(t, s, "testsigning state", + "the stage must record the policy it ran under — iteration 8 ran under the wrong one") +} + +func TestGenerateWinFspInstallScript_UnattendedFromRelease(t *testing.T) { + s := GenerateWinFspInstallScript() + + assert.Contains(t, s, "winfsp", "WinFsp is the FUSE layer virtiofs.exe requires") + assert.Contains(t, s, "msiexec") + assert.Contains(t, s, "/qn") + assert.Contains(t, s, "Invoke-WebRequest", "installer comes over the guest's own network") +} + +func TestGenerateVirtioFSMountScript_MountsTagAndVerifies(t *testing.T) { + s := GenerateVirtioFSMountScript("devcell", "Z:") + + assert.Contains(t, s, "virtiofs.exe", "the ARM64 service binary from the driver ISO") + assert.Contains(t, s, "devcell", "must mount the host-side tag") + assert.Contains(t, s, "Z:", "must surface the share as the requested drive") + assert.Contains(t, s, "Get-ChildItem", "mounting without reading proves nothing") +} + +// First Test B run (20260801T013317): virtiofsd logged "Client connected, +// servicing requests" — and Z: still never appeared. Whatever sc.exe had to +// say about why was piped to Out-Null. The mount script must keep service +// manager output, declare the documented dependency chain, and poll rather +// than hope a fixed 5s is enough under TCG. +func TestGenerateVirtioFSMountScript_KeepsServiceDiagnostics(t *testing.T) { + s := GenerateVirtioFSMountScript("devcell", "Z:") + + // Iteration 2 hardcoded virtio-win's documented dependency string and got + // error 1075: on this guest at least one of the two services does not + // exist under that name. Candidates are probed and only existing ones + // become dependencies. + assert.Contains(t, s, "WinFsp.Launcher") + assert.Contains(t, s, "VirtioFsDrv") + assert.Contains(t, s, "Get-Service", + "dependencies must be probed, not assumed — error 1075 taught that") + assert.Contains(t, s, "sc.exe query VirtioFsSvc", + "the service state is the first thing a mount failure needs") + assert.Contains(t, s, "Get-PSDrive", + "if the mount landed on another letter, the drive list says so") + assert.NotRegexp(t, `sc\.exe [^\n]*\| Out-Null`, s, + "discarding sc.exe output is how the first failure explained nothing") +} + +// NixOS-WSL requires WSL2 (https://nix-community.github.io/NixOS-WSL/install.html +// — "WSL 2 is required, WSL 1 not supported"), so the guest must gain both +// features: the WSL subsystem and VirtualMachinePlatform, which is what +// carries the WSL2 utility VM. +func TestGenerateWSL2EnableScript_EnablesBothFeatures(t *testing.T) { + s := GenerateWSL2EnableScript() + + assert.Contains(t, s, "Microsoft-Windows-Subsystem-Linux") + assert.Contains(t, s, "VirtualMachinePlatform", + "WSL2 — and therefore NixOS-WSL — cannot run without it") + assert.Contains(t, s, "Enable-WindowsOptionalFeature") + assert.Contains(t, s, "-NoRestart", "the caller owns the reboot, not the script") +} + +// The release ships one image per architecture; the asset name must follow +// the guest. Run 20260802: the hardcoded nixos.wsl (x86_64) imported cleanly +// on ARM64 Windows and then every exec inside the distro died with ENOEXEC +// (execv errno 8) — the utility VM, kernel, and mounts were all fine. +func TestGenerateNixOSWSLImportScript_PicksAssetByGuestArch(t *testing.T) { + s := GenerateNixOSWSLImportScript() + + assert.Contains(t, s, "nixos.aarch64.wsl", + "ARM64 Windows needs the aarch64 image — the x86_64 one imports fine and init dies with ENOEXEC") + assert.Contains(t, s, "PROCESSOR_ARCHITECTURE", + "the asset must be chosen by the guest's architecture, not hardcoded") +} + +// WSL's defaults assume real hardware. Under TCG double emulation the +// utility-VM kernel needs far more than the default 30s KernelBootTimeout +// (WslCoreConfig.h), and vGPU setup (the FlexibleIov device WSLg adds) has +// no partitionable GPU to bind. Both must be configured before any wsl.exe +// VM operation, so the engine-install stage owns writing .wslconfig. +func TestGenerateWSLEngineInstallScript_ConfiguresWslForEmulatedHosts(t *testing.T) { + s := GenerateWSLEngineInstallScript() + + assert.Contains(t, s, ".wslconfig", + "the settings live in the user's .wslconfig, written before first VM start") + assert.Contains(t, s, "kernelBootTimeout=3600000", + "15 min was still short on a loaded host (run 20260802T125133 timed out at ~19 min)") + assert.Contains(t, s, "distributionStartTimeout", + "distro start shares the same emulation slowness as kernel boot") + assert.Contains(t, s, "gpuSupport=false", + "vGPU hot-add is the last HCS operation before wslservice died with E_UNEXPECTED") + assert.Contains(t, s, "guiApplications=false", + "WSLg has no display to serve in a headless cell and drags vGPU back in") + assert.Contains(t, s, "processors=4", + "TCG ARM64 degrades above 4 vCPUs (Linaro benchmarks) — 4 is the sweet spot") + assert.Contains(t, s, "memory=4GB", + "WSL needs enough RAM for the NixOS utility VM under double emulation") +} + +// The distro is NixOS-WSL's own image, imported as WSL2 per the project's +// install docs. Nothing is "installed into" it: NixOS ships nix, so a +// separate nix-install stage would be both redundant and non-idiomatic. +func TestGenerateNixOSWSLImportScript_ImportsOfficialImageAsWSL2(t *testing.T) { + s := GenerateNixOSWSLImportScript() + + assert.Contains(t, s, "wsl engine still missing", + "import must verify the engine the previous stage claimed to install") + assert.Contains(t, s, "WSL_UTF8", "wsl.exe output is unreadable UTF-16 without it") + assert.Contains(t, s, "$LASTEXITCODE", + "with 'Stop' unusable around wsl.exe, exit codes are the only failure signal") + + assert.Contains(t, s, "api.github.com/repos/nix-community/NixOS-WSL", + "the image comes from the NixOS-WSL releases, not a generic rootfs") + assert.Contains(t, s, "nixos.wsl", "current releases ship nixos.wsl") + assert.Contains(t, s, "wsl --set-default-version 2", + "NixOS-WSL does not support WSL1") + assert.Contains(t, s, "--version 2", "the import must be a WSL2 import") + assert.Contains(t, s, "--from-file", + "WSL 2.4.4+ installs a .wsl image directly — that is the documented path") + assert.Contains(t, s, "nixos-version", + "the proof is NixOS answering, not merely an import that returned 0") + // The WSL1 vocabulary must be gone. + assert.NotContains(t, s, "--set-default-version 1") + assert.NotContains(t, s, "ubuntu") +} + +// The cell's user is the host's user on every engine (Docker cells create +// $HOST_USER; the Windows session is $HOST_USER). The WSL distro must match +// — NixOS-WSL otherwise defaults to "nixos", leaving the repo symlink at +// /home/ owned by a user that does not exist inside the distro. +// Official procedure: nix-community.github.io/NixOS-WSL/how-to/change-username.html +func TestGenerateWSLUserScript_SetsDefaultUserToSessionUser(t *testing.T) { + s := GenerateWSLUserScript("dmitry") + + assert.Contains(t, s, "wsl.defaultUser", "the option that renames the distro's default user") + assert.Contains(t, s, "dmitry") + assert.Contains(t, s, "nixos-rebuild boot", + "the docs are explicit: boot, not switch — switch misconfigures the account") + assert.NotContains(t, s, "nixos-rebuild switch") + assert.Contains(t, s, "--terminate", + "the distro must be cycled for the new generation's user to take effect") + assert.Contains(t, s, "extraGroups", "the cell user needs sudo (wheel) inside the distro") +} + +// --- the WSL distro user is NOT the Windows session user --------------------- + +// Two identities, deliberately separate: +// +// - the WINDOWS account is the host's $USER (autounattend creates it, SSH +// lands as it) — unattend.SessionUsername() +// - the WSL DISTRO user is whoever nixhome's home-manager config was built +// for — WSLDistroUser +// +// Conflating them is what made home-manager unactivatable: nixhome pins +// `wslUser = {username = "nixos"; ...}` (nixhome/flake.nix:210) while the +// stages renamed the distro to the Windows user, and home-manager's activation +// guard rejects a config whose username differs from the invoking user: +// +// Error: USER is set to "dmitry" but we expect "nixos" +// +// Verified on the host: the shipped wsl-base-aarch64 activation package clears +// the username guard as USER=nixos. So the WSL-side stages must address the +// distro user, and only the distro user. +func TestWSLDistroUser_MatchesTheNixhomeWSLConfig(t *testing.T) { + assert.Equal(t, "nixos", WSLDistroUser, + "nixhome's wslUser pins this name; changing one without the other "+ + "breaks home-manager activation") +} + +func TestDevEnvStages_WSLStagesAddressTheDistroUserNotTheWindowsUser(t *testing.T) { + const windowsUser = "dmitry" + require.NotEqual(t, windowsUser, WSLDistroUser, + "this test is meaningless unless the two identities actually differ") + + byName := map[string]GuestStage{} + for _, st := range DevEnvStages(windowsUser, "devcell", "Z:") { + byName[st.Name] = st + } + + wslUser, ok := byName["set WSL default user"] + require.True(t, ok, "stage not found") + assert.Equal(t, WSLDistroUser, wslUser.Args["User"], + "the rename target is the distro user nixhome was built for") + + hm, ok := byName["activate nixhome home-manager"] + require.True(t, ok, "stage not found") + assert.Equal(t, WSLDistroUser, hm.Args["User"], + "home-manager activates into the distro user's home") + + // The stage is file-backed, so what travels over SSH is an INVOCATION. + // The distro user must reach the script as a parameter, and the Windows + // user must not appear anywhere in it. + payload := stagePayload(hm) + assert.Contains(t, payload, "-User '"+WSLDistroUser+"'") + assert.NotContains(t, payload, windowsUser, + "the Windows user has no home inside the distro") +} + +// The cell must finally run as the HOST user, like every other engine. +// +// Docker gets there by building the profile for a fixed user (`devcell`) and +// remapping in the entrypoint; WSL builds for `nixos` and never remaps, so +// `whoami` inside the distro answers "nixos" (run 20260803T231223). The fix is +// the WSL analogue of that entrypoint step, and its ORDER is load-bearing: +// +// - home-manager's activation guard is `checkUsername `, +// compared against $USER at activation time only. nixhome pins +// wslUser.username = "nixos", so activation MUST run as nixos. +// - activation is a one-time build step. Afterwards the result is store +// paths plus symlinks in /home/nixos, and the guard never runs again — +// so the host user can be introduced safely after it. +// +// Renaming before activation is what produced +// `Error: USER is set to "dmitry" but we expect "nixos"`. +func TestDevEnvStages_HostUserBecomesTheDistroUserAfterActivation(t *testing.T) { + const windowsUser = "dmitry" + stages := DevEnvStages(windowsUser, "devcell", "Z:") + + idx := func(name string) int { + for i, st := range stages { + if st.Name == name { + return i + } + } + return -1 + } + + activate := idx("activate nixhome home-manager") + require.GreaterOrEqual(t, activate, 0, "activation stage not found") + + adopt := idx("adopt the host user in the distro") + require.GreaterOrEqual(t, adopt, 0, + "no stage makes the host user the distro's user — `whoami` stays %q", + WSLDistroUser) + + assert.Greater(t, adopt, activate, + "the host user must be adopted AFTER activation; before it, "+ + "home-manager's checkUsername guard rejects the config") + + assert.Equal(t, windowsUser, stages[adopt].Args["User"], + "the stage adopts the HOST user, not the build-time distro user") +} + +// NixOS ships nix; the old curl|sh single-user install has no place here. +// This stage only proves the toolchain the image already carries. +func TestGenerateNixVerifyScript_UsesTheDistrosOwnNix(t *testing.T) { + s := GenerateNixVerifyScript() + + assert.Contains(t, s, "nix --version") + assert.Contains(t, s, NixOSWSLDistro) + assert.NotContains(t, s, "nixos.org/nix/install", + "NixOS already has nix — installing it again is not idiomatic") + assert.NotContains(t, s, "--no-daemon", "that is the non-NixOS single-user path") +} + +// The stage must record the distro's environment: USER/HOME/PATH decide +// where home-manager activates and whether its CLI is reachable, and +// Windows-interop entries on PATH are what let the cell call Windows tools. +// Recording beats asking a running guest — SSH is unusable while a stage +// saturates it. +func TestGenerateNixVerifyScript_RecordsGuestEnvironment(t *testing.T) { + s := GenerateNixVerifyScript() + + for _, want := range []string{"$USER", "$HOME", "$PATH"} { + assert.Contains(t, s, want, "the stage log must carry %s for later diagnosis", want) + } +} + +func TestGenerateHomeManagerScript_ActivatesNixhomeViaShare(t *testing.T) { + s := GenerateHomeManagerScript("dmitry", "Z:") + + assert.Contains(t, s, "/mnt/z", "WSL sees the mounted share as a drvfs drive") + assert.Contains(t, s, "/home/dmitry/dev/dimmkirr/devcell", + "the repo must appear at the agreed path inside WSL") + assert.Contains(t, s, "nixhome", "activation targets the repo's nixhome") + assert.Contains(t, s, "home-manager") + assert.Contains(t, s, "$LASTEXITCODE", + "native wsl calls fail via exit code, not exceptions") +} + +// A failing activation must fail the stage. +// +// Run 20260803T231223 lost 9 minutes to this: the activation command ended in +// `| tail -40`, and in a shell pipeline $? is the LAST command's status. tail +// always succeeds, so nix's +// +// error: opening lock file "/nix/var/nix/db/big-lock": Permission denied +// +// exited non-zero into a pipe that reported success. Assert-DevcellExitCode +// saw 0 and the step logged "ok in 36s". `set -e` does not catch it either — +// the pipeline as a whole succeeded. Only the NEXT step (home-manager +// --version, exit 127) revealed that nothing had been activated. +// "verify nix" must verify what the next stage needs. +// +// Run 20260803T231223: the verify stage passed on `nix --version` and the +// activation then died with +// +// error: opening lock file "/nix/var/nix/db/big-lock": Permission denied +// This command may have been run as non-root in a single-user Nix +// installation, or the Nix daemon may have crashed. +// +// NixOS is a MULTI-user store: nix-daemon mediates every write, and nothing +// in the pipeline configures or checks it. `nix --version` answers fine on a +// store the invoking user cannot write, so the verify stage certified a +// distro that could not build — 13 minutes before the stage that needed it. +func TestGenerateNixVerifyScript_ProvesTheStoreIsWritableNotJustThatNixAnswers(t *testing.T) { + s := GenerateNixVerifyScript() + + assert.Contains(t, s, "nix --version", "sanity: this is the verify script") + + assert.True(t, + strings.Contains(s, "nix-daemon") || strings.Contains(s, "systemctl"), + "the stage must record whether nix-daemon is reachable — without it a "+ + "non-root build fails on /nix/var/nix/db/big-lock, and `nix --version` "+ + "cannot tell you that") + + assert.True(t, + strings.Contains(s, "nix build") || strings.Contains(s, "nix-build") || + strings.Contains(s, "nix-store --add") || strings.Contains(s, "nix store add"), + "verification must exercise a real store WRITE as the invoking user; "+ + "otherwise the next stage is the first thing to discover the store "+ + "is read-only to it") +} + +func TestGenerateHomeManagerScript_PipeCannotSwallowAFailedActivation(t *testing.T) { + s := GenerateHomeManagerScript("dmitry", "Z:") + + require.Contains(t, s, "home-manager", "sanity: this is the activation script") + + // Truncating output is fine; losing the status is not. Either drop the + // pipe or make the shell propagate the left-hand status. Matches a real + // pipe-into-command, not the `||` in the arch-suffix expression. + pipedSwitch := regexp.MustCompile(`switch[^\n]*[^|]\|[^|]\s*\w`) + if loc := pipedSwitch.FindString(s); loc != "" { + assert.True(t, + strings.Contains(s, "pipefail") || strings.Contains(s, "PIPESTATUS"), + "the activation pipes its output (%q) without pipefail/PIPESTATUS — "+ + "$? becomes the pipe's last command and a failed "+ + "`home-manager switch` reports success", loc) + } +} + +// The activation must follow the official standalone-flake path +// (https://nix-community.github.io/home-manager/installation.html) on this +// guest: nix through a login shell, the home-manager release branch matching +// the NixOS release, and the flake attr the nixhome flake actually defines +// for this architecture ("-aarch64" suffix per its own comment). +func TestGenerateHomeManagerScript_OfficialFlakePathForThisGuest(t *testing.T) { + s := GenerateHomeManagerScript("dmitry", "Z:") + + assert.Contains(t, s, "-lc", + "nix is only on PATH in a login shell (run 20260802: bare wsl -- nix is exit 127)") + assert.Contains(t, s, "home-manager/release-", + "the runner must be pinned to the release branch matching NixOS, not floating master") + assert.Contains(t, s, "--extra-experimental-features nix-command --extra-experimental-features flakes", + "repeat the flag: an inner-quoted \"nix-command flakes\" loses its quotes crossing "+ + "PowerShell -> wsl.exe -> sh -lc and nix sees no subcommand (run 20260802T112212)") + assert.NotRegexp(t, `--extra-experimental-features "`, s, + "no embedded double quotes in the guest command line") + assert.Contains(t, s, "wsl-base", + "WSL activates the wsl-* configs: their user is the distro default (nixos), not the Docker cell's devcell") + assert.Contains(t, s, "aarch64", + "aarch64 guests need the -aarch64 config suffix (flake.nix's own contract)") + assert.Contains(t, s, "uname -m", + "the suffix must follow the guest architecture, not be hardcoded") +} + +// "Installed" means the CLI answers afterwards: the stage must assert +// `home-manager --version` prints an actual semantic version, not merely +// that the switch exited 0. +func TestGenerateHomeManagerScript_AssertsSemanticVersion(t *testing.T) { + s := GenerateHomeManagerScript("dmitry", "Z:") + + assert.Contains(t, s, "home-manager --version", + "the proof of installation is the CLI answering from the activated profile") + assert.Regexp(t, `grep -E.*[0-9].*\\.`, s, + "the version output must be matched against a semantic-version pattern") + assert.Contains(t, s, "throw", + "a missing or unversioned home-manager must fail the stage") +} + +// The engine install tears the SSH session down mid-MSI (iteration 10) — the +// stage must declare that so the harness treats the drop as expected rather +// than a failure, and must fetch the ARM64 package from microsoft/WSL. +func TestGenerateWSLEngineInstallScript_FetchesARM64Engine(t *testing.T) { + s := GenerateWSLEngineInstallScript() + + assert.Contains(t, s, "api.github.com/repos/microsoft/WSL", + "the WSL engine package comes from the microsoft/WSL releases") + assert.Contains(t, s, "arm64.msi", "the guest is ARM64 — so is the WSL package") + assert.Contains(t, s, "msiexec") + // Iteration 11: the probe itself threw — 'Stop' turns native stderr into + // an exception, and wsl.exe speaks UTF-16 unless told otherwise. + assert.Contains(t, s, "WSL_UTF8", + "wsl.exe output is UTF-16 null soup without WSL_UTF8=1 — nothing matches it") + assert.Contains(t, s, "$ErrorActionPreference = 'Continue'", + "the probe must not throw on the stderr message it exists to read") + + for _, st := range DevEnvStages("dmitry", "devcell", "Z:") { + if st.Name == "install WSL engine" { + assert.True(t, st.ToleratesDisconnect, + "the MSI kills the SSH session — the stage must say so") + assert.True(t, st.RebootAfter, + "engine services want a clean boot before first use") + return + } + } + t.Fatal("no 'install WSL engine' stage in DevEnvStages") +} + +// The stages must run in dependency order: drivers before WinFsp before the +// mount that needs both; WSL feature before the import that needs it; nix +// before home-manager. +func TestDevEnvStages_Order(t *testing.T) { + stages := DevEnvStages("dmitry", "devcell", "Z:") + + var names []string + for _, st := range stages { + names = append(names, st.Name) + // A stage is executable either as a real script file on the control + // volume (CELL-402) or as legacy rendered PowerShell — never neither. + require.NotEmpty(t, stagePayload(st), "stage %s has no executable payload", st.Name) + } + joined := strings.Join(names, " → ") + + require.Less(t, indexOf(names, "trust driver signers"), indexOf(names, "install virtio drivers and guest agent"), joined) + require.Less(t, indexOf(names, "install virtio drivers and guest agent"), indexOf(names, "install WinFsp"), joined) + require.Less(t, indexOf(names, "install WinFsp"), indexOf(names, "mount project share"), joined) + require.Less(t, indexOf(names, "enable WSL2 features"), indexOf(names, "install WSL engine"), joined) + require.Less(t, indexOf(names, "install WSL engine"), indexOf(names, "import NixOS-WSL distro"), joined) + require.Less(t, indexOf(names, "import NixOS-WSL distro"), indexOf(names, "verify nix in NixOS-WSL"), joined) + + // nix verification is part of the WSL component: NixOS-WSL ships nix, so + // it proves the import, it does not install anything. + for _, st := range stages { + if st.Name == "verify nix in NixOS-WSL" { + require.Equal(t, "WSL", st.Component, + "verifying nix proves the NixOS-WSL import — it is not its own phase") + } + } + require.Less(t, indexOf(names, "verify nix in NixOS-WSL"), indexOf(names, "activate nixhome home-manager"), joined) +} + +func indexOf(ss []string, want string) int { + for i, s := range ss { + if s == want { + return i + } + } + return -1 +} + +// TestWindowsDevEnv_QEMU builds the dev environment on top of the verified +// ssh-able image: virtio drivers + guest agent, project passthrough over +// virtio-fs, WSL1, nix, and the repo's nixhome home-manager profile. +// +// It boots an overlay — the ssh-able image itself is never written. +// +// Run explicitly, after TestSSHAble_ConnectAndListFiles has produced the image: +// +// DEVCELL_TEST_DEVENV=1 go test -run TestWindowsDevEnv_QEMU -timeout 6h -v ./internal/vm/qemu/ +func TestWindowsDevEnv_QEMU(t *testing.T) { + if testing.Short() { + t.Skip("long: boots the ssh-able Windows image and provisions a dev environment") + } + if os.Getenv("DEVCELL_TEST_DEVENV") == "" { + t.Skip("set DEVCELL_TEST_DEVENV=1 to run the dev-env provisioning test") + } + requireQEMUBin(t) + + home := filepath.Join(repoRoot(t), "test", "testdata", "cellhome") + + // Resume from the furthest checkpoint available. A WSL-ready image already + // carries the drivers, the share and the WSL engine, so iterating on the + // distro itself costs a boot instead of the ~40-minute prelude. + baseImage, err := LatestWSLReadyTestImage(testdataDir(t)) + resumeAt := wslReadyCheckpointStage + if err != nil { + t.Logf("no WSL-ready checkpoint (%v) — starting from ssh-able", err) + baseImage, err = LatestSSHAbleTestImage(testdataDir(t)) + if err != nil { + t.Skipf("no ssh-able image: %v", err) + } + resumeAt = "" + } + t.Logf("building on image: %s (resume at: %q)", baseImage, resumeAt) + + resultsDir := testResultsDir(t) + workDir := t.TempDir() + repo := repoRoot(t) + + overlay := filepath.Join(workDir, "devenv.qcow2") + require.NoError(t, CloneDisk(baseImage, overlay)) + varsSrc, err := os.ReadFile(filepath.Join(TemplateDir(home, "base", nil), "vars.fd")) + require.NoError(t, err) + varsPath := filepath.Join(workDir, "vars.fd") + require.NoError(t, os.WriteFile(varsPath, varsSrc, 0o644)) + + // Host side of the passthrough. Without virtiofsd the mount stage cannot + // pass — surface that as a stage failure with a clear message, not a + // silent skip: the passthrough is part of what this test exists to prove. + const shareTag = "devcell" + virtioFSSock := filepath.Join(workDir, "virtiofs.sock") + virtiofsd := os.Getenv("DEVCELL_VIRTIOFSD") + if virtiofsd == "" { + virtiofsd, _ = exec.LookPath("virtiofsd") + } + require.NotEmpty(t, virtiofsd, + "virtiofsd not found: set DEVCELL_VIRTIOFSD or put it on PATH (nix build nixpkgs#virtiofsd)") + // virtiofsd is not a start-once service: it exits as soon as its client + // disconnects ("Client disconnected, shutting down"), so every time the VM + // goes away — the checkpoint power-off included — it must be started again + // or the next QEMU finds a dead vhost-user socket and never boots. + // + // host- prefix, no sequence number: this is a host service that spans the + // whole run, not a pipeline stage. Sequence numbers mean "position in the + // stage order" and would be a lie here. + startFSD := func() { + t.Helper() + fsd := exec.Command(virtiofsd, + "--socket-path", virtioFSSock, + "--shared-dir", repo, + "--sandbox", "none") + fsdLog, err := os.OpenFile(filepath.Join(resultsDir, "host-virtiofsd.log"), + os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + require.NoError(t, err) + fsd.Stdout, fsd.Stderr = fsdLog, fsdLog + require.NoError(t, fsd.Start()) + t.Cleanup(func() { + if fsd.Process != nil { + _ = fsd.Process.Kill() + } + _ = fsd.Wait() + _ = fsdLog.Close() + }) + } + startFSD() + + keyPath := filepath.Join(home, ".devcell", "main", "qemu", "id_ed25519") + user := unattend.SessionUsername() + const shareDrive = "Z:" + + // The FAT log volume: guest-side stage transcripts the host can read off + // the image even when SSH and the run are gone — install-test logic, + // shared with every other QEMU test via attachGuestLogVolume. + allStages := DevEnvStages(user, shareTag, shareDrive) + stages := stagesFrom(t, allStages, resumeAt) + stageLogNames := StageLogNames(stages) + logVolume := attachGuestLogVolume(t, workDir, resultsDir, stageLogNames) + + spec := Spec{ + VMName: "devcell-qemu-devenv", + CPUs: 4, + MemoryGB: 6, + DiskPath: overlay, + FirmwarePath: FirmwarePath(), + VarsPath: varsPath, + VirtioISO: VirtioISOPath(home), + SSHHost: "127.0.0.1", + SSHPort: freeTCPPort(10222), + MACAddr: DeterministicMAC("devcell-qemu-devenv"), + QMPSocketDir: workDir, + DiskCacheMode: "unsafe", + GuestAgentSocketPath: filepath.Join(workDir, "qga.sock"), + VirtioFSSocketPath: virtioFSSock, + VirtioFSTag: shareTag, + LogVolumePath: logVolume, + NestedVirt: true, + } + spec.ApplyDefaults() + require.NoError(t, spec.Validate()) + + vmDone := startVM(t, spec) + defer vmDone.stop() + + qmpSock := QMPSocketPath(spec) + waitSSH := func(phase string, timeout time.Duration) { + require.NoError(t, + WaitForSSH(spec.SSHHost, spec.SSHPort, timeout, 5*time.Second, testLogObserver{t}, vmStateFn(qmpSock)), + "SSH must come back: %s", phase) + } + waitSSH("initial boot of the ssh-able image", time.Hour) + + // The stage table drives subtests: each reports pass/fail under its own + // sequenced name (`-run 'TestWindowsDevEnv_QEMU/03-install-WinFsp'` to + // re-read one), while its output appends to the component's log — so a + // subtest identifies the step and the log covers the whole subsystem. + for i, stage := range stages { + i, stage := i, stage + logName := stageLogNames[i] + subtestName := fmt.Sprintf("%02d-%s", i+1, strings.ReplaceAll(stage.Name, " ", "-")) + ok := t.Run(subtestName, func(t *testing.T) { + // Streamed, not buffered: a long stage (nix install) must be + // observable while it runs — `tail -f` the artifact. + livePath := filepath.Join(resultsDir, logName) + out, runErr := sshStream(spec, user, keyPath, stage.Script, livePath, stageTimeout) + if runErr != nil && stage.ToleratesDisconnect && strings.Contains(out, "closed by remote host") { + t.Logf("dropped the SSH session as expected — the next stage verifies the outcome") + } else { + require.NoError(t, runErr, "stage %q failed:\n%s", stage.Name, out) + } + t.Logf("output:\n%s", tailLines(out, 15)) + + // The stage before the resume point is the checkpoint: its reboot + // becomes a clean shutdown, so the overlay can be saved as a + // WSL-ready image and every later run skips straight to here. + if resumeAt == "" && i+1 < len(stages) && stages[i+1].Name == wslReadyCheckpointStage { + t.Logf("checkpoint — powering off to save a WSL-ready image") + _, _ = sshTry(spec, user, keyPath, "Stop-Computer -Force") + select { + case <-vmDone.done: + t.Log("guest powered off cleanly") + case <-time.After(guestShutdownTimeout): + t.Logf("guest did not power off in %s — forcing stop; checkpoint may be dirty", + guestShutdownTimeout) + vmDone.stop() + } + dest := filepath.Join(testdataDir(t), WSLReadyTestImageName(time.Now())) + require.NoError(t, SaveBaseProfileImage(overlay, dest)) + info, statErr := os.Stat(dest) + require.NoError(t, statErr) + t.Logf("WSL-ready image saved: %s (%.1f GB) — later runs resume at %q", + dest, float64(info.Size())/(1<<30), wslReadyCheckpointStage) + + startFSD() // the old one exited with the VM + vmDone = startVM(t, spec) + waitSSH("boot after checkpoint", 45*time.Minute) + return + } + if stage.RebootAfter { + t.Logf("needs a reboot — restarting the guest") + _, _ = sshTry(spec, user, keyPath, "Restart-Computer -Force") + time.Sleep(30 * time.Second) // let the old sshd actually go down + waitSSH("reboot after "+stage.Name, 45*time.Minute) + } + }) + // Stages are strictly dependent: continuing past a failure only + // produces confusing downstream errors. + if !ok { + t.Fatalf("stage %d/%d %q failed — see %s", i+1, len(stages), stage.Name, + filepath.Join(resultsDir, logName)) + } + } + + // The final proof the user asked for: the repo is visible inside WSL at + // the agreed path, through the passthrough. + out := sshCapture(t, spec, user, keyPath, + fmt.Sprintf(`$env:WSL_UTF8='1'; wsl -d devcell -u %s -- ls /home/%s/dev/dimmkirr/devcell`, user, user)) + require.Contains(t, out, "go.mod", "the repo must be readable inside WSL through the share") + // result- prefix: an assertion's evidence, not a stage log or a service log. + writeArtifact(t, resultsDir, "result-wsl-repo-listing.txt", out) + + // Everything proved: this overlay now holds the finished dev environment + // — the "base profile" state `cell build --engine=qemu` is meant to end + // at. Shut the guest down cleanly (NTFS must be quiesced before the disk + // is copied) and flatten the overlay into the base-profile image. + t.Log("all stages green — shutting down to save the base-profile image") + _, _ = sshTry(spec, user, keyPath, "Stop-Computer -Force") + select { + case <-vmDone.done: + t.Log("guest powered off cleanly") + case <-time.After(guestShutdownTimeout): + t.Logf("guest did not power off in %s — forcing stop; image save may be dirty", + guestShutdownTimeout) + vmDone.stop() + } + + dest := BaseProfileImagePath(home, "base", nil) + require.NoError(t, SaveBaseProfileImage(overlay, dest)) + info, err := os.Stat(dest) + require.NoError(t, err) + require.Greater(t, info.Size(), int64(1<<30), + "base-profile image should hold Windows + WSL + nix, got %d bytes", info.Size()) + t.Logf("base-profile image saved: %s (%.1f GB)", dest, float64(info.Size())/(1<<30)) +} + +// --- shared VM harness helpers ---------------------------------------------- + +type vmHandle struct { + cmd *exec.Cmd + done chan error +} + +func (h *vmHandle) stop() { + if h.cmd.Process != nil { + _ = h.cmd.Process.Kill() + } + <-h.done +} + +func startVM(t *testing.T, spec Spec) *vmHandle { + t.Helper() + exclusiveQEMU(t) + argv := BuildRunCommand(spec) + t.Logf("booting: %s", strings.Join(argv, " ")) + cmd := exec.Command(argv[0], argv[1:]...) + // QEMU's dying words go to stderr. Run 20260802T083354 lost its VM-exit + // cause because this was discarded — persist it with the run's evidence. + qemuLog, err := os.OpenFile(filepath.Join(testResultsDir(t), "qemu-stderr.log"), + os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err == nil { + cmd.Stdout, cmd.Stderr = qemuLog, qemuLog + t.Cleanup(func() { _ = qemuLog.Close() }) + } + require.NoError(t, cmd.Start()) + done := make(chan error, 1) + go func() { + err := cmd.Wait() + // The exit status is evidence: "signal: killed" with an empty stderr + // means an external kill (OOM et al.), not a QEMU error. Written to + // the file, not t.Logf — the test may already be past its end. + if qemuLog != nil { + fmt.Fprintf(qemuLog, "\n=== qemu exited: %v (%s)\n", err, time.Now().UTC().Format(time.RFC3339)) + } + done <- err + }() + return &vmHandle{cmd: cmd, done: done} +} + +// vmStateFn adapts QueryVMState for WaitForSSH, treating "socket not up yet" +// as still-running so early boot does not read as VM death. +func vmStateFn(qmpSock string) VMStateFunc { + return func() VMState { + s, err := QueryVMState(qmpSock) + if err != nil { + return StateRunning + } + return s + } +} + +// sshTry runs a script in the guest and returns output + error without +// failing the test — stages own their error reporting. +func sshTry(spec Spec, user, keyPath, script string) (string, error) { + argv := BuildSSHExecArgv(spec.SSHHost, spec.SSHPort, user, keyPath, + PowerShellEncodedCommand(script)) + out, err := exec.Command(argv[0], argv[1:]...).CombinedOutput() + return string(out), err +} + +// appendToFile is teeToFile in append mode: component logs accumulate across +// the stages that belong to them instead of each stage truncating the last. +func appendToFile(path string, mem io.Writer) (io.Writer, func() error, error) { + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return nil, nil, fmt.Errorf("opening component log %s: %w", path, err) + } + return io.MultiWriter(f, mem), f.Close, nil +} + +// wslReadyCheckpointStage is where a WSL-ready image resumes; everything +// before it — drivers, the share, the WSL2 features — is baked in. +// +// It deliberately stops short of the engine install. That stage tolerates its +// SSH session dropping (`wsl --install` tears it down), so "it did not fail" +// is not the same as "it finished": checkpointing after it saved a guest whose +// engine was half-registered, and every resume then started from that broken +// state with no way to repair it. A checkpoint may only follow a stage whose +// success was *verified* — here, `state VirtualMachinePlatform: Enabled` read +// back from the guest. The engine install is cheap and self-verifying, so it +// re-runs on every resume. +const wslReadyCheckpointStage = "enable Hyper-V hypervisor" + +// stagesFrom drops the stages a checkpoint image has already been through. +// An empty name runs everything; an unknown name is a programming error, not +// a reason to silently run the whole pipeline. +func stagesFrom(t *testing.T, stages []GuestStage, name string) []GuestStage { + t.Helper() + if name == "" { + return stages + } + for i, st := range stages { + if st.Name == name { + return stages[i:] + } + } + t.Fatalf("resume stage %q is not in the pipeline", name) + return nil +} + +// guestShutdownTimeout bounds a graceful guest power-off. Windows shutdown +// under TCG is far slower than the 5 minutes first allowed: the checkpoint in +// run 20260801T081640 timed out and had to force-stop, leaving a dirty image. +const guestShutdownTimeout = 25 * time.Minute + +// stageTimeout bounds a single dev-env stage. The slowest legitimate stage +// (nix install under TCG) runs well under an hour; iteration 12 sat wedged +// for three, because nothing bounded it. Keepalives now catch a dead peer, +// this catches everything else. +const stageTimeout = 90 * time.Minute + +// sshStream is sshTry with the output mirrored to livePath as it arrives, so +// a multi-hour stage can be watched with `tail -f` instead of revealing +// nothing until it exits (the same lesson teeToFile encodes for cell-build), +// and with a hard bound so a hung stage fails the run instead of stalling it. +func sshStream(spec Spec, user, keyPath, script, livePath string, timeout time.Duration) (string, error) { + argv := BuildSSHExecArgv(spec.SSHHost, spec.SSHPort, user, keyPath, + PowerShellEncodedCommand(script)) + cmd := exec.Command(argv[0], argv[1:]...) + var mem strings.Builder + // Append: several stages share one component log, and each contributes + // its own section rather than truncating the previous one. + w, closeFn, err := appendToFile(livePath, &mem) + if err != nil { + w, closeFn = &mem, func() error { return nil } + } + cmd.Stdout, cmd.Stderr = w, w + + if err := cmd.Start(); err != nil { + _ = closeFn() + return mem.String(), err + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + + var runErr error + select { + case runErr = <-done: + case <-time.After(timeout): + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + <-done + runErr = fmt.Errorf("stage exceeded %s and was killed — see %s for where it stopped", + timeout, livePath) + } + _ = closeFn() + return mem.String(), runErr +} + +func tailLines(s string, n int) string { + lines := strings.Split(strings.TrimRight(s, "\n"), "\n") + if len(lines) > n { + lines = lines[len(lines)-n:] + } + return strings.Join(lines, "\n") +} + +// Guard against the harness dialing a dead port forever: the reboot wait must +// tolerate the guest being down, which WaitForSSH already does — this pins +// that net.Dial failure inside the wait loop is not fatal. +func TestVMStateFn_TreatsMissingSocketAsRunning(t *testing.T) { + fn := vmStateFn(filepath.Join(t.TempDir(), "never-created.sock")) + require.Equal(t, StateRunning, fn()) +} + +// --- answer-volume log channel (same logic as the install test's) ---------- + +func TestBuildGuestLogVolume_MarkerRoundTrips(t *testing.T) { + img := filepath.Join(t.TempDir(), "guest-logs.img") + + require.NoError(t, BuildGuestLogVolume(img)) + + data, err := isokit.ReadFileFromFAT(img, "/"+GuestLogVolumeMarker) + require.NoError(t, err, "the marker is how the guest finds the volume — it must exist") + require.Contains(t, string(data), "devcell guest control volume") +} + +// Every stage must transcript itself onto the log volume: the SSH stream dies +// with the connection, but FAT survives anything short of the host losing the +// image file — the same reasoning as the install's answer volume. +// Logs group by component, not by SSH execution: every WSL step — feature, +// engine, distro import — appends to one 00N-devenv-WSL.log, so reading "what +// happened with WSL" is one file, not three. The number is the component's +// position in the pipeline, so a results dir still reads in execution order. +func TestStageLogName_SequencedPerComponent(t *testing.T) { + assert.Equal(t, "001-devenv-drivers.log", StageLogName(1, "drivers")) + assert.Equal(t, "004-devenv-WSL.log", StageLogName(4, "WSL")) +} + +func TestStageLogNames_ShareOneLogPerComponent(t *testing.T) { + stages := DevEnvStages("dmitry", "devcell", "Z:") + names := StageLogNames(stages) + + require.Len(t, names, len(stages)) + + byComponent := map[string]map[string]bool{} + for i, st := range stages { + if byComponent[st.Component] == nil { + byComponent[st.Component] = map[string]bool{} + } + byComponent[st.Component][names[i]] = true + } + for comp, set := range byComponent { + assert.Len(t, set, 1, "component %q must write exactly one log, got %v", comp, set) + } + + // The WSL component covers three stages — they must all land in one file. + var wslLogs []string + for i, st := range stages { + if st.Component == "WSL" { + wslLogs = append(wslLogs, names[i]) + } + } + require.Greater(t, len(wslLogs), 1, "WSL spans several stages") + for _, n := range wslLogs { + assert.Equal(t, wslLogs[0], n) + assert.Contains(t, n, "devenv-WSL.log") + } +} + +// WSL VM starts are transiently flaky under TCG: run 20260802T103055 got +// Wsl/Service/CreateInstance/CreateVm/E_ABORT on a stage that had passed +// identically the run before. The stages that start the utility VM are +// idempotent, so they must carry retries rather than fail the pipeline on +// the first transient abort. +func TestDevEnvStages_WSLVMStagesRetryTransientAborts(t *testing.T) { + stages := devEnvStages("dmitry", "devcell", "Z:") + for _, name := range []string{"import NixOS-WSL distro", "verify nix in NixOS-WSL"} { + found := false + for _, st := range stages { + if st.Name == name { + found = true + assert.GreaterOrEqual(t, st.Retries, 2, + "stage %q starts the WSL utility VM — transient CreateVm aborts need retries", name) + } + } + assert.True(t, found, "stage %q must exist", name) + } +} + +func TestDevEnvStages_TranscriptsCarryTheirComponentLog(t *testing.T) { + stages := DevEnvStages("dmitry", "devcell", "Z:") + names := StageLogNames(stages) + + for i, st := range stages { + if st.ScriptFile != "" { + // File-backed stages receive their component log as a parameter + // and do their own logging inside the script. + assert.Equal(t, names[i], st.Args["LogName"], + "stage %s must be told which component log to write", st.Name) + continue + } + assert.Contains(t, st.Script, names[i], + "stage %q must transcript into its component log", st.Name) + // The 20MB CLIXML progress dumps of iteration 12 came from + // Invoke-WebRequest's progress records travelling over SSH. + assert.Contains(t, st.Script, "$ProgressPreference = 'SilentlyContinue'", + "stage %q must suppress progress records — they ballooned logs to 11MB", st.Name) + } +} + +// Before WSL is installed the run must record whether this nested guest can +// host a hypervisor at all: WSL2 needs one, and our accelerators may not +// provide a usable one. Measured, not assumed. +func TestGenerateVirtualizationProbeScript_ReportsHypervisorCapability(t *testing.T) { + s := GenerateVirtualizationProbeScript() + + assert.Contains(t, s, "HyperVRequirement", "the OS's own hypervisor-capability report") + assert.Contains(t, s, "hypervisor visible to the guest", + "HypervisorPresent means we run UNDER one, not that we can host one — say so") + assert.Contains(t, s, "QUERY FAILED", + "a failed feature query must not read as \"feature absent\" (iteration 14)") + assert.Contains(t, s, "VirtualMachinePlatform", + "the feature WSL2 needs — its state must be recorded") + assert.NotContains(t, s, "Enable-WindowsOptionalFeature", + "a probe observes; enabling is a separate, explicit decision") +} + +func TestDevEnvStages_ProbeVirtualizationBeforeWSL(t *testing.T) { + stages := DevEnvStages("dmitry", "devcell", "Z:") + var names []string + for _, st := range stages { + names = append(names, st.Name) + } + + require.Less(t, indexOf(names, "probe virtualization support"), indexOf(names, "enable WSL2 features"), + "the hypervisor question must be answered before WSL is chosen") +} + +func TestDevEnvStages_TranscriptToLogVolume(t *testing.T) { + for _, st := range DevEnvStages("dmitry", "devcell", "Z:") { + if st.ScriptFile != "" { + continue // file-backed: logging lives in the script, not a wrapper + } + assert.Contains(t, st.Script, "Start-Transcript", + "stage %q must transcript to the log volume", st.Name) + assert.Contains(t, st.Script, GuestLogVolumeMarker, + "stage %q must locate the volume by marker, not drive letter", st.Name) + assert.Contains(t, st.Script, "Stop-Transcript", + "stage %q must flush its transcript", st.Name) + } +} + +func TestBuildRunCommand_AttachesLogVolume(t *testing.T) { + spec := testSpec() + spec.LogVolumePath = "/tmp/devenv-logs.img" + + joined := strings.Join(BuildRunCommand(spec), " ") + + assert.Contains(t, joined, "file=/tmp/devenv-logs.img,format=raw") + assert.Contains(t, joined, "usb-storage") + assert.Contains(t, joined, "removable=true") +} + +func TestBuildRunCommand_NoLogVolumeByDefault(t *testing.T) { + joined := strings.Join(BuildRunCommand(testSpec()), " ") + assert.NotContains(t, joined, "format=raw,if=none,id=usbfat0") +} + +// Absence must be reported per stage, same contract as CollectGuestLogs: "the +// guest never wrote it" is a finding, not a silent skip. +func TestCollectVolumeLogs_ReportsAbsence(t *testing.T) { + img := filepath.Join(t.TempDir(), "guest-logs.img") + require.NoError(t, BuildGuestLogVolume(img)) + + logs := CollectVolumeLogs(img, []string{ + StageLogName(1, "drivers"), + StageLogName(2, "virtiofs"), + }) + + require.Len(t, logs, 2) + for _, l := range logs { + require.Error(t, l.Err, "an unwritten log must carry its absence, not vanish") + } +} + +// One table type, one logging contract: build provisioning and dev-env setup +// are both GuestStage tables, so both get component-grouped guest transcripts +// without either table mentioning logs. +func TestDefaultProvisionSteps_AreAGuestStageTableWithLogging(t *testing.T) { + steps := DefaultProvisionSteps("ssh-ed25519 AAAA...", "devcell", "devcell") + + names := StageLogNames(steps) + for i, st := range steps { + assert.Equal(t, "provisioning", st.Component, + "build provisioning is one component — one log for the whole phase") + assert.Equal(t, "001-devenv-provisioning.log", names[i]) + if st.ScriptFile != "" { + continue // file-backed: logging lives in the script, not a wrapper + } + assert.Contains(t, st.Script, "Start-Transcript", + "step %q must transcript like every other guest stage", st.Name) + } +} + +// A build VM under TCG wastes ~3 GB on WerFault instances and Defender scans +// that serve no purpose in a disposable build environment. The provisioning +// pipeline must include a hardening step that disables both. +func TestDefaultProvisionSteps_IncludesEmulationHardening(t *testing.T) { + steps := DefaultProvisionSteps("ssh-ed25519 AAAA...", "devcell", "devcell") + + var found bool + for _, st := range steps { + if st.Name == "Harden for emulation" { + found = true + assert.Contains(t, st.Script, "WerFault", + "hardening step must disable WerFault") + assert.Contains(t, st.Script, "DisableRealtimeMonitoring", + "hardening step must disable Defender real-time monitoring") + break + } + } + assert.True(t, found, "DefaultProvisionSteps must include a 'Harden for emulation' stage") +} + +// A checkpoint may only follow a stage whose success was verified. The engine +// install tolerates its SSH session dropping, so "did not fail" does not mean +// "finished" — checkpointing after it once saved a half-registered engine that +// every later resume inherited. +func TestWSLReadyCheckpoint_FollowsAVerifiedStage(t *testing.T) { + stages := DevEnvStages("dmitry", "devcell", "Z:") + + idx := -1 + for i, st := range stages { + if st.Name == wslReadyCheckpointStage { + idx = i + break + } + } + require.Greater(t, idx, 0, "resume stage %q must exist and not be first", wslReadyCheckpointStage) + + preceding := stages[idx-1] + require.False(t, preceding.ToleratesDisconnect, + "the checkpoint would capture unverified state: %q may drop its SSH session", preceding.Name) + require.True(t, stages[idx].ToleratesDisconnect || stages[idx].RebootAfter, + "the stage resumed into should be the flaky one, re-run each time, not baked in") +} + +// The WSL2 utility VM is created on a running hypervisor, so Hyper-V must be +// installed and launched BEFORE the WSL2 features and the distro import. +// Run 20260801T090038 proved the cost of getting this wrong: the import died +// with Wsl/Service/RegisterDistro/CreateVm/HCS/HCS_E_HYPERV_NOT_INSTALLED +// while Microsoft-Hyper-V was absent and hypervisorlaunchtype was unset. +func TestDevEnvStages_HyperVBeforeWSL(t *testing.T) { + stages := DevEnvStages("dmitry", "devcell", "Z:") + var names []string + for _, st := range stages { + names = append(names, st.Name) + } + + hv := indexOf(names, "enable Hyper-V hypervisor") + require.GreaterOrEqual(t, hv, 0, "the pipeline must enable the hypervisor") + require.Less(t, hv, indexOf(names, "enable WSL2 features"), + "the hypervisor is a prerequisite of the WSL2 platform, not a follow-up") + require.Less(t, hv, indexOf(names, "import NixOS-WSL distro"), "must precede the import") + + require.True(t, stages[hv].RebootAfter, + "hypervisorlaunchtype is read at boot — the stage is worthless without a reboot") +} + +func TestGenerateHyperVEnableScript_InstallsAndRequestsLaunch(t *testing.T) { + s := GenerateHyperVEnableScript() + + assert.Contains(t, s, "Microsoft-Hyper-V-Hypervisor", + "VirtualMachinePlatform alone does not launch a hypervisor") + assert.Contains(t, s, "bcdedit /set hypervisorlaunchtype auto", + "the hypervisor must be told to launch at boot") + assert.Contains(t, s, "ENABLE FAILED", + "if the payload is missing from our media, say so instead of continuing quietly") + // State is asserted after the reboot, by the verify stage — not here, + // where the answer could only ever be "pending". + assert.NotContains(t, s, "hyperv started", + "this stage requests; the verify stage judges") +} + +// Installed and started fail for different reasons — a missing payload versus +// a hypervisor that cannot launch on emulated EL2 — so they are separate +// assertions with separate messages. +func TestGenerateHyperVVerifyScript_AssertsInstalledAndStartedSeparately(t *testing.T) { + s := GenerateHyperVVerifyScript() + + assert.Contains(t, s, "hyperv installed: ", "must report the installed fact") + assert.Contains(t, s, "hyperv started: ", "must report the started fact") + assert.Contains(t, s, "throw 'hyperv installed: False", "installed failure throws on its own") + assert.Contains(t, s, "throw 'hyperv started: False", "started failure throws on its own") + + // Evidence, not a single ambiguous signal: HypervisorPresent is True merely + // because we run under QEMU, so the started verdict also needs the launch + // type and the Host Compute Service. + assert.Contains(t, s, "hypervisorlaunchtype") + assert.Contains(t, s, "vmcompute") + // Iteration 20: launchtype+vmcompute reported started:True while HCS still + // said HYPERV_NOT_INSTALLED. Only the hypervisor's own log proves it booted. + assert.Contains(t, s, "Hyper-V-Hypervisor-Operational", + "the started verdict must rest on the hypervisor's own launch log") +} + +// The hypervisor must be installed, rebooted into, and verified before the +// WSL2 features and the distro import that depend on it. +func TestDevEnvStages_HyperVVerifiedBeforeWSLFeatures(t *testing.T) { + stages := DevEnvStages("dmitry", "devcell", "Z:") + var names []string + for _, st := range stages { + names = append(names, st.Name) + } + + enable := indexOf(names, "enable Hyper-V hypervisor") + verify := indexOf(names, "verify Hyper-V running") + require.GreaterOrEqual(t, enable, 0) + require.Less(t, enable, verify, "enable, reboot, then judge") + require.Less(t, verify, indexOf(names, "enable WSL2 features"), + "no point enabling the WSL2 platform on a machine with no hypervisor") + require.True(t, stages[enable].RebootAfter, + "hypervisorlaunchtype is read at boot — the stage is worthless without a reboot") +} diff --git a/internal/vm/qemu/devenvlogs.go b/internal/vm/qemu/devenvlogs.go new file mode 100644 index 0000000..890b49e --- /dev/null +++ b/internal/vm/qemu/devenvlogs.go @@ -0,0 +1,135 @@ +package qemu + +import ( + "fmt" + "strings" + + "github.com/devcell-sh/go-winkit/isokit" + "github.com/devcell-sh/go-winkit/templates" + "github.com/devcell-sh/go-winkit/winpe" +) + +// The guest log volume: a FAT image any post-install VM can write logs to — +// the same channel the install's answer volume provides, for the same reason. +// The SSH stream dies with its connection, while FAT survives anything short +// of losing the image file. The guest finds the volume by marker file, never +// by drive letter. +const GuestLogVolumeMarker = "devcell-guest-logs.txt" + +// BuildGuestLogVolume creates the FAT image guests write their logs to. +// Attach it via Spec.LogVolumePath; read it back with CollectVolumeLogs. +func BuildGuestLogVolume(destPath string) error { + return BuildControlVolume(destPath, nil) +} + +// BuildControlVolume writes the per-run control volume: the marker the guest +// resolves its drive letter by, plus any payload to deliver INTO the guest +// (the PowerShell module and stage scripts — see CELL-402). Logs come back +// on the same volume, so one attachment carries both directions. +// +// Built fresh on the host every run and attached at boot, so it is never +// inside the qcow2: a checkpoint image cannot freeze a stale copy, which is +// the failure mode that ruled out installing the module onto the guest disk. +func BuildControlVolume(destPath string, payload map[string][]byte) error { + files := map[string][]byte{ + "/" + GuestLogVolumeMarker: winpe.PadForFAT([]byte("devcell guest control volume\r\n")), + } + for name, data := range payload { + files[name] = winpe.PadForFAT(data) + } + if err := isokit.CreateFATImage(destPath, files); err != nil { + return fmt.Errorf("building control volume: %w", err) + } + return nil +} + +// CollectVolumeLogs reads the named files off a guest log volume — one entry +// per name, absence reported rather than skipped, same contract as +// CollectGuestLogs. +func CollectVolumeLogs(imgPath string, names []string) []winpe.GuestLog { + logs := make([]winpe.GuestLog, 0, len(names)) + for _, name := range names { + data, err := isokit.ReadFileFromFAT(imgPath, "/"+name) + if err != nil { + logs = append(logs, winpe.GuestLog{Name: name, Err: fmt.Errorf("%w: %v", winpe.ErrNoSuchGuestLog, err)}) + continue + } + logs = append(logs, winpe.GuestLog{Name: name, Content: data}) + } + return logs +} + +// StageLogName is the transcript filename for a dev-env component, +// prefixed with the component's 1-based position in the pipeline. Grouping by +// component rather than by SSH execution means "what happened with WSL" is one +// file covering the feature, the engine and the distro import; the number +// keeps a results directory sorted in execution order. +func StageLogName(seq int, component string) string { + return fmt.Sprintf("%03d-devenv-%s.log", seq, strings.ReplaceAll(component, " ", "-")) +} + +// withStageLogging wraps every stage in the table so it transcripts into its +// component's log on the guest log volume. One call at the end of a table +// builder is the whole contract — every guest pipeline gets identical logging +// without its stage definitions mentioning logs at all. +func withStageLogging(stages []GuestStage) []GuestStage { + names := StageLogNames(stages) + for i := range stages { + if stages[i].ScriptFile != "" { + // File-backed stages own their logging (Initialize-DevcellLogging + // inside the script); wrapping them would double it. Pass the + // component log name through as a parameter instead. + if stages[i].Args == nil { + stages[i].Args = map[string]string{} + } + stages[i].Args["LogName"] = names[i] + continue + } + stages[i].Script = withLogVolumeTranscript(names[i], stages[i].Name, stages[i].Script) + } + return stages +} + +// StageLogNames maps each stage to its component's log name, numbering +// components by first appearance. Stages sharing a component share a file. +func StageLogNames(stages []GuestStage) []string { + seq := map[string]int{} + carried := map[string]string{} + names := make([]string, len(stages)) + for i, st := range stages { + // A stage that was TOLD which log to write (file-backed stages carry + // Args["LogName"]) is authoritative: the host must read exactly the + // file the guest writes. Renumbering a span independently once had + // the guest writing 004-devenv-WSL.log while the host wrote 001. + if given := st.Args["LogName"]; given != "" { + carried[st.Component] = given + } + if _, seen := seq[st.Component]; !seen { + seq[st.Component] = len(seq) + 1 + } + names[i] = StageLogName(seq[st.Component], st.Component) + } + for i, st := range stages { + if given := carried[st.Component]; given != "" { + names[i] = given + } + } + return names +} + +// withLogVolumeTranscript wraps a stage script so it transcripts itself onto +// the guest log volume. Best-effort by design: a missing volume must never +// fail a stage, and the wrapper must not swallow the script's own throw — +// finally preserves propagation. +// withLogVolumeTranscript wraps a stage script so it appends to its +// component's transcript on the guest log volume. $ProgressPreference is +// silenced first: Invoke-WebRequest's progress records travel over SSH as +// CLIXML and turned two stage logs into 8.9MB and 11.8MB of noise. +func withLogVolumeTranscript(logName, stageName, script string) string { + return templates.Render("stage-wrapper.ps1.tmpl", struct { + Marker string + LogName string + StageName string + Script string + }{GuestLogVolumeMarker, logName, stageName, script}) +} diff --git a/internal/vm/qemu/devenvlogs_test.go b/internal/vm/qemu/devenvlogs_test.go new file mode 100644 index 0000000..bf78443 --- /dev/null +++ b/internal/vm/qemu/devenvlogs_test.go @@ -0,0 +1,47 @@ +package qemu + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// Guest logging is one abstraction shared by every stage: a partial that +// resolves the log volume loudly and exposes Write-DevcellLog (per-line +// append, readable while a long stage runs) — not a Go string built per call +// site. Run 20260802T125133 produced empty volume logs and reported nothing, +// because the old wrapper swallowed both failure modes. +func TestWithLogVolumeTranscript_UsesTheSharedLoggingPartial(t *testing.T) { + got := withLogVolumeTranscript("001-devenv-WSL.log", "import NixOS-WSL distro", "Write-Output 'x'") + + assert.Contains(t, got, "function Write-DevcellLog", + "stages must have a logging function, not ad-hoc Write-Output") + assert.Contains(t, got, "Add-Content", + "per-line append is what makes a running stage readable; Start-Transcript alone buffers") + assert.Contains(t, got, "LOG VOLUME NOT FOUND", + "a missing volume must be loud in the stage output") + assert.Contains(t, got, GuestLogVolumeMarker) + assert.Contains(t, got, "001-devenv-WSL.log") + assert.Contains(t, got, "=== stage: import NixOS-WSL distro ===") + assert.Contains(t, got, "Write-Output 'x'", "the stage script itself must still run") + assert.Contains(t, got, "finally", "a throwing stage must still close its transcript") +} + +// A stage that was told which log to write (file-backed stages carry +// Args["LogName"]) is the single source of truth: run 20260803T075624 had +// the guest writing D:\004-devenv-WSL.log while the host wrote +// 001-devenv-WSL.log, because the span was renumbered independently. +func TestStageLogNames_RespectTheNameTheStageCarries(t *testing.T) { + stages := []GuestStage{ + {Component: "WSL", Name: "a", ScriptFile: "x.ps1", Args: map[string]string{"LogName": "004-devenv-WSL.log"}}, + {Component: "WSL", Name: "b", Script: "legacy"}, + {Component: "nix", Name: "c", Script: "legacy"}, + } + names := StageLogNames(stages) + + assert.Equal(t, "004-devenv-WSL.log", names[0], + "the host must read/write exactly the file the guest was told to write") + assert.Equal(t, "004-devenv-WSL.log", names[1], + "stages of one component share the file, including the carried name") + assert.NotEqual(t, names[0], names[2], "a different component gets its own log") +} diff --git a/internal/vm/qemu/discover.go b/internal/vm/qemu/discover.go new file mode 100644 index 0000000..9848d75 --- /dev/null +++ b/internal/vm/qemu/discover.go @@ -0,0 +1,58 @@ +package qemu + +import ( + "os" + "path/filepath" +) + +// DiscoveredVM represents a running QEMU VM found during discovery. +type DiscoveredVM struct { + CellName string + Ports PortMeta +} + +// DiscoverRunningVMs scans ~/.devcell//windows/ directories for running +// QEMU VMs with valid PID files and port metadata. +func DiscoverRunningVMs(home string) []DiscoveredVM { + devcellDir := filepath.Join(home, ".devcell") + entries, err := os.ReadDir(devcellDir) + if err != nil { + return nil + } + + var vms []DiscoveredVM + for _, entry := range entries { + if !entry.IsDir() { + continue + } + cellName := entry.Name() + // Skip non-cell directories (cache, windows template dirs) + if cellName == "cache" || cellName == "windows" { + continue + } + + windowsDir := filepath.Join(devcellDir, cellName, "windows") + if _, err := os.Stat(windowsDir); err != nil { + continue + } + + pid, err := ReadPIDFile(windowsDir) + if err != nil { + continue + } + if !IsProcessAlive(pid) { + continue + } + + pm, err := ReadPortMeta(windowsDir) + if err != nil { + continue + } + + vms = append(vms, DiscoveredVM{ + CellName: cellName, + Ports: pm, + }) + } + return vms +} diff --git a/internal/vm/qemu/discover_test.go b/internal/vm/qemu/discover_test.go new file mode 100644 index 0000000..1548cc3 --- /dev/null +++ b/internal/vm/qemu/discover_test.go @@ -0,0 +1,93 @@ +package qemu + +import ( + "os" + "path/filepath" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDiscoverRunningVMs_Empty(t *testing.T) { + home := t.TempDir() + vms := DiscoverRunningVMs(home) + assert.Empty(t, vms) +} + +func TestDiscoverRunningVMs_NoPortMeta(t *testing.T) { + home := t.TempDir() + // Create cell dir with windows subdir but no ports.json + cellDir := filepath.Join(home, ".devcell", "main", "windows") + require.NoError(t, os.MkdirAll(cellDir, 0755)) + vms := DiscoverRunningVMs(home) + assert.Empty(t, vms) +} + +func TestDiscoverRunningVMs_WithPortMeta(t *testing.T) { + home := t.TempDir() + cellDir := filepath.Join(home, ".devcell", "main", "windows") + require.NoError(t, os.MkdirAll(cellDir, 0755)) + + pm := PortMeta{SSHPort: 10122, VNCPort: 10150, RDPPort: 10189} + require.NoError(t, WritePortMeta(cellDir, pm)) + + // Write a PID file with our own PID (so it appears "running") + require.NoError(t, os.WriteFile( + filepath.Join(cellDir, "qemu.pid"), + []byte(strconv.Itoa(os.Getpid())), + 0644, + )) + + vms := DiscoverRunningVMs(home) + require.Len(t, vms, 1) + assert.Equal(t, "main", vms[0].CellName) + assert.Equal(t, uint16(10150), vms[0].Ports.VNCPort) + assert.Equal(t, uint16(10189), vms[0].Ports.RDPPort) + assert.Equal(t, uint16(10122), vms[0].Ports.SSHPort) +} + +func TestDiscoverRunningVMs_MultipleCells(t *testing.T) { + home := t.TempDir() + + for _, cell := range []string{"main", "work"} { + cellDir := filepath.Join(home, ".devcell", cell, "windows") + require.NoError(t, os.MkdirAll(cellDir, 0755)) + pm := PortMeta{SSHPort: 10122, VNCPort: 10150, RDPPort: 10189} + require.NoError(t, WritePortMeta(cellDir, pm)) + require.NoError(t, os.WriteFile( + filepath.Join(cellDir, "qemu.pid"), + []byte(strconv.Itoa(os.Getpid())), + 0644, + )) + } + + vms := DiscoverRunningVMs(home) + assert.Len(t, vms, 2) + names := map[string]bool{} + for _, vm := range vms { + names[vm.CellName] = true + } + assert.True(t, names["main"]) + assert.True(t, names["work"]) +} + +func TestDiscoverRunningVMs_StalePID(t *testing.T) { + home := t.TempDir() + cellDir := filepath.Join(home, ".devcell", "main", "windows") + require.NoError(t, os.MkdirAll(cellDir, 0755)) + + pm := PortMeta{SSHPort: 10122, VNCPort: 10150, RDPPort: 10189} + require.NoError(t, WritePortMeta(cellDir, pm)) + + // Write a PID file with a definitely-dead PID + require.NoError(t, os.WriteFile( + filepath.Join(cellDir, "qemu.pid"), + []byte("999999999"), + 0644, + )) + + vms := DiscoverRunningVMs(home) + assert.Empty(t, vms, "stale PID should not appear as running") +} diff --git a/internal/vm/qemu/disk.go b/internal/vm/qemu/disk.go new file mode 100644 index 0000000..38f6007 --- /dev/null +++ b/internal/vm/qemu/disk.go @@ -0,0 +1,133 @@ +package qemu + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" +) + +// DefaultDiskSizeGB is the default Windows VM disk size. +const DefaultDiskSizeGB = 64 + +// CreateDisk creates a new qcow2 disk image at the given path. +func CreateDisk(path string, sizeGB int) error { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("creating disk directory: %w", err) + } + qemuImg, err := qemuImgPath() + if err != nil { + return err + } + cmd := exec.Command(qemuImg, "create", "-f", "qcow2", path, fmt.Sprintf("%dG", sizeGB)) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("qemu-img create: %w\n%s", err, out) + } + return nil +} + +// CloneDisk creates a qcow2 snapshot (backing file) from a template disk. +// The instance disk is thin — only stores delta writes. +func CloneDisk(templateDisk, instanceDisk string) error { + if err := os.MkdirAll(filepath.Dir(instanceDisk), 0755); err != nil { + return fmt.Errorf("creating instance directory: %w", err) + } + qemuImg, err := qemuImgPath() + if err != nil { + return err + } + cmd := exec.Command(qemuImg, "create", "-f", "qcow2", + "-F", "qcow2", "-b", templateDisk, instanceDisk) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("qemu-img clone: %w\n%s", err, out) + } + return nil +} + +// DiskInfo returns basic information about a qcow2 image. +func DiskInfo(path string) (string, error) { + qemuImg, err := qemuImgPath() + if err != nil { + return "", err + } + out, err := exec.Command(qemuImg, "info", path).CombinedOutput() + if err != nil { + return "", fmt.Errorf("qemu-img info: %w\n%s", err, out) + } + return string(out), nil +} + +// firmwareCandidates lists where the ARM64 EDK2 firmware may live on Linux, in +// priority order: distro packages first, then nix profiles. +// +// /opt/devcell is the devcell thin-cell nix profile — a stable path that is +// never remounted and, unlike the session user's $HOME, actually holds the +// profile (the entrypoint copies dotfiles into $HOME, not the store). +func firmwareCandidates(home string) []string { + candidates := []string{ + "/usr/share/AAVMF/AAVMF_CODE.fd", + "/usr/share/qemu-efi-aarch64/QEMU_EFI.fd", + "/usr/share/edk2/aarch64/QEMU_EFI.fd", + } + const nixRelative = ".local/state/nix/profiles/profile/share/qemu/edk2-aarch64-code.fd" + if home != "" { + candidates = append(candidates, filepath.Join(home, nixRelative)) + } + return append(candidates, filepath.Join("/opt/devcell", nixRelative)) +} + +// firmwareFromBinary resolves the EDK2 firmware path relative to the +// qemu-system-aarch64 binary: /share/qemu/edk2-aarch64-code.fd. +// Works for Homebrew, Nix, distro packages — any standard install layout. +func firmwareFromBinary() string { + bin, err := exec.LookPath("qemu-system-aarch64") + if err != nil { + return "" + } + real, err := filepath.EvalSymlinks(bin) + if err != nil { + return "" + } + p := filepath.Join(filepath.Dir(real), "..", "share", "qemu", "edk2-aarch64-code.fd") + if _, err := os.Stat(p); err != nil { + return "" + } + return p +} + +// FirmwarePath returns the path to the EDK2 UEFI firmware for ARM64. +func FirmwarePath() string { + if p := firmwareFromBinary(); p != "" { + return p + } + home, _ := os.UserHomeDir() + for _, p := range firmwareCandidates(home) { + if _, err := os.Stat(p); err == nil { + return p + } + } + return "/usr/share/AAVMF/AAVMF_CODE.fd" +} + +// PrepareVarsFile copies the UEFI firmware to create a writable vars store. +func PrepareVarsFile(firmwarePath, varsPath string) error { + if err := os.MkdirAll(filepath.Dir(varsPath), 0755); err != nil { + return fmt.Errorf("creating vars directory: %w", err) + } + src, err := os.ReadFile(firmwarePath) + if err != nil { + return fmt.Errorf("reading firmware: %w", err) + } + if err := os.WriteFile(varsPath, src, 0644); err != nil { + return fmt.Errorf("writing vars: %w", err) + } + return nil +} + +func qemuImgPath() (string, error) { + path, err := exec.LookPath("qemu-img") + if err != nil { + return "", fmt.Errorf("qemu-img not found — install QEMU (brew install qemu)") + } + return path, nil +} diff --git a/internal/vm/qemu/disk_test.go b/internal/vm/qemu/disk_test.go new file mode 100644 index 0000000..01249fa --- /dev/null +++ b/internal/vm/qemu/disk_test.go @@ -0,0 +1,89 @@ +package qemu + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFirmwarePath_NonEmpty(t *testing.T) { + path := FirmwarePath() + assert.NotEmpty(t, path) + assert.True(t, filepath.IsAbs(path), "firmware path should be absolute") +} + +func TestPrepareVarsFile(t *testing.T) { + tmpDir := t.TempDir() + firmware := filepath.Join(tmpDir, "firmware.fd") + require.NoError(t, os.WriteFile(firmware, []byte("UEFI firmware data"), 0644)) + + vars := filepath.Join(tmpDir, "subdir", "vars.fd") + require.NoError(t, PrepareVarsFile(firmware, vars)) + + data, err := os.ReadFile(vars) + require.NoError(t, err) + assert.Equal(t, "UEFI firmware data", string(data)) +} + +func TestPrepareVarsFile_MissingFirmware(t *testing.T) { + tmpDir := t.TempDir() + err := PrepareVarsFile("/nonexistent/firmware.fd", filepath.Join(tmpDir, "vars.fd")) + assert.Error(t, err) + assert.Contains(t, err.Error(), "reading firmware") +} + +func TestDefaultDiskSizeGB(t *testing.T) { + assert.Equal(t, 64, DefaultDiskSizeGB) +} + +// The nix profile in a devcell thin cell lives at /opt/devcell, not under the +// session user's $HOME — the entrypoint copies dotfiles, not the profile. When +// that candidate was missing, requireFirmware() found nothing and every QEMU +// integration test SKIPped instead of running, which reads as "green". +func TestFirmwareCandidates_IncludesDevcellNixProfile(t *testing.T) { + got := firmwareCandidates("/home/bob") + assert.Contains(t, got, "/opt/devcell/.local/state/nix/profiles/profile/share/qemu/edk2-aarch64-code.fd") +} + +func TestFirmwareCandidates_StillPrefersSystemPackages(t *testing.T) { + got := firmwareCandidates("/home/bob") + assert.Equal(t, "/usr/share/AAVMF/AAVMF_CODE.fd", got[0], + "distro packages must keep priority over the nix profile") + assert.Contains(t, got, "/home/bob/.local/state/nix/profiles/profile/share/qemu/edk2-aarch64-code.fd") +} + +func TestFirmwareFromBinary_FindsFirmwareNextToQemu(t *testing.T) { + // Build a fake qemu-system-aarch64 install tree with the firmware file. + root := t.TempDir() + binDir := filepath.Join(root, "bin") + shareDir := filepath.Join(root, "share", "qemu") + require.NoError(t, os.MkdirAll(binDir, 0755)) + require.NoError(t, os.MkdirAll(shareDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(shareDir, "edk2-aarch64-code.fd"), []byte("fw"), 0644)) + + fakeBin := filepath.Join(binDir, "qemu-system-aarch64") + require.NoError(t, os.WriteFile(fakeBin, []byte("#!/bin/sh\n"), 0755)) + + // Put our fake bin first on PATH. + t.Setenv("PATH", binDir+":"+os.Getenv("PATH")) + + got := firmwareFromBinary() + assert.NotEmpty(t, got, "should find firmware relative to binary") + assert.FileExists(t, got) +} + +func TestFirmwareFromBinary_ReturnsEmptyWhenNoBinary(t *testing.T) { + t.Setenv("PATH", t.TempDir()) // empty dir — no qemu binary + assert.Empty(t, firmwareFromBinary()) +} + +func TestFirmwareCandidates_OmitsHomePathWhenHomeUnknown(t *testing.T) { + for _, p := range firmwareCandidates("") { + assert.False(t, strings.HasPrefix(p, "/.local"), + "an empty $HOME must not produce a rootless /.local/... path, got %q", p) + } +} diff --git a/internal/vm/qemu/download.go b/internal/vm/qemu/download.go new file mode 100644 index 0000000..1db959f --- /dev/null +++ b/internal/vm/qemu/download.go @@ -0,0 +1,476 @@ +package qemu + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/devcell-sh/go-winkit/unattend" + "github.com/devcell-sh/go-winkit/winpe" + + "github.com/devcell-sh/go-winkit/mctcatalog" + "github.com/devcell-sh/go-winkit/uupdump" +) + +const ( + // VirtioDriversURL is the stable direct-download link for the latest VirtIO drivers ISO. + VirtioDriversURL = "https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/stable-virtio/virtio-win.iso" + + // WindowsISODownloadURL is the Microsoft page for downloading Windows 11 ARM64 ISO. + // Kept for the manual-download fallback message in ResolveWindowsISO. + WindowsISODownloadURL = "https://www.microsoft.com/en-us/software-download/windows11arm64" + + // PwshVersion is the PowerShell 7 release shipped on the answer volume. + PwshVersion = "7.6.5" + // PwshReleaseURL is the direct GitHub download for the self-contained ARM64 zip. + PwshReleaseURL = "https://github.com/PowerShell/PowerShell/releases/download/v" + PwshVersion + "/PowerShell-" + PwshVersion + "-win-arm64.zip" + // PwshZipName is the cached zip filename. + PwshZipName = "pwsh-arm64.zip" + + // AlpineVersion is the Alpine minirootfs release used as the WSL2 + // smoke-test distro — a ~4 MB tarball, the cheapest real Linux there is. + AlpineVersion = "3.22.1" + // AlpineRootfsURL is the direct CDN download for the aarch64 minirootfs. + AlpineRootfsURL = "https://dl-cdn.alpinelinux.org/alpine/v3.22/releases/aarch64/alpine-minirootfs-" + AlpineVersion + "-aarch64.tar.gz" + // AlpineRootfsName is the cached tarball filename. + AlpineRootfsName = "alpine-minirootfs-" + AlpineVersion + "-aarch64.tar.gz" +) + +// WindowsISOPath returns the path to the cached Windows ISO for a given language. +func WindowsISOPath(home, language string) string { + safe := strings.ReplaceAll(strings.ToLower(language), " ", "-") + return filepath.Join(CacheDir(home), fmt.Sprintf("windows-arm64-%s.iso", safe)) +} + +// DownloadWindowsISO fetches and caches the Windows 11 ARM64 ISO via UUP dump. +// Downloads an ESD from Microsoft's CDN and assembles it into a bootable ISO +// using wimlib-imagex and mkisofs (must be on PATH). +func DownloadWindowsISO(ctx context.Context, home, language string, noCache bool, obs Observer) (string, error) { + if language == "" { + language = "en-us" + } + + cacheDir := CacheDir(home) + dest := WindowsISOPath(home, language) + if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { + return "", fmt.Errorf("creating cache dir: %w", err) + } + + if noCache { + obs.Logf("--no-cache: removing Windows ISO download marker") + os.Remove(dest + ".done") + } + + if hasDownloadMarker(dest) { + if _, err := os.Stat(dest); err == nil { + // A cached image that firmware cannot boot (e.g. pure UDF with no + // El Torito, what hdiutil used to master) would burn a 20–40 min + // install cycle before failing at the EFI shell. Re-master instead. + if err := winpe.WindowsISOBootable(dest); err != nil { + obs.Logf("cached Windows ISO is unusable (%v) — re-mastering", err) + os.Remove(dest) + os.Remove(dest + ".done") + } else { + obs.Logf("Windows ISO cache hit: %s", dest) + return dest, nil + } + } else { + obs.Logf("Windows ISO .done marker found but file missing — re-downloading") + os.Remove(dest + ".done") + } + } + + var dlStart time.Time + var lastLogPct float64 + progressCb := func(filename string, downloaded, total int64) { + if total <= 0 { + return + } + if dlStart.IsZero() { + dlStart = time.Now() + } + pct := float64(downloaded) / float64(total) * 100 + dlMB := float64(downloaded) / (1024 * 1024) + totalMB := float64(total) / (1024 * 1024) + + spinnerMsg := fmt.Sprintf("%.0f MB / %.0f MB (%.1f%%)", dlMB, totalMB, pct) + obs.Progress(float64(downloaded)/float64(total), spinnerMsg) + + if pct-lastLogPct >= 5 || pct >= 100 { + lastLogPct = pct + elapsed := time.Since(dlStart) + logMsg := spinnerMsg + if downloaded > 0 && elapsed > time.Second { + speed := float64(downloaded) / elapsed.Seconds() / (1024 * 1024) + remaining := time.Duration(float64(total-downloaded) / float64(downloaded) * float64(elapsed)) + logMsg = fmt.Sprintf("%.0f MB / %.0f MB (%.1f%%) — %s left @ %.0f MB/s", + dlMB, totalMB, pct, remaining.Round(time.Second), speed) + } + obs.Logf("download: %s", logMsg) + } + } + + // Try MCT catalog first (self-contained ESD from Microsoft CDN — always works for ARM64). + obs.Logf("trying MCT catalog path (self-contained ESD)") + isoPath, err := mctcatalog.FetchWindowsISO(ctx, mctcatalog.FetchConfig{ + CacheDir: cacheDir, + Language: language, + Edition: "Professional", + LogFunc: obs.Logf, + OnProgress: progressCb, + }) + if err != nil { + obs.Logf("MCT catalog failed: %v — falling back to UUP dump", err) + isoPath, err = uupdump.FetchWindowsISO(ctx, uupdump.FetchConfig{ + CacheDir: cacheDir, + Language: language, + Edition: "PROFESSIONAL", + Concurrency: 5, + LogFunc: obs.Logf, + OnProgress: progressCb, + }) + if err != nil { + return "", fmt.Errorf("downloading Windows ISO (MCT + UUP dump both failed): %w", err) + } + } + + os.WriteFile(isoPath+".done", []byte("ok"), 0644) + obs.Logf("download complete, wrote %s.done", isoPath) + return isoPath, nil +} + +// ISOMetadata holds parsed information from an ISO filename. +type ISOMetadata struct { + Version string // e.g. "24H2" + Arch string // e.g. "Arm64", "x64" +} + +var isoFilenameRe = regexp.MustCompile(`Win\d+_(\w+)_\w+_(\w+)\.iso`) + +// ParseISOFilename extracts version and architecture from a Windows ISO filename. +func ParseISOFilename(name string) ISOMetadata { + m := isoFilenameRe.FindStringSubmatch(name) + if len(m) < 3 { + return ISOMetadata{} + } + return ISOMetadata{Version: m[1], Arch: m[2]} +} + +// CacheDir returns the QEMU media cache directory. +// +// DEVCELL_QEMU_CACHE_DIR points it somewhere shared. Inside a cell $HOME is +// itself a per-cell directory, so the default renders as +// ~/.devcell//.devcell/cache/qemu and every cell re-downloads the same +// ~6 GB of immutable media. There is no way to reach the real host home from +// inside the container, so the location has to be pointable rather than +// inferred (CELL-386). +func CacheDir(home string) string { + if dir := os.Getenv("DEVCELL_QEMU_CACHE_DIR"); dir != "" { + return dir + } + return filepath.Join(home, ".devcell", "cache", "qemu") +} + +// VirtioISOPath returns the path to the cached VirtIO drivers ISO. +func VirtioISOPath(home string) string { + return filepath.Join(CacheDir(home), "virtio-win.iso") +} + +// DownloadVirtioDrivers downloads the VirtIO drivers ISO if not already cached. +// Uses .done marker pattern (mirrors tart.DownloadIPSW). +// When noCache is true, removes the .done marker to force re-download. +func DownloadVirtioDrivers(ctx context.Context, home string, noCache bool, obs Observer) (string, error) { + dest := VirtioISOPath(home) + if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { + return "", fmt.Errorf("creating cache dir: %w", err) + } + + if noCache { + obs.Logf("--no-cache: removing VirtIO download marker") + os.Remove(dest + ".done") + } + + if hasDownloadMarker(dest) { + if _, err := os.Stat(dest); err == nil { + obs.Logf("VirtIO drivers cache hit: %s", dest) + return dest, nil + } + obs.Logf("VirtIO .done marker found but file missing — re-downloading") + os.Remove(dest + ".done") + } + + obs.Logf("downloading VirtIO drivers from %s", VirtioDriversURL) + var lastErr error + for attempt := 1; attempt <= 3; attempt++ { + obs.Logf("download attempt %d/3", attempt) + lastErr = downloadFile(ctx, VirtioDriversURL, dest, obs) + if lastErr == nil { + os.WriteFile(dest+".done", []byte("ok"), 0644) + obs.Logf("download complete, wrote %s.done", dest) + return dest, nil + } + obs.Logf("attempt %d failed: %v", attempt, lastErr) + if attempt < 3 { + time.Sleep(time.Duration(attempt) * time.Second) + } + } + return "", fmt.Errorf("downloading VirtIO drivers after 3 attempts: %w", lastErr) +} + +// hasDownloadMarker checks if a .done marker exists for the given file path. +func hasDownloadMarker(path string) bool { + _, err := os.Stat(path + ".done") + return err == nil +} + +// downloadFile fetches url to dest with progress reporting. +func downloadFile(ctx context.Context, url, dest string, obs Observer) error { + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP %d from %s", resp.StatusCode, url) + } + + // Download to a sibling temp file and rename into place. Writing to dest + // directly writes *through* any hard link sharing that inode — which + // truncated the host's real 789MB virtio-win.iso to a 300MB stub when a + // test seeded its cache by linking (CELL-386). Rename replaces the + // directory entry instead, and has the second benefit that a killed + // download leaves no half-file that looks complete. + tmp, err := os.CreateTemp(filepath.Dir(dest), filepath.Base(dest)+".part-*") + if err != nil { + return err + } + tmpName := tmp.Name() + defer func() { + tmp.Close() + os.Remove(tmpName) // no-op once renamed + }() + f := tmp + + var written int64 + buf := make([]byte, 32*1024) + for { + n, readErr := resp.Body.Read(buf) + if n > 0 { + if _, wErr := f.Write(buf[:n]); wErr != nil { + return wErr + } + written += int64(n) + if resp.ContentLength > 0 { + obs.Progress(float64(written)/float64(resp.ContentLength), + fmt.Sprintf("%.0f MB / %.0f MB", float64(written)/(1024*1024), float64(resp.ContentLength)/(1024*1024))) + } + } + if readErr == io.EOF { + break + } + if readErr != nil { + return readErr + } + } + if err := f.Close(); err != nil { + return err + } + return os.Rename(tmpName, dest) +} + +// ResolveWindowsISO resolves the Windows ARM64 ISO path. +// Priority: env DEVCELL_QEMU_WINDOWS_ISO > config path > cached download > error. +func ResolveWindowsISO(envISO, configISO, home string) (string, error) { + path := envISO + if path == "" { + path = configISO + } + if path == "" && home != "" { + cached := WindowsISOPath(home, "en-us") + if hasDownloadMarker(cached) { + if _, err := os.Stat(cached); err == nil { + path = cached + } + } + } + if path == "" { + return "", fmt.Errorf("Windows ARM64 ISO not configured.\n\n"+ + "Run: cell init --engine=qemu (downloads automatically)\n"+ + "Or download from: %s\n"+ + "Then set: export DEVCELL_QEMU_WINDOWS_ISO=/path/to/Win11_ARM64.iso\n"+ + "Or add to .devcell.toml:\n"+ + " [cell]\n"+ + " qemu_windows_iso = \"/path/to/Win11_ARM64.iso\"", WindowsISODownloadURL) + } + if _, err := os.Stat(path); err != nil { + return "", fmt.Errorf("Windows ISO not found at %s: %w", path, err) + } + if err := ValidateISO(path); err != nil { + return "", fmt.Errorf("invalid ISO at %s: %w", path, err) + } + return path, nil +} + +// ValidateISO checks that a file carries a recognised disc format by reading +// the volume descriptor at sector 16 (offset 0x8001). Both ISO 9660 (CD001) +// and UDF (BEA01/NSR02/NSR03) are accepted — Windows ARM64 ISOs built by UUP +// dump are pure UDF. +func ValidateISO(path string) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + + magic := make([]byte, 5) + if _, err := f.ReadAt(magic, 0x8001); err != nil { + return fmt.Errorf("cannot read ISO magic bytes: %w", err) + } + switch string(magic) { + case "CD001", "BEA01", "NSR02", "NSR03": + return nil + default: + return fmt.Errorf("not a recognised disc image (expected CD001 or UDF descriptor at offset 0x8001, got %q)", magic) + } +} + +// RemoveDownloadMarkers removes .done markers for all cached ISOs, +// forcing re-download on next use. Used by --no-cache. +func RemoveDownloadMarkers(home string) { + cacheDir := CacheDir(home) + entries, err := os.ReadDir(cacheDir) + if err != nil { + return + } + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".done") { + os.Remove(filepath.Join(cacheDir, e.Name())) + } + } +} + +// OpenSSHPayloadPath returns the cached Win32-OpenSSH release path. +func OpenSSHPayloadPath(home string) string { + return filepath.Join(CacheDir(home), unattend.OpenSSHPayloadName) +} + +// DownloadOpenSSH fetches Microsoft's signed Win32-OpenSSH ARM64 release. +// +// The guest cannot install OpenSSH Server through Windows servicing: our media +// carries the capability manifest but not its payload, so the capability sits +// Staged and the install fails 0x80070002 — with Windows Update reachable and +// permitted. The Server FoD ships on a separate build-matched ISO, and the UUP +// package has no Server package at all. This release needs no servicing. +func DownloadOpenSSH(ctx context.Context, home string, noCache bool, obs Observer) (string, error) { + dest := OpenSSHPayloadPath(home) + if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { + return "", fmt.Errorf("creating cache dir: %w", err) + } + + if noCache { + obs.Logf("--no-cache: removing OpenSSH download marker") + os.Remove(dest + ".done") + } + + if hasDownloadMarker(dest) { + if _, err := os.Stat(dest); err == nil { + obs.Logf("OpenSSH payload cache hit: %s", dest) + return dest, nil + } + obs.Logf("OpenSSH .done marker found but file missing — re-downloading") + os.Remove(dest + ".done") + } + + obs.Logf("downloading OpenSSH from %s", unattend.OpenSSHReleaseURL) + if err := downloadFile(ctx, unattend.OpenSSHReleaseURL, dest, obs); err != nil { + return "", fmt.Errorf("downloading OpenSSH release: %w", err) + } + if err := os.WriteFile(dest+".done", nil, 0644); err != nil { + return "", fmt.Errorf("writing download marker: %w", err) + } + return dest, nil +} + +// PwshZipPath returns the cached PowerShell 7 zip path. +func PwshZipPath(home string) string { + return filepath.Join(CacheDir(home), PwshZipName) +} + +// AlpineRootfsPath returns the cached Alpine minirootfs tarball path. +func AlpineRootfsPath(home string) string { + return filepath.Join(CacheDir(home), AlpineRootfsName) +} + +// DownloadAlpineRootfs fetches the Alpine aarch64 minirootfs if not cached. +func DownloadAlpineRootfs(ctx context.Context, home string, noCache bool, obs Observer) (string, error) { + dest := AlpineRootfsPath(home) + if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { + return "", fmt.Errorf("creating cache dir: %w", err) + } + + if noCache { + obs.Logf("--no-cache: removing alpine download marker") + os.Remove(dest + ".done") + } + + if hasDownloadMarker(dest) { + if _, err := os.Stat(dest); err == nil { + obs.Logf("alpine cache hit: %s", dest) + return dest, nil + } + obs.Logf("alpine .done marker found but file missing — re-downloading") + os.Remove(dest + ".done") + } + + obs.Logf("downloading Alpine minirootfs %s from %s", AlpineVersion, AlpineRootfsURL) + if err := downloadFile(ctx, AlpineRootfsURL, dest, obs); err != nil { + return "", fmt.Errorf("downloading Alpine minirootfs: %w", err) + } + if err := os.WriteFile(dest+".done", nil, 0644); err != nil { + return "", fmt.Errorf("writing download marker: %w", err) + } + return dest, nil +} + +// DownloadPwsh fetches the PowerShell 7 ARM64 self-contained zip if not cached. +func DownloadPwsh(ctx context.Context, home string, noCache bool, obs Observer) (string, error) { + dest := PwshZipPath(home) + if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { + return "", fmt.Errorf("creating cache dir: %w", err) + } + + if noCache { + obs.Logf("--no-cache: removing pwsh download marker") + os.Remove(dest + ".done") + } + + if hasDownloadMarker(dest) { + if _, err := os.Stat(dest); err == nil { + obs.Logf("pwsh cache hit: %s", dest) + return dest, nil + } + obs.Logf("pwsh .done marker found but file missing — re-downloading") + os.Remove(dest + ".done") + } + + obs.Logf("downloading PowerShell %s from %s", PwshVersion, PwshReleaseURL) + if err := downloadFile(ctx, PwshReleaseURL, dest, obs); err != nil { + return "", fmt.Errorf("downloading PowerShell release: %w", err) + } + if err := os.WriteFile(dest+".done", nil, 0644); err != nil { + return "", fmt.Errorf("writing download marker: %w", err) + } + return dest, nil +} + diff --git a/internal/vm/qemu/download_test.go b/internal/vm/qemu/download_test.go new file mode 100644 index 0000000..f7ff85d --- /dev/null +++ b/internal/vm/qemu/download_test.go @@ -0,0 +1,233 @@ +package qemu + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/devcell-sh/go-winkit/isokit" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseISOFilename(t *testing.T) { + tests := []struct { + name, version, arch string + }{ + {"Win11_24H2_EnglishInternational_Arm64.iso", "24H2", "Arm64"}, + {"Win11_24H2_English_x64.iso", "24H2", "x64"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + meta := ParseISOFilename(tt.name) + assert.Equal(t, tt.version, meta.Version) + assert.Equal(t, tt.arch, meta.Arch) + }) + } +} + +func TestParseISOFilename_Invalid(t *testing.T) { + meta := ParseISOFilename("random-file.iso") + assert.Empty(t, meta.Version) + assert.Empty(t, meta.Arch) +} + +func TestHasDownloadMarker(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "test.iso") + + assert.False(t, hasDownloadMarker(path)) + + require.NoError(t, os.WriteFile(path+".done", []byte("ok"), 0644)) + + assert.True(t, hasDownloadMarker(path)) +} + +func TestValidateISO_RejectsNonISO(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "fake.iso") + require.NoError(t, os.WriteFile(path, make([]byte, 0x9000), 0644)) + + err := ValidateISO(path) + assert.Error(t, err) + assert.Contains(t, err.Error(), "not a recognised disc image") +} + +func TestValidateISO_RejectsSmallFile(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "tiny.iso") + require.NoError(t, os.WriteFile(path, []byte("tiny"), 0644)) + + err := ValidateISO(path) + assert.Error(t, err) +} + +func TestValidateISO_AcceptsValidMagic(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "valid.iso") + data := make([]byte, 0x9000) + copy(data[0x8001:], "CD001") + require.NoError(t, os.WriteFile(path, data, 0644)) + + assert.NoError(t, ValidateISO(path)) +} + +func TestValidateISO_AcceptsUDF(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "udf.iso") + data := make([]byte, 0x9000) + copy(data[0x8001:], "BEA01") + require.NoError(t, os.WriteFile(path, data, 0644)) + + assert.NoError(t, ValidateISO(path)) +} + +func TestResolveWindowsISO_EnvOverride(t *testing.T) { + tmpDir := t.TempDir() + isoPath := filepath.Join(tmpDir, "win.iso") + data := make([]byte, 0x9000) + copy(data[0x8001:], "CD001") + require.NoError(t, os.WriteFile(isoPath, data, 0644)) + + result, err := ResolveWindowsISO(isoPath, "/some/toml/path.iso", "") + require.NoError(t, err) + assert.Equal(t, isoPath, result) +} + +func TestResolveWindowsISO_FallsBackToConfig(t *testing.T) { + tmpDir := t.TempDir() + isoPath := filepath.Join(tmpDir, "win.iso") + data := make([]byte, 0x9000) + copy(data[0x8001:], "CD001") + require.NoError(t, os.WriteFile(isoPath, data, 0644)) + + result, err := ResolveWindowsISO("", isoPath, "") + require.NoError(t, err) + assert.Equal(t, isoPath, result) +} + +func TestResolveWindowsISO_FallsBackToCache(t *testing.T) { + tmpDir := t.TempDir() + cached := WindowsISOPath(tmpDir, "en-us") + require.NoError(t, os.MkdirAll(filepath.Dir(cached), 0755)) + data := make([]byte, 0x9000) + copy(data[0x8001:], "CD001") + require.NoError(t, os.WriteFile(cached, data, 0644)) + require.NoError(t, os.WriteFile(cached+".done", []byte("ok"), 0644)) + + result, err := ResolveWindowsISO("", "", tmpDir) + require.NoError(t, err) + assert.Equal(t, cached, result) +} + +func TestResolveWindowsISO_MissingReturnsErrorWithURL(t *testing.T) { + _, err := ResolveWindowsISO("", "", "") + require.Error(t, err) + assert.Contains(t, err.Error(), WindowsISODownloadURL) + assert.Contains(t, err.Error(), "cell init --engine=qemu") +} + +func TestResolveWindowsISO_FileNotFound(t *testing.T) { + _, err := ResolveWindowsISO("/nonexistent/win.iso", "", "") + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestWindowsISODownloadURL_IsSet(t *testing.T) { + assert.NotEmpty(t, WindowsISODownloadURL) + assert.Contains(t, WindowsISODownloadURL, "microsoft.com") +} + +func TestVirtioDriversURL_IsSet(t *testing.T) { + assert.NotEmpty(t, VirtioDriversURL) + assert.Contains(t, VirtioDriversURL, "virtio-win.iso") +} + +func TestCacheDir(t *testing.T) { + dir := CacheDir("/home/user") + assert.Contains(t, dir, ".devcell/cache/qemu") +} + +func TestRemoveDownloadMarkers(t *testing.T) { + tmpDir := t.TempDir() + cacheDir := filepath.Join(tmpDir, ".devcell", "cache", "qemu") + require.NoError(t, os.MkdirAll(cacheDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(cacheDir, "virtio-win.iso.done"), []byte("ok"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(cacheDir, "virtio-win.iso"), []byte("data"), 0644)) + + RemoveDownloadMarkers(tmpDir) + + _, err := os.Stat(filepath.Join(cacheDir, "virtio-win.iso.done")) + assert.True(t, os.IsNotExist(err), ".done marker should be removed") + _, err = os.Stat(filepath.Join(cacheDir, "virtio-win.iso")) + assert.NoError(t, err, "ISO file should remain") +} + +func TestWindowsISOPath(t *testing.T) { + path := WindowsISOPath("/home/user", "en-us") + assert.Equal(t, "/home/user/.devcell/cache/qemu/windows-arm64-en-us.iso", path) + + path = WindowsISOPath("/home/user", "de-de") + assert.Equal(t, "/home/user/.devcell/cache/qemu/windows-arm64-de-de.iso", path) +} + +// writeBootableCachedISO plants a cache entry that passes the reuse check: +// since run 20260812T081924 a cache hit requires firmware-bootable media +// (El Torito EFI catalog), not just a .done marker. +func writeBootableCachedISO(t *testing.T, cached string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Dir(cached), 0755)) + img := make([]byte, 64*2048) + for sector, magic := range map[int]string{16: "BEA01", 17: "NSR02", 18: "TEA01"} { + copy(img[sector*2048+1:], magic) + img[sector*2048+6] = 0x01 + } + require.NoError(t, os.WriteFile(cached, img, 0644)) + require.NoError(t, isokit.AddElToritoEFIBoot(cached, []byte("boot-image"))) + require.NoError(t, os.WriteFile(cached+".done", []byte("ok"), 0644)) +} + +func TestDownloadWindowsISO_CacheHit(t *testing.T) { + tmpDir := t.TempDir() + cached := WindowsISOPath(tmpDir, "en-us") + writeBootableCachedISO(t, cached) + + path, err := DownloadWindowsISO(nil, tmpDir, "en-us", false, NopObserver{}) + require.NoError(t, err) + assert.Equal(t, cached, path) +} + +func TestDownloadWindowsISO_DefaultLanguage(t *testing.T) { + tmpDir := t.TempDir() + cached := WindowsISOPath(tmpDir, "en-us") + writeBootableCachedISO(t, cached) + + path, err := DownloadWindowsISO(nil, tmpDir, "", false, NopObserver{}) + require.NoError(t, err) + assert.True(t, strings.HasSuffix(path, "en-us.iso")) +} + +func TestPwshConstants(t *testing.T) { + assert.Contains(t, PwshReleaseURL, "PowerShell") + assert.Contains(t, PwshReleaseURL, "arm64") + assert.Contains(t, PwshReleaseURL, PwshVersion) +} + +func TestPwshZipPath(t *testing.T) { + path := PwshZipPath("/home/user") + assert.Contains(t, path, ".devcell/cache/qemu") + assert.Contains(t, path, PwshZipName) +} + +func TestAlpineConstants(t *testing.T) { + assert.Contains(t, AlpineRootfsURL, "alpine") + assert.Contains(t, AlpineRootfsURL, "aarch64") + assert.Contains(t, AlpineRootfsURL, AlpineVersion) +} + +func TestAlpineRootfsPath(t *testing.T) { + path := AlpineRootfsPath("/home/user") + assert.Contains(t, path, ".devcell/cache/qemu") + assert.Contains(t, path, AlpineRootfsName) +} diff --git a/internal/vm/qemu/engine.go b/internal/vm/qemu/engine.go new file mode 100644 index 0000000..913f35d --- /dev/null +++ b/internal/vm/qemu/engine.go @@ -0,0 +1,123 @@ +package qemu + +import ( + "bufio" + "context" + "fmt" + "net" + "runtime" + "strings" + "time" +) + +// Engine implements vm.Engine for QEMU Windows VMs. +type Engine struct { + Spec Spec + obs Observer + vm *VM +} + +// NewEngine creates a new QEMU engine with the given spec. +func NewEngine(spec Spec, obs Observer) *Engine { + return &Engine{ + Spec: spec, + obs: obs, + } +} + +// Preflight validates the host can run QEMU Windows VMs. +func (e *Engine) Preflight() error { + if err := PreflightCheck(runtime.GOOS, runtime.GOARCH); err != nil { + return err + } + if _, err := QEMUBinaryPath(); err != nil { + return err + } + return nil +} + +// Boot starts the QEMU VM and waits for SSH to become available. +func (e *Engine) Boot(ctx context.Context) error { + e.Spec.ApplyDefaults() + if err := e.Spec.Validate(); err != nil { + return fmt.Errorf("invalid spec: %w", err) + } + + e.vm = NewVM(e.Spec, e.obs, "") + if err := e.vm.Start(ctx); err != nil { + return err + } + + e.obs.Logf("waiting for SSH on %s:%d", e.Spec.SSHHost, e.Spec.SSHPort) + return WaitForSSH(e.Spec.SSHHost, e.Spec.SSHPort, 5*time.Minute, 3*time.Second, e.obs) +} + +// Shutdown gracefully stops the QEMU VM. +func (e *Engine) Shutdown(ctx context.Context) error { + if e.vm == nil { + return nil + } + return e.vm.Shutdown(ctx) +} + +// SSHArgv constructs the SSH argv for running a command inside the VM. +func (e *Engine) SSHArgv(binary string, flags, args []string) []string { + spec := e.Spec + spec.Binary = binary + spec.DefaultFlags = flags + spec.UserArgs = args + return BuildSSHArgv(spec) +} + +// VMStateFunc returns the current VM state. WaitForSSH uses it to bail early +// when the QEMU process exits (e.g. drive collision, missing firmware). +type VMStateFunc func() VMState + +// WaitForSSH polls until the SSH port accepts connections. +// If vmState is non-nil, returns immediately when the VM is no longer running. +func WaitForSSH(host string, port uint16, timeout, interval time.Duration, obs Observer, vmState ...VMStateFunc) error { + addr := net.JoinHostPort(host, fmt.Sprintf("%d", port)) + deadline := time.Now().Add(timeout) + obs.Logf("polling SSH at %s (timeout %s)", addr, timeout) + + var checkVM VMStateFunc + if len(vmState) > 0 { + checkVM = vmState[0] + } + + attempt := 0 + var lastErr error + for time.Now().Before(deadline) { + if checkVM != nil { + if s := checkVM(); s != StateRunning { + return fmt.Errorf("VM exited (state=%s) while waiting for SSH after %d attempts", s, attempt) + } + } + attempt++ + conn, err := net.DialTimeout("tcp", addr, 2*time.Second) + if err == nil { + conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + scanner := bufio.NewScanner(conn) + gotBanner := false + if scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "SSH-") { + gotBanner = true + obs.Logf("SSH banner: %s", line) + } + } + conn.Close() + if gotBanner { + obs.Logf("SSH ready after %d attempts", attempt) + return nil + } + lastErr = fmt.Errorf("TCP open but no SSH banner at %s", addr) + } + lastErr = err + elapsed := timeout - time.Until(deadline) + obs.Progress(float64(elapsed)/float64(timeout), + fmt.Sprintf("waiting for SSH (%d attempts)", attempt)) + time.Sleep(interval) + } + return fmt.Errorf("SSH not ready at %s after %s: %w", addr, timeout, lastErr) +} diff --git a/internal/vm/qemu/engine_test.go b/internal/vm/qemu/engine_test.go new file mode 100644 index 0000000..44e955b --- /dev/null +++ b/internal/vm/qemu/engine_test.go @@ -0,0 +1,86 @@ +package qemu + +import ( + "net" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestEngine_SSHArgv(t *testing.T) { + spec := Spec{ + SSHUser: "devcell", + SSHHost: "127.0.0.1", + SSHPort: 2222, + } + e := NewEngine(spec, NopObserver{}) + argv := e.SSHArgv("powershell", []string{"-NoProfile"}, []string{"Get-Process"}) + joined := strings.Join(argv, " ") + assert.Contains(t, joined, "ssh") + assert.Contains(t, joined, "devcell@127.0.0.1") + assert.Contains(t, joined, "-p 2222") + assert.Contains(t, joined, "powershell") + assert.Contains(t, joined, "Get-Process") +} + +func TestEngine_Preflight_LinuxPasses(t *testing.T) { + // This test runs in the Linux container — preflight check should pass + // (QEMU binary may not be installed though, so we test the platform check separately) + err := PreflightCheck("linux", "amd64") + assert.NoError(t, err) +} + +func TestNewEngine(t *testing.T) { + spec := testSpec() + e := NewEngine(spec, NopObserver{}) + assert.NotNil(t, e) + assert.Equal(t, spec.VMName, e.Spec.VMName) +} + +func TestWaitForSSH_RejectsNoSSHBanner(t *testing.T) { + // Simulate QEMU's user-mode networking: accepts TCP but sends nothing + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + // Accept connection but send nothing — like QEMU before guest SSH starts + time.Sleep(5 * time.Second) + conn.Close() + } + }() + port := uint16(ln.Addr().(*net.TCPAddr).Port) + err = WaitForSSH("127.0.0.1", port, 3*time.Second, 500*time.Millisecond, NopObserver{}) + assert.Error(t, err, "WaitForSSH should fail when port is open but no SSH banner") +} + +func TestWaitForSSH_AcceptsRealSSHBanner(t *testing.T) { + // Simulate a real SSH server: accepts TCP and sends banner + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + conn.Write([]byte("SSH-2.0-OpenSSH_9.0\r\n")) + time.Sleep(1 * time.Second) + conn.Close() + } + }() + port := uint16(ln.Addr().(*net.TCPAddr).Port) + err = WaitForSSH("127.0.0.1", port, 5*time.Second, 500*time.Millisecond, NopObserver{}) + assert.NoError(t, err, "WaitForSSH should succeed when SSH banner is present") +} diff --git a/internal/vm/qemu/finalize.go b/internal/vm/qemu/finalize.go new file mode 100644 index 0000000..2e02e1d --- /dev/null +++ b/internal/vm/qemu/finalize.go @@ -0,0 +1,59 @@ +package qemu + +import ( + "fmt" + "os" + "os/exec" +) + +// FinalizeSpec derives the dev-env boot from a build spec: the same guest +// (disk, network identity, credentials) on the EL3 machine — secure=on with +// a kernel-loaded relocatable firmware — which is what lets Windows' own +// hypervisor, and therefore WSL2, run (docs/spec/QEMU-ARM64-WINDOWS11-WSL2-NIX.md §2.2). +// +// The install boot and this one are intentionally different machines: the +// installer is proven on the plain pflash machine, the WSL2 stack on this +// one. Only the boot environment changes; everything identifying the guest +// is carried over. +func FinalizeSpec(build Spec, kernelFirmware string) Spec { + fin := build + fin.VMName = build.VMName + "-devenv" + fin.SecureWorld = true + fin.FirmwareKernel = true + fin.FirmwarePath = kernelFirmware + // -kernel loading has no pflash NVRAM bank, and the secure machine + // supersedes the NestedVirt one. + fin.VarsPath = "" + fin.NestedVirt = false + // Install media stays behind. + fin.VirtioISO = "" + return fin +} + +// VirtiofsdPath resolves the host-side virtio-fs daemon: +// $DEVCELL_VIRTIOFSD, then PATH. +func VirtiofsdPath() (string, error) { + if p := os.Getenv("DEVCELL_VIRTIOFSD"); p != "" { + if _, err := os.Stat(p); err != nil { + return "", fmt.Errorf("virtiofsd: %w", err) + } + return p, nil + } + if p, err := exec.LookPath("virtiofsd"); err == nil { + return p, nil + } + return "", fmt.Errorf( + "virtiofsd not found: set DEVCELL_VIRTIOFSD or put it on PATH (nix build nixpkgs#virtiofsd)") +} + +// VirtiofsdCommand builds the daemon invocation for a project share. +// --sandbox none: the default sandbox needs user namespaces the devcell +// container does not have. The caller owns the process — virtiofsd exits +// whenever its client disconnects, so it must be started fresh for every VM +// boot that mounts the share. +func VirtiofsdCommand(bin, socketPath, sharedDir string) *exec.Cmd { + return exec.Command(bin, + "--socket-path", socketPath, + "--shared-dir", sharedDir, + "--sandbox", "none") +} diff --git a/internal/vm/qemu/finalize_test.go b/internal/vm/qemu/finalize_test.go new file mode 100644 index 0000000..cd889c6 --- /dev/null +++ b/internal/vm/qemu/finalize_test.go @@ -0,0 +1,105 @@ +package qemu + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The finalization phase boots the just-installed template on the EL3 +// machine. The derived spec must keep the guest's identity (disk, ports, +// credentials) and swap only the boot environment — and it must not share a +// VM name with the build boot, or the QMP socket paths collide. +func TestFinalizeSpec_DerivesEL3BootFromBuildSpec(t *testing.T) { + build := Spec{ + VMName: "devcell-qemu-build", + CPUs: 4, + MemoryGB: 6, + DiskPath: "/x/disk.qcow2", + FirmwarePath: "/usr/share/qemu/edk2-aarch64-code.fd", + VarsPath: "/x/vars.fd", + VirtioISO: "/x/virtio.iso", + SSHHost: "127.0.0.1", + SSHPort: 10022, + SSHUser: "dmitry", + SSHKeyPath: "/k/id_ed25519", + MACAddr: "52:54:00:00:00:01", + QMPSocketDir: "/x", + NestedVirt: true, + } + + fin := FinalizeSpec(build, "/cache/QEMU_EFI.kernel.fd") + + // Same guest, same access. + assert.Equal(t, build.DiskPath, fin.DiskPath) + assert.Equal(t, build.SSHPort, fin.SSHPort) + assert.Equal(t, build.SSHUser, fin.SSHUser) + assert.Equal(t, build.SSHKeyPath, fin.SSHKeyPath) + assert.Equal(t, build.MACAddr, fin.MACAddr) + + // New boot environment: EL3 via -kernel, no pflash vars bank, no NestedVirt + // (the secure machine supersedes it), no install media. + assert.True(t, fin.SecureWorld) + assert.True(t, fin.FirmwareKernel) + assert.Equal(t, "/cache/QEMU_EFI.kernel.fd", fin.FirmwarePath) + assert.Empty(t, fin.VarsPath, "-kernel loading has no pflash NVRAM bank") + assert.False(t, fin.NestedVirt, "SecureWorld machine replaces the NestedVirt one") + assert.Empty(t, fin.VirtioISO, "install media has no business in the finalize boot") + + assert.NotEqual(t, build.VMName, fin.VMName, + "a distinct VM name keeps QMP/pid paths from colliding with the build boot") +} + +// End to end through the command builder: the finalize spec must emit exactly +// the proven WSL2 machine line. +func TestFinalizeSpec_ArgvIsTheProvenWSL2Machine(t *testing.T) { + build := Spec{ + VMName: "devcell-qemu-build", + CPUs: 4, + MemoryGB: 6, + DiskPath: "/x/disk.qcow2", + SSHPort: 10022, + } + fin := FinalizeSpec(build, "/cache/QEMU_EFI.kernel.fd") + fin.ApplyDefaults() + joined := strings.Join(BuildRunCommand(fin), " ") + + assert.Contains(t, joined, "secure=on") + assert.Contains(t, joined, "-cpu neoverse-n1") + assert.Contains(t, joined, "-kernel /cache/QEMU_EFI.kernel.fd") + assert.NotContains(t, joined, "if=pflash") +} + +// The host side of the project share. The binary comes from +// DEVCELL_VIRTIOFSD or PATH; the command must expose the shared dir on the +// socket the spec points at, with --sandbox none (the container has no user +// namespaces for virtiofsd's default sandbox). +func TestVirtiofsdCommand_SharesTheProjectOnTheSocket(t *testing.T) { + dir := t.TempDir() + bin := filepath.Join(dir, "virtiofsd") + require.NoError(t, os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o755)) + t.Setenv("DEVCELL_VIRTIOFSD", bin) + + resolved, err := VirtiofsdPath() + require.NoError(t, err) + assert.Equal(t, bin, resolved) + + cmd := VirtiofsdCommand(resolved, "/tmp/fs.sock", "/repo") + joined := strings.Join(cmd.Args, " ") + assert.Contains(t, joined, "--socket-path /tmp/fs.sock") + assert.Contains(t, joined, "--shared-dir /repo") + assert.Contains(t, joined, "--sandbox none") +} + +func TestVirtiofsdPath_ErrorNamesTheKnob(t *testing.T) { + t.Setenv("DEVCELL_VIRTIOFSD", "") + t.Setenv("PATH", t.TempDir()) + _, err := VirtiofsdPath() + require.Error(t, err) + assert.Contains(t, err.Error(), "DEVCELL_VIRTIOFSD", + "the error must say how to point at a binary") +} diff --git a/internal/vm/qemu/firmware.go b/internal/vm/qemu/firmware.go new file mode 100644 index 0000000..fa4f755 --- /dev/null +++ b/internal/vm/qemu/firmware.go @@ -0,0 +1,63 @@ +package qemu + +import ( + "bytes" + "fmt" + "os" + "path/filepath" +) + +// KernelFirmwareCacheName is where a kernel-bootable EDK2 image lives in the +// devcell cache (~/.devcell/cache/qemu/). Named distinctly from QEMU_EFI.fd +// on purpose: every distro ships a *different, incompatible* build under that +// name, and telling them apart by filename is exactly the trap. +const KernelFirmwareCacheName = "QEMU_EFI.kernel.fd" + +// CheckKernelBootableFirmware verifies that path holds the ArmVirtQemuKernel +// EDK2 build — the relocatable image with the ARM64 kernel-image magic +// ("ARMd" at offset 56) that QEMU's -kernel loader understands. The common +// ArmVirtQemu build (what nixpkgs, Debian and openSUSE all ship as +// QEMU_EFI.fd) is linked for flash address 0 and boots to *silence* when +// loaded into DRAM, so this must be checked, not assumed. +func CheckKernelBootableFirmware(path string) error { + f, err := os.Open(path) + if err != nil { + return fmt.Errorf("kernel firmware: %w", err) + } + defer f.Close() + header := make([]byte, 60) + if _, err := f.ReadAt(header, 0); err != nil { + return fmt.Errorf("kernel firmware %s: reading header: %w", path, err) + } + if !bytes.Equal(header[56:60], []byte("ARMd")) { + return fmt.Errorf( + "%s is not an ArmVirtQemuKernel build (no ARM64 kernel-image magic at offset 56) — "+ + "it would boot to silence on the secure=on machine; build one with: "+ + `nix build --impure --expr 'let pkgs = (builtins.getFlake "nixpkgs").legacyPackages.$`+ + `{builtins.currentSystem}; in (pkgs.OVMF.override { projectDscPath = "ArmVirtPkg/ArmVirtQemuKernel.dsc"; }).fd'`, + path) + } + return nil +} + +// KernelFirmwarePath resolves the firmware for the WSL2 machine (secure=on + +// -kernel): $DEVCELL_QEMU_EFI_KERNEL if set, else the devcell cache. An +// explicit override that fails validation is an error rather than a +// fallthrough — the user pointed at a specific file, and using another would +// hide the mistake behind a silent boot failure later. +func KernelFirmwarePath() (string, error) { + if p := os.Getenv("DEVCELL_QEMU_EFI_KERNEL"); p != "" { + if err := CheckKernelBootableFirmware(p); err != nil { + return "", err + } + return p, nil + } + home, _ := os.UserHomeDir() + cached := filepath.Join(home, ".devcell", "cache", "qemu", KernelFirmwareCacheName) + if err := CheckKernelBootableFirmware(cached); err != nil { + return "", fmt.Errorf( + "no kernel-bootable firmware: set DEVCELL_QEMU_EFI_KERNEL or place one at %s (%w)", + cached, err) + } + return cached, nil +} diff --git a/internal/vm/qemu/firmware_fault.go b/internal/vm/qemu/firmware_fault.go new file mode 100644 index 0000000..50a91e1 --- /dev/null +++ b/internal/vm/qemu/firmware_fault.go @@ -0,0 +1,105 @@ +package qemu + +import ( + "fmt" + "strconv" + "strings" +) + +// edk2Banner is printed once per firmware start. Counting it is the cheapest +// reliable way to notice the guest reset: a screendump cannot tell "back at +// the firmware splash" from "never left it". +const edk2Banner = "UEFI firmware (version" + +// FirmwareBootCount reports how many times the guest firmware started. +// More than one during an install that has not finished applying its image +// means the guest reset prematurely. +func FirmwareBootCount(serial string) int { + return strings.Count(serial, edk2Banner) +} + +// FirmwareFault is EDK2's CPU exception dump, which it prints to the serial +// console before giving up. Its presence means the *firmware* died — not the +// guest OS — so no amount of further waiting can help. +type FirmwareFault struct { + SP string + ELR string + ESR string + FAR string + Description string // e.g. "Data abort: Translation fault, second level" +} + +// ParseFirmwareFault extracts the crash dump from a serial log, if present. +func ParseFirmwareFault(serial string) (FirmwareFault, bool) { + f := FirmwareFault{ + SP: fieldAfter(serial, "SP 0x"), + ELR: fieldAfter(serial, "ELR 0x"), + ESR: fieldAfter(serial, "ESR 0x"), + FAR: fieldAfter(serial, "FAR 0x"), + } + for _, line := range strings.Split(serial, "\n") { + l := strings.TrimSpace(strings.ReplaceAll(line, "\r", "")) + if strings.HasPrefix(l, "Data abort:") || + strings.HasPrefix(l, "Prefetch abort:") || + strings.HasPrefix(l, "Synchronous Exception") { + f.Description = l + break + } + } + if f.ESR == "" || f.FAR == "" { + return FirmwareFault{}, false + } + return f, true +} + +// fieldAfter returns the hex value following a "NAME 0x" marker, normalised +// back to a 0x-prefixed string. EDK2 pads these fields with varying +// whitespace, so the marker carries its own "0x". +func fieldAfter(s, marker string) string { + i := strings.Index(s, marker) + if i < 0 { + return "" + } + rest := s[i+len(marker):] + end := 0 + for end < len(rest) && isHexDigit(rest[end]) { + end++ + } + if end == 0 { + return "" + } + return "0x" + rest[:end] +} + +func isHexDigit(c byte) bool { + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') +} + +// Summary interprets the dump in one line. +// +// The load-bearing observation for the 2026-07-30 install failure: the +// faulting address sits just *below* the stack pointer, and the stack pointer +// sits at the very bottom of guest RAM (QEMU virt puts RAM at 0x40000000, with +// the PCIe ECAM window immediately below it). That is a stack that ran off the +// bottom of its region — not a stray pointer. +func (f FirmwareFault) Summary() string { + base := fmt.Sprintf("EDK2 %s (ESR=%s FAR=%s ELR=%s SP=%s)", + orUnknown(f.Description), f.ESR, f.FAR, f.ELR, f.SP) + + sp, spErr := strconv.ParseUint(strings.TrimPrefix(f.SP, "0x"), 16, 64) + far, farErr := strconv.ParseUint(strings.TrimPrefix(f.FAR, "0x"), 16, 64) + if spErr != nil || farErr != nil || far >= sp { + return base + } + return base + fmt.Sprintf( + " — the faulting address is 0x%x below the SP, so the firmware overran its stack; "+ + "guest RAM starts at 0x40000000 and the PCIe ECAM window sits directly below it", + sp-far) +} + +func orUnknown(s string) string { + if s == "" { + return "CPU exception" + } + return s +} diff --git a/internal/vm/qemu/firmware_fault_test.go b/internal/vm/qemu/firmware_fault_test.go new file mode 100644 index 0000000..325bb70 --- /dev/null +++ b/internal/vm/qemu/firmware_fault_test.go @@ -0,0 +1,69 @@ +package qemu + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Firmware boot/fault parsing, against the real serial output of run +// 20260730T140237 — the install that reset ~4 minutes in with only 234MB +// written, then died in EDK2 on the second boot. + +const twoBootsWithFault = `UEFI firmware (version edk2-stable202408-prebuilt.qemu.org built at 16:28:50 on Sep 12 2024) +ArmTrngLib could not be correctly initialized. +Tpm2SubmitCommand - Tcg2 - Not Found +BdsDxe: loading Boot0001 "UEFI QEMU QEMU USB HARDDRIVE 1-0000:00:03.0-3" +BdsDxe: starting Boot0001 "UEFI QEMU QEMU USB HARDDRIVE 1-0000:00:03.0-3" +UEFI firmware (version edk2-stable202408-prebuilt.qemu.org built at 16:28:50 on Sep 12 2024) +ArmTrngLib could not be correctly initialized. +BdsDxe: failed to load Boot0003 "UEFI QEMU NVMe Ctrl devcell0 1": Not Found +BdsDxe: starting Boot0001 "UEFI QEMU QEMU USB HARDDRIVE 1-0000:00:03.0-3" + SP 0x0000000040000070 ELR 0x00000001BC266280 SPSR 0x60002749 FPSR 0x00000000 + ESR 0x96000046 FAR 0x000000003FFFFFD0 + + ESR : EC 0x25 IL 0x1 ISS 0x00000046 + +Data abort: Translation fault, second level + +Stack dump: + +Recursive exception occurred while dumping the CPU state +` + +func TestFirmwareBootCount(t *testing.T) { + assert.Equal(t, 2, FirmwareBootCount(twoBootsWithFault), + "two EDK2 banners means the guest reset once") + assert.Equal(t, 0, FirmwareBootCount("")) + assert.Equal(t, 1, FirmwareBootCount("UEFI firmware (version edk2-stable202408) \nBdsDxe: starting")) +} + +func TestFirmwareFault_ParsesTheRealCrash(t *testing.T) { + f, ok := ParseFirmwareFault(twoBootsWithFault) + require.True(t, ok, "the EDK2 crash dump must be recognised") + + assert.Equal(t, "0x96000046", f.ESR) + assert.Equal(t, "0x000000003FFFFFD0", f.FAR) + assert.Equal(t, "0x00000001BC266280", f.ELR) + assert.Equal(t, "0x0000000040000070", f.SP) + assert.Contains(t, f.Description, "Translation fault") +} + +// The interpretation that turns four hex numbers into the actual finding: +// SP sits just above RAM base and the faulting address is below it, so the +// firmware ran its stack off the bottom of RAM into the PCIe ECAM window. +func TestFirmwareFault_StackUnderflowInterpretation(t *testing.T) { + f, ok := ParseFirmwareFault(twoBootsWithFault) + require.True(t, ok) + + s := f.Summary() + assert.Contains(t, s, "stack", "must name the stack as the mechanism") + assert.Contains(t, s, "below the SP", "must state the fault is below the stack pointer") + assert.Contains(t, s, "0xa0", "must quantify how far below") +} + +func TestFirmwareFault_NoFaultInCleanLog(t *testing.T) { + _, ok := ParseFirmwareFault("UEFI firmware (version edk2)\nBdsDxe: starting Boot0001\n") + assert.False(t, ok) +} diff --git a/internal/vm/qemu/gdb.go b/internal/vm/qemu/gdb.go new file mode 100644 index 0000000..2b8c9d2 --- /dev/null +++ b/internal/vm/qemu/gdb.go @@ -0,0 +1,266 @@ +package qemu + +import ( + "encoding/binary" + "encoding/hex" + "fmt" + "net" + "strings" + "time" +) + +// GDBConn is a minimal GDB Remote Serial Protocol client that can +// read and write guest virtual memory through QEMU's built-in GDB stub. +type GDBConn struct { + conn net.Conn +} + +// GDBDial connects to QEMU's GDB stub at the given address (e.g. +// "tcp:localhost:1234" or "unix:/path/to/sock"). It sends the initial +// handshake and returns a ready-to-use connection. +func GDBDial(addr string, timeout time.Duration) (*GDBConn, error) { + network, address, _ := strings.Cut(addr, ":") + if network == "unix" { + // addr was "unix:/path" + } else { + // addr was "tcp:host:port" — recombine host:port + network = "tcp" + address = addr[len("tcp:"):] + } + + conn, err := net.DialTimeout(network, address, timeout) + if err != nil { + return nil, fmt.Errorf("gdb dial %s: %w", addr, err) + } + conn.SetDeadline(time.Now().Add(timeout)) + + g := &GDBConn{conn: conn} + + // QEMU sends '+' and possibly a stop notification ($T05#b9) on + // connect. Drain everything available within a short window. + _ = g.conn.SetReadDeadline(time.Now().Add(1 * time.Second)) + drain := make([]byte, 256) + for { + n, err := g.conn.Read(drain) + if err != nil || n == 0 { + break + } + // ACK any stop notification the stub sent + g.conn.Write([]byte("+")) + } + _ = g.conn.SetReadDeadline(time.Time{}) + + return g, nil +} + +func (g *GDBConn) Close() error { + return g.conn.Close() +} + +// gdbChecksum computes the GDB RSP checksum (sum of bytes mod 256). +func gdbChecksum(data []byte) byte { + var sum byte + for _, b := range data { + sum += b + } + return sum +} + +// sendPacket sends a GDB RSP packet and reads the ACK + reply. +func (g *GDBConn) sendPacket(payload string) (string, error) { + csum := gdbChecksum([]byte(payload)) + pkt := fmt.Sprintf("$%s#%02x", payload, csum) + + g.conn.SetDeadline(time.Now().Add(5 * time.Second)) + + if _, err := g.conn.Write([]byte(pkt)); err != nil { + return "", fmt.Errorf("gdb write: %w", err) + } + + // Read response: optional '+', then '$...#xx' + buf := make([]byte, 4096) + var resp []byte + for { + n, err := g.conn.Read(buf) + if err != nil { + return "", fmt.Errorf("gdb read: %w", err) + } + resp = append(resp, buf[:n]...) + + // Look for complete packet: $...#xx + s := string(resp) + dollarIdx := strings.Index(s, "$") + if dollarIdx < 0 { + continue + } + hashIdx := strings.Index(s[dollarIdx:], "#") + if hashIdx < 0 { + continue + } + hashIdx += dollarIdx + if len(s) >= hashIdx+3 { + body := s[dollarIdx+1 : hashIdx] + // Send ACK + g.conn.Write([]byte("+")) + return body, nil + } + } +} + +// Stop halts the guest (equivalent to Ctrl-C in GDB). +func (g *GDBConn) Stop() error { + g.conn.SetDeadline(time.Now().Add(5 * time.Second)) + // Send interrupt (0x03) + if _, err := g.conn.Write([]byte{0x03}); err != nil { + return fmt.Errorf("gdb stop: %w", err) + } + // Read the stop reply + buf := make([]byte, 256) + var resp []byte + for { + n, err := g.conn.Read(buf) + if err != nil { + return fmt.Errorf("gdb stop read: %w", err) + } + resp = append(resp, buf[:n]...) + if strings.Contains(string(resp), "#") { + break + } + } + return nil +} + +// Continue resumes guest execution. +func (g *GDBConn) Continue() error { + // 'c' resumes; the stub sends a stop reply only when it halts again, + // so we just fire and don't wait for a reply. + csum := gdbChecksum([]byte("c")) + pkt := fmt.Sprintf("$c#%02x", csum) + g.conn.SetDeadline(time.Now().Add(5 * time.Second)) + _, err := g.conn.Write([]byte(pkt)) + return err +} + +// ReadMemory reads len bytes from virtual address addr. +func (g *GDBConn) ReadMemory(addr uint64, length int) ([]byte, error) { + cmd := fmt.Sprintf("m%x,%x", addr, length) + reply, err := g.sendPacket(cmd) + if err != nil { + return nil, err + } + if strings.HasPrefix(reply, "E") { + return nil, fmt.Errorf("gdb read memory error: %s", reply) + } + return hex.DecodeString(reply) +} + +// WriteMemory writes data to virtual address addr. +func (g *GDBConn) WriteMemory(addr uint64, data []byte) error { + cmd := fmt.Sprintf("M%x,%x:%s", addr, len(data), hex.EncodeToString(data)) + reply, err := g.sendPacket(cmd) + if err != nil { + return err + } + if reply != "OK" { + return fmt.Errorf("gdb write memory: %s", reply) + } + return nil +} + +// WriteUint16LE writes a little-endian uint16 to the given virtual address. +func (g *GDBConn) WriteUint16LE(addr uint64, val uint16) error { + buf := make([]byte, 2) + binary.LittleEndian.PutUint16(buf, val) + return g.WriteMemory(addr, buf) +} + +// ReadUint16LE reads a little-endian uint16 from the given virtual address. +func (g *GDBConn) ReadUint16LE(addr uint64) (uint16, error) { + data, err := g.ReadMemory(addr, 2) + if err != nil { + return 0, err + } + return binary.LittleEndian.Uint16(data), nil +} + +// SetBreakpoint inserts a software breakpoint (Z0) at addr. +func (g *GDBConn) SetBreakpoint(addr uint64) error { + cmd := fmt.Sprintf("Z0,%x,4", addr) + reply, err := g.sendPacket(cmd) + if err != nil { + return err + } + if reply != "OK" { + return fmt.Errorf("gdb set breakpoint: %s", reply) + } + return nil +} + +// RemoveBreakpoint removes a software breakpoint (z0) at addr. +func (g *GDBConn) RemoveBreakpoint(addr uint64) error { + cmd := fmt.Sprintf("z0,%x,4", addr) + reply, err := g.sendPacket(cmd) + if err != nil { + return err + } + if reply != "OK" { + return fmt.Errorf("gdb remove breakpoint: %s", reply) + } + return nil +} + +// ReadRegisters reads all general-purpose registers via the 'g' packet. +// Returns the raw hex-encoded register dump. +func (g *GDBConn) ReadRegisters() (string, error) { + return g.sendPacket("g") +} + +// ReadRegister reads a single register by index via the 'p' packet. +// AArch64 QEMU register indices: x0-x30 = 0-30, SP = 31, PC = 32, +// CPSR = 33, V0-V31 = 34-65, FPSR = 66, FPCR = 67, +// ELR_EL1 = 68 (0x44), ... system regs vary by QEMU version. +func (g *GDBConn) ReadRegister(index int) ([]byte, error) { + reply, err := g.sendPacket(fmt.Sprintf("p%x", index)) + if err != nil { + return nil, err + } + if strings.HasPrefix(reply, "E") { + return nil, fmt.Errorf("gdb read register %d: %s", index, reply) + } + return hex.DecodeString(reply) +} + +// WriteRegister writes a single register by index via the 'P' packet. +func (g *GDBConn) WriteRegister(index int, data []byte) error { + reply, err := g.sendPacket(fmt.Sprintf("P%x=%s", index, hex.EncodeToString(data))) + if err != nil { + return err + } + if reply != "OK" { + return fmt.Errorf("gdb write register %d: %s", index, reply) + } + return nil +} + +// WaitBreak waits for the stub to report a stop event (breakpoint hit, +// signal, etc). Returns the raw stop-reply packet body. +func (g *GDBConn) WaitBreak(timeout time.Duration) (string, error) { + g.conn.SetDeadline(time.Now().Add(timeout)) + buf := make([]byte, 4096) + var resp []byte + for { + n, err := g.conn.Read(buf) + if err != nil { + return "", fmt.Errorf("gdb wait: %w", err) + } + resp = append(resp, buf[:n]...) + s := string(resp) + if idx := strings.Index(s, "$"); idx >= 0 { + if end := strings.Index(s[idx:], "#"); end >= 0 && len(s) >= idx+end+3 { + body := s[idx+1 : idx+end] + g.conn.Write([]byte("+")) + return body, nil + } + } + } +} diff --git a/internal/vm/qemu/golden_test.go b/internal/vm/qemu/golden_test.go new file mode 100644 index 0000000..2bc567c --- /dev/null +++ b/internal/vm/qemu/golden_test.go @@ -0,0 +1,75 @@ +package qemu + +import ( + "crypto/sha256" + "encoding/hex" + "testing" + + "github.com/devcell-sh/go-winkit/unattend" + "github.com/devcell-sh/go-winkit/winpe" + + "github.com/stretchr/testify/require" +) + +// Byte-for-byte fingerprints of every generated guest artifact. +// +// The existing tests assert `Contains` on individual lines, which cannot catch +// a stray newline, a reordered attribute, or a lost indent. That is precisely +// the class of damage a template extraction (CELL-387) could do: the scripts +// would still contain every asserted substring while rendering differently, and +// the difference would only surface hours later inside a guest. +// +// These are not golden files to be blessed casually. A deliberate change to a +// generated script updates the hash *in the same commit as the change* and the +// reviewer sees both. A refactor that claims to change nothing must not touch +// them at all. +func TestGeneratedArtifacts_AreByteStable(t *testing.T) { + cfg := unattend.DefaultConfig() + cfg.Username = "dmitry" + cfg.Password = "rdp" + cfg.SSHPubKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIexample test@devcell" + cfg.VirtIODrivers = unattend.NetKVMDriverPaths() + cfg.EnableRDP = true + cfg.OpenSSHPayload = unattend.OpenSSHPayloadName + cfg.OpenSSHPayloadSize = 5026201 + cfg.WinPEAgent = true + + for _, tc := range []struct { + name string + got []byte + want string // sha256 of the rendered bytes + }{ + {"autounattend.xml", unattend.GenerateXML(cfg), + "cda4911e1a517553946024387f65e529fc46f89921fc04f24eb65768212874ec"}, + // bootstrap hash updated 2026-08-23: unattend.OpenSSHPayloadName carries the + // pinned version, and the payload's filename is rendered into the + // script. The installed-Windows path still ships Win32-OpenSSH — + // only the WinPE path moved to gosshd, and it renders no bootstrap. + {"devcell-bootstrap.ps1", unattend.GenerateBootstrapScript(cfg), + "cc0bfbe535377c50e488e08021010f02c765684c7264f491529b658d739ef169"}, + // diag hash updated 2026-08-13: added routing table, QEMU host + // connectivity, DNS resolution, and Get-NetIPConfiguration. + {"devcell-diag.ps1", unattend.GenerateGuestDiagnosticsScript(), + "c9414853b704ca0414de91ad507904dc04a2fad68963fcffe11eb55768a132fa"}, + // winpe-agent hash updated 2026-08-22: CELL-453 template extraction + // (winpe-agent.ps1.tmpl) rendered the agent from a file instead of + // spliced Go strings. + {"winpe-agent", winpe.GenerateAgent(winpe.PayloadConfig{}), + "3b2f6af18f68d4a16bc9eeeebd29f90e0626baeb7ad2b3e6550a7161151f1e32"}, + // The verify/boot pass scripts joined the golden set 2026-08-23 when + // the WSL pass4 script landed (CELL-456). + // vmp-verify hash updated 2026-08-27: moved to go-winkit; sc.exe → New-Service. + {"devcell-vmp-verify.ps1", winpe.GenerateVMPVerifyScript(), + "ae3e57b581c5c9f78ba46b80f8b3dceba6859225965dfb9c120ecd252f4b2a42"}, + {"devcell-hcs-boot.ps1", winpe.GenerateHCSBootScript(), + "5f74309af5aa67d8f61787b02105db22da8d2b14105882c0fcc91b15c2fd7d11"}, + {"devcell-wsl-boot.ps1", winpe.GenerateWSLBootScript(), + "c9ea446dce74a348976966d6fd591f079ee291bea9f9d838e5e6e81fefdcedb1"}, + } { + sum := hex.EncodeToString(func() []byte { h := sha256.Sum256(tc.got); return h[:] }()) + require.Equal(t, tc.want, sum, + "%s rendered differently (%d bytes). If this change is intended, update the hash in "+ + "the same commit as the change so a reviewer sees both; if it is a refactor that "+ + "claims to change nothing, it changed something.", tc.name, len(tc.got)) + } +} diff --git a/internal/vm/qemu/gosshd_payload.go b/internal/vm/qemu/gosshd_payload.go new file mode 100644 index 0000000..18f9f7e --- /dev/null +++ b/internal/vm/qemu/gosshd_payload.go @@ -0,0 +1,53 @@ +package qemu + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +const ( + // GoSSHDPayloadName is the server's filename on the agent volume. + GoSSHDPayloadName = "devcell-gosshd.exe" + + // GoSSHDPackage is the package cross-compiled into that payload. + GoSSHDPackage = "github.com/devcell-sh/go-winkit/gosshd/cmd/gosshd" + + // GoSSHDLogFile is the server's log on the shared volume. It is not + // written to the guest ramdisk: a session that fails minutes in still + // has to be explainable after the VM is gone. + GoSSHDLogFile = "devcell-gosshd.log" +) + +// BuildGoSSHDPayload cross-compiles the guest SSH server for windows/arm64 +// into dir and returns its path. +// +// Building beats downloading: the previous Win32-OpenSSH payload was a +// pinned GitHub release that had to be cached, checksummed and version- +// guarded, and an unpinned URL silently moved us onto a release whose split +// binaries changed the failure mode mid-investigation. This binary is the +// tree's own code, so it cannot drift from the harness that talks to it. +// +// CGO is off so the result is a single static binary with no DLL +// dependencies — WinPE has a reduced System32 and cannot be assumed to +// carry any particular runtime. +func BuildGoSSHDPayload(dir string) (string, error) { + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", fmt.Errorf("gosshd payload dir: %w", err) + } + out := filepath.Join(dir, GoSSHDPayloadName) + + cmd := exec.Command("go", "build", "-o", out, GoSSHDPackage) + cmd.Env = append(os.Environ(), + "GOOS=windows", + "GOARCH=arm64", + "CGO_ENABLED=0", + ) + if combined, err := cmd.CombinedOutput(); err != nil { + return "", fmt.Errorf("building %s for windows/arm64: %w: %s", + GoSSHDPackage, err, strings.TrimSpace(string(combined))) + } + return out, nil +} diff --git a/internal/vm/qemu/gosshd_payload_test.go b/internal/vm/qemu/gosshd_payload_test.go new file mode 100644 index 0000000..d2a03fa --- /dev/null +++ b/internal/vm/qemu/gosshd_payload_test.go @@ -0,0 +1,35 @@ +package qemu + +import ( + "debug/pe" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The guest's SSH server is built from this repo rather than downloaded. +// The old Win32-OpenSSH payload was a pinned GitHub release; this one cannot +// drift, cannot 404, and needs no cache. +func TestBuildGoSSHDPayload_ProducesAnARM64WindowsBinary(t *testing.T) { + path, err := BuildGoSSHDPayload(t.TempDir()) + require.NoError(t, err, "cross-compiling the gosshd payload") + + f, err := pe.Open(path) + require.NoError(t, err, "the payload must be a PE binary") + defer f.Close() + + assert.Equal(t, uint16(pe.IMAGE_FILE_MACHINE_ARM64), f.Machine, + "WinPE here is ARM64; an amd64 payload would not run") + + info, err := os.Stat(path) + require.NoError(t, err) + assert.Greater(t, info.Size(), int64(1<<20), + "a statically linked Go server is megabytes; a tiny file means a failed link") +} + +// The payload name is what the guest script starts, so the two must agree. +func TestGoSSHDPayloadName_IsAWindowsExecutable(t *testing.T) { + assert.Equal(t, "devcell-gosshd.exe", GoSSHDPayloadName) +} diff --git a/internal/vm/qemu/gosshd_serve.go b/internal/vm/qemu/gosshd_serve.go new file mode 100644 index 0000000..afc17ce --- /dev/null +++ b/internal/vm/qemu/gosshd_serve.go @@ -0,0 +1,24 @@ +package qemu + +// GoSSHDServeData is the template context the "gosshd-serve" partial needs. +// Embed it in a script's own data struct to include the partial: +// +// data := struct { +// GoSSHDServeData +// Banner string +// }{GoSSHDServeData: DefaultGoSSHDServeData(), Banner: "..."} +type GoSSHDServeData struct { + // SSHExe is the server payload's filename on the agent volume. + SSHExe string + // ServerLog is the server's log filename on the agent volume. + ServerLog string +} + +// DefaultGoSSHDServeData wires the partial to the payload names the host +// stages. +func DefaultGoSSHDServeData() GoSSHDServeData { + return GoSSHDServeData{ + SSHExe: GoSSHDPayloadName, + ServerLog: GoSSHDLogFile, + } +} diff --git a/internal/vm/qemu/guest/Devcell.psm1 b/internal/vm/qemu/guest/Devcell.psm1 new file mode 100644 index 0000000..2b2fb6b --- /dev/null +++ b/internal/vm/qemu/guest/Devcell.psm1 @@ -0,0 +1,90 @@ +# Devcell.psm1 — the shared guest library. +# +# Delivered fresh on the per-run control volume (never baked into a qcow2, so +# a checkpoint image cannot freeze a stale copy). Every stage script imports +# this module; nothing here is generated, interpolated, or templated — it is +# real PowerShell, lintable and runnable standalone on a guest. + +Set-StrictMode -Version Latest +# Progress records travel over SSH as CLIXML and once turned two stage logs +# into 8.9MB and 11.8MB of noise; the run at 20260803T083705 showed them +# again from Get-Volume. +$ProgressPreference = 'SilentlyContinue' + +# Get-DevcellControlVolume returns the drive letter of the control volume, +# found by its marker file. The letter is assigned by Windows and moves +# between boots (observed D: and E:), so it must never be hardcoded. +function Get-DevcellControlVolume { + param([string]$Marker = 'devcell-guest-logs.txt') + $vol = (Get-Volume | + Where-Object DriveLetter | + Where-Object { Test-Path ($_.DriveLetter + ':\' + $Marker) } | + Select-Object -First 1).DriveLetter + return $vol +} + +# Write-DevcellLog appends a timestamped line to BOTH the SSH stream and the +# control volume. Add-Content flushes per call, so a long stage is readable +# while it runs — proven 20260803T073911, where the host saw a line 21s +# before the stage ended. Start-Transcript alone buffers and reveals nothing. +function Write-DevcellLog { + param( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)][string]$Message, + [string]$LogFile = $script:DevcellLogFile + ) + process { + $line = ((Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + ' ' + $Message) + Write-Output $line + if ($LogFile) { + try { Add-Content -Path $LogFile -Value $line -Encoding utf8 -ErrorAction Stop } + catch { Write-Output ('[log] volume write failed: ' + $_.Exception.Message) } + } + } +} + +# Invoke-DevcellStep runs a labelled unit of work, timing it and reporting +# the outcome. Without it a 20-minute operation logs nothing until it ends, +# which is how a timeout became indistinguishable from a hang. +function Invoke-DevcellStep { + param( + [Parameter(Mandatory = $true)][string]$Label, + [Parameter(Mandatory = $true)][scriptblock]$Body + ) + Write-DevcellLog ('step start: ' + $Label) + $sw = [Diagnostics.Stopwatch]::StartNew() + try { + & $Body 2>&1 | ForEach-Object { Write-DevcellLog (' ' + $_) } + Write-DevcellLog ('step ok: ' + $Label + ' in ' + [int]$sw.Elapsed.TotalSeconds + 's') + } catch { + Write-DevcellLog ('step FAILED: ' + $Label + ' after ' + [int]$sw.Elapsed.TotalSeconds + 's: ' + $_.Exception.Message) + throw + } +} + +# Assert-DevcellExitCode fails a stage on a native command's exit code. +# Native tools (wsl.exe, pnputil, msiexec) do not throw; three stages once +# reported success over a failed command because only $LASTEXITCODE knew. +function Assert-DevcellExitCode { + param([Parameter(Mandatory = $true)][string]$What, [int]$Code = $global:LASTEXITCODE) + if ($Code -ne 0) { throw ($What + ' failed with exit code ' + $Code) } +} + +# Initialize-DevcellLogging resolves the control volume once, loudly, and +# points Write-DevcellLog at this stage's component log. A missing volume is +# reported, never swallowed — silent catch{} is why volume logs were empty +# for two days with no explanation. +function Initialize-DevcellLogging { + param([Parameter(Mandatory = $true)][string]$LogName, [string]$Marker = 'devcell-guest-logs.txt') + $script:DevcellLogVol = Get-DevcellControlVolume -Marker $Marker + if ($script:DevcellLogVol) { + $script:DevcellLogFile = ($script:DevcellLogVol + ':\' + $LogName) + Write-Output ('[log] volume ' + $script:DevcellLogVol + ': -> ' + $script:DevcellLogFile) + } else { + $script:DevcellLogFile = $null + Write-Output '[log] CONTROL VOLUME NOT FOUND - guest-side logs will not reach the host' + } + return $script:DevcellLogVol +} + +Export-ModuleMember -Function Write-DevcellLog, Invoke-DevcellStep, + Get-DevcellControlVolume, Assert-DevcellExitCode, Initialize-DevcellLogging diff --git a/internal/vm/qemu/guest/helpers/activate-home-manager.sh b/internal/vm/qemu/guest/helpers/activate-home-manager.sh new file mode 100644 index 0000000..d5bc962 --- /dev/null +++ b/internal/vm/qemu/guest/helpers/activate-home-manager.sh @@ -0,0 +1,59 @@ +#!/bin/sh +# Activate nixhome via home-manager inside NixOS-WSL. Runs as root, and the +# WHOLE sequence shares this one process tree: WSL kills background processes +# when the wsl.exe session ends, so the nix-daemon that nix-verify started is +# dead by now — it must be ensured here, beside the switch that needs it +# (WSL#13236; proven run 20260804). +# +# Usage: activate-home-manager.sh [flake-attr] +# user distro user the flake was built for (nixhome pins "nixos") +# tarball-path nixhome.tgz as seen inside WSL (/mnt//devcell/nixhome.tgz) +# flake-attr optional; defaults to wsl-base with the arch suffix +# +# nixhome travels as a TARBALL and is extracted to ext4: activating from the +# share path fails on nix's dirty-git-tree ingestion and on readlink over +# virtiofs+drvfs (36 symlinks in the icewm theme, run 20260804). +set -u +USER_NAME=${1:-nixos} +TARBALL=${2:?tarball path required} +ATTR=${3:-} + +SOCKET=/nix/var/nix/daemon-socket/socket +DAEMON=/run/current-system/sw/bin/nix-daemon + +if ! su - "$USER_NAME" -c 'timeout 30 nix-store --add /etc/hostname' >/dev/null 2>&1; then + echo "nix-daemon not answering - starting manually (WSL#13236)" + rm -f "$SOCKET" + "$DAEMON" & + i=0 + while [ $i -lt 60 ]; do + test -S "$SOCKET" && break + sleep 1 + i=$((i+1)) + done + test -S "$SOCKET" || { echo "SOCKET_FAIL"; exit 1; } + echo "SOCKET_OK" + sleep 3 +fi +su - "$USER_NAME" -c 'timeout 60 nix-store --add /etc/hostname' >/dev/null || { echo "STORE_FAIL"; exit 1; } +echo "STORE_OK" + +su - "$USER_NAME" -c 'mkdir -p ~/.config/nix && printf "experimental-features = nix-command flakes\n" > ~/.config/nix/nix.conf' + +SRC="/home/$USER_NAME/nixhome-src" +rm -rf "$SRC" +mkdir -p "$SRC" +tar -xzf "$TARBALL" -C "$SRC" || { echo "TAR_FAIL"; exit 1; } +chown -R "$USER_NAME": "$SRC" +echo "NIXHOME_COPIED" + +if [ -z "$ATTR" ]; then + SUFFIX=$([ "$(uname -m)" = "aarch64" ] && echo "-aarch64" || echo "") + ATTR="wsl-base$SUFFIX" +fi + +echo "HM_START attr=$ATTR" +su - "$USER_NAME" -c "cd $SRC/nixhome && nix --extra-experimental-features nix-command --extra-experimental-features flakes run home-manager/release-26.05 -- switch -b backup --flake .#$ATTR > /tmp/devcell-hm-activate.log 2>&1; rc=\$?; tail -40 /tmp/devcell-hm-activate.log; exit \$rc" +rc=$? +echo "HM_EXIT=$rc" +exit $rc diff --git a/internal/vm/qemu/guest/helpers/nixos-rebuild-boot.sh b/internal/vm/qemu/guest/helpers/nixos-rebuild-boot.sh new file mode 100644 index 0000000..2cddaff --- /dev/null +++ b/internal/vm/qemu/guest/helpers/nixos-rebuild-boot.sh @@ -0,0 +1,46 @@ +#!/bin/sh +# Build the next NixOS generation ("boot", never "switch" — the upstream +# change-username procedure is explicit) inside NixOS-WSL. Runs as root. +# +# Two image-specific traps, both proven on 20260805: +# - root's LOCAL store mode is broken: eval dies with +# `opening lock file /nix/var/nix/temproots/: No such file or +# directory` even after pre-creating the directory (3 runs). Every +# daemon-mediated operation worked, so the rebuild runs NIX_REMOTE=daemon. +# - the daemon itself must be ensured HERE: WSL kills background processes +# per wsl.exe session (WSL#13236), so no earlier stage's daemon survives. +# +# Usage: nixos-rebuild-boot.sh [probe-user] +# probe-user unprivileged user for the daemon probe (default nixos); root +# passes in local mode even when the daemon is dead. +set -u +PROBE_USER=${1:-nixos} + +SOCKET=/nix/var/nix/daemon-socket/socket +DAEMON=/run/current-system/sw/bin/nix-daemon + +if ! su - "$PROBE_USER" -c 'timeout 30 nix-store --add /etc/hostname' >/dev/null 2>&1; then + echo "nix-daemon not answering - starting manually (WSL#13236)" + rm -f "$SOCKET" + "$DAEMON" & + i=0 + while [ $i -lt 60 ]; do + test -S "$SOCKET" && break + sleep 1 + i=$((i+1)) + done + test -S "$SOCKET" || { echo "SOCKET_FAIL"; exit 1; } + echo "SOCKET_OK" + sleep 3 +fi +echo "STORE_OK" + +export PATH="/run/current-system/sw/bin:$PATH" +# The image ships without the temproots dir; harmless to pre-create. +mkdir -p /nix/var/nix/temproots +export NIX_REMOTE=daemon +nixos-rebuild boot > /tmp/devcell-rebuild.log 2>&1 +rc=$? +tail -20 /tmp/devcell-rebuild.log +echo "REBUILD_EXIT=$rc" +exit $rc diff --git a/internal/vm/qemu/guest/helpers/start-nix-daemon.sh b/internal/vm/qemu/guest/helpers/start-nix-daemon.sh new file mode 100755 index 0000000..76c42a3 --- /dev/null +++ b/internal/vm/qemu/guest/helpers/start-nix-daemon.sh @@ -0,0 +1,28 @@ +#!/bin/sh +# WSL#13236 workaround: start nix-daemon manually when systemd socket +# activation is broken because WSL hardcodes /usr/bin/systemctl. +# Must run as root. The daemon AND the store-write test happen in the +# SAME process tree — WSL kills background processes when the last +# session disconnects, so a daemon started in one wsl.exe call is +# dead by the next. +set -u +SOCKET=/nix/var/nix/daemon-socket/socket +DAEMON=/run/current-system/sw/bin/nix-daemon +TARGET_USER=${1:-nixos} + +rm -f "$SOCKET" +"$DAEMON" & +i=0 +while [ $i -lt 60 ]; do + test -S "$SOCKET" && break + sleep 1 + i=$((i+1)) +done +if ! test -S "$SOCKET"; then + echo "SOCKET_FAIL" + exit 1 +fi +echo "SOCKET_OK" +sleep 5 +su - "$TARGET_USER" -c 'timeout 30 nix-store --add /etc/hostname 2>&1' +echo "STORE_EXIT=$?" diff --git a/internal/vm/qemu/guest/stages/home-manager.ps1 b/internal/vm/qemu/guest/stages/home-manager.ps1 new file mode 100644 index 0000000..6b12db9 --- /dev/null +++ b/internal/vm/qemu/guest/stages/home-manager.ps1 @@ -0,0 +1,77 @@ +# Activate the repo's nixhome profile inside NixOS-WSL. +# +# The last link in the chain: the project share becomes visible inside the +# distro, gets linked to the agreed repo path, and home-manager activates +# from it. Everything before this stage exists to make this possible. +# +# $User is the WSL DISTRO user (WSLDistroUser), NOT the Windows session user. +# home-manager refuses to activate a config whose username differs from the +# invoking user — `Error: USER is set to "X" but we expect "Y"` — and the +# nixhome wsl-* configs are built for the distro's own default account. +param( + [string]$User = 'nixos', + [string]$Drive = 'Z:', + [string]$Mount = '/mnt/z', + [string]$Distro = 'NixOS', + [string]$LogName = '005-devenv-home-manager.log' +) +Import-Module (Join-Path $PSScriptRoot '..\Devcell.psm1') -Force +Initialize-DevcellLogging -LogName $LogName | Out-Null +$env:WSL_UTF8 = '1' + +$repo = "/home/$User/dev/dimmkirr/devcell" + +Invoke-DevcellStep "mount the project share at $Mount" { + # WSL2 usually automounts Windows drives under /mnt, but not always, and + # the share is this stage's only external dependency. `mountpoint -q` + # short-circuits when it is already mounted, so a non-zero exit here means + # the mount genuinely failed — which must be REPORTED. The previous + # version discarded it with `2>/dev/null; true`, so a missing share + # surfaced ~30 minutes later as an unexplained `ls` error instead. + wsl.exe -d $Distro -u root -- /bin/sh -c "mkdir -p $Mount; mountpoint -q $Mount || mount -t drvfs $Drive $Mount" + Assert-DevcellExitCode -What "mounting $Drive at $Mount" + "share mounted at $Mount" +} + +Invoke-DevcellStep "link the repo at $repo" { + # chown on a drvfs symlink is best-effort — the Windows filesystem has no + # POSIX owner to set — so only the link itself is asserted. + wsl.exe -d $Distro -u root -- /bin/sh -c "mkdir -p /home/$User/dev/dimmkirr && ln -sfn $Mount $repo" + Assert-DevcellExitCode -What "linking $Mount to $repo" + wsl.exe -d $Distro -u root -- /bin/sh -c "chown -h ${User}: $repo 2>/dev/null; true" + "linked $Mount -> $repo" +} + +Invoke-DevcellStep 'prove the repo is readable through the share' { + (& wsl.exe -d $Distro -- /bin/sh -c "ls $repo/nixhome" 2>&1 | Out-String).Trim() + Assert-DevcellExitCode -What 'reading nixhome through the share' +} + +Invoke-DevcellStep 'activate nixhome via home-manager' { + # The whole activation — nix-daemon ensure, nixhome extraction to ext4, + # switch — runs as ONE wsl.exe call through the shipped helper. Split any + # of it out and it breaks: WSL kills background processes per session, so + # a daemon from an earlier call is dead (WSL#13236); and nix cannot read + # the share directly — it ingests the repo as a dirty git tree and dies + # on readlink for the share's symlinks (run 20260804). The nixhome + # tarball ships on the control volume beside this script. + $volLetter = (Split-Path (Split-Path $PSScriptRoot) -Qualifier) -replace ':$','' + $volMount = "/mnt/$($volLetter.ToLower())" + wsl.exe -d $Distro -u root -- /bin/sh -c "mkdir -p $volMount; mountpoint -q $volMount || mount -t drvfs ${volLetter}: $volMount" + Assert-DevcellExitCode -What "mounting the control volume at $volMount" + $helper = "$volMount/devcell/helpers/activate-home-manager.sh" + $tarball = "$volMount/devcell/nixhome.tgz" + (& wsl.exe -d $Distro -u root -- /bin/sh -c "sh '$helper' '$User' '$tarball'" 2>&1 | Out-String).Trim() + Assert-DevcellExitCode -What 'home-manager switch (via activation helper)' +} + +Invoke-DevcellStep 'prove home-manager is on the activated profile' { + # Installed means the CLI answers with a real version from the activated + # profile (programs.home-manager.enable in nixhome), not merely exit 0. + $v = (& wsl.exe -d $Distro -- /bin/sh -lc 'home-manager --version' 2>&1 | Out-String).Trim() + Assert-DevcellExitCode -What 'home-manager --version' + if ($v -notmatch '[0-9]+\.[0-9]+') { + throw "home-manager --version did not print a semantic version: $v" + } + "home-manager $v" +} diff --git a/internal/vm/qemu/guest/stages/nix-verify.ps1 b/internal/vm/qemu/guest/stages/nix-verify.ps1 new file mode 100644 index 0000000..99134ea --- /dev/null +++ b/internal/vm/qemu/guest/stages/nix-verify.ps1 @@ -0,0 +1,109 @@ +# Prove the toolchain the NixOS-WSL image already carries. +# +# NixOS *is* nix — running the upstream installer inside it would be both +# redundant and non-idiomatic. Every nix call goes through a LOGIN shell: +# NixOS-WSL sets nix's PATH in /etc/profile only, so a bare `wsl -- nix` is +# exit 127 on a perfectly working distro (run 20260802). +param( + [string]$Distro = 'NixOS', + [string]$LogName = '004-devenv-WSL.log' +) +Import-Module (Join-Path $PSScriptRoot '..\Devcell.psm1') -Force +Initialize-DevcellLogging -LogName $LogName | Out-Null +$env:WSL_UTF8 = '1' + +Invoke-DevcellStep 'record the guest environment' { + # USER/HOME/PATH decide where home-manager activates and whether its CLI + # is reachable; the Windows-interop entries are what let the cell call + # Windows tools. Recording beats interrogating a busy guest later. + (& wsl.exe -d $Distro -- /bin/sh -lc 'echo "guest USER=$USER"; echo "guest HOME=$HOME"; echo "guest SHELL=$SHELL"; echo "guest PATH=$PATH"' 2>&1 | Out-String).Trim() +} + +Invoke-DevcellStep 'nix answers inside the distro' { + $v = (& wsl.exe -d $Distro -- /bin/sh -lc 'nix --version' 2>&1 | Out-String).Trim() + Assert-DevcellExitCode -What 'nix --version' + $v +} + +Invoke-DevcellStep 'ensure systemd (and therefore nix-daemon) runs in the distro' { + # NixOS is a MULTI-user store: every write goes through nix-daemon, which + # systemd starts. WSL does not run systemd unless /etc/wsl.conf asks for + # it, so without this the cell user's first build dies on + # error: opening lock file "/nix/var/nix/db/big-lock": Permission denied + # (run 20260803T231223, both attempts). + $has = (& wsl.exe -d $Distro -- /bin/sh -lc 'grep -qs "systemd[[:space:]]*=[[:space:]]*true" /etc/wsl.conf && echo yes || echo no' 2>&1 | Out-String).Trim() + if ($has -match 'yes') { + 'systemd already enabled in /etc/wsl.conf' + } else { + wsl.exe -d $Distro -u root -- /bin/sh -lc 'printf "\n[boot]\nsystemd=true\n" >> /etc/wsl.conf' + Assert-DevcellExitCode -What 'appending [boot] systemd=true to /etc/wsl.conf' + # The setting is read when the distro starts, so it must be cycled. + wsl.exe --terminate $Distro + wsl.exe -d $Distro -- /bin/sh -lc 'true' + Assert-DevcellExitCode -What 'restarting the distro with systemd' + 'systemd enabled; distro cycled' + } +} + +Invoke-DevcellStep 'wait for nix-daemon and prove the store is writable' { + # WSL hardcodes /usr/bin/systemctl (microsoft/WSL#13236) — NixOS puts it + # at /run/current-system/sw/bin/systemctl so systemd user sessions fail, + # D-Bus never comes up, and socket-activated services like nix-daemon + # never start (run 20260804T095500). Polling nix-store --add alone would + # hang forever. After a few failed rounds we detect the stale socket and + # start nix-daemon manually. + # + # The daemon must be started AND tested in the SAME wsl.exe call: WSL + # kills background processes when the last session disconnects, so a + # daemon started in one wsl.exe call is dead by the next (run 20260804). + $deadline = (Get-Date).AddMinutes(10) + $storePath = '' + $attempt = 0 + $daemonStarted = $false + do { + $attempt++ + $out = (& wsl.exe -d $Distro -- /bin/sh -lc 'timeout 30 nix-store --add /etc/hostname 2>&1' 2>&1 | Out-String).Trim() + $code = $LASTEXITCODE + Write-DevcellLog "attempt $attempt (exit=$code): $out" + if ($code -eq 0 -and $out -match '/nix/store/') { + $storePath = ($out -split "`n" | Where-Object { $_ -match '^/nix/store/' } | Select-Object -First 1) + break + } + # After 3 failed attempts, start nix-daemon manually and test in one + # WSL session (daemon + su to the cell user for the store write). + if ($attempt -ge 3 -and -not $daemonStarted) { + Write-DevcellLog "nix-daemon not running after $attempt attempts — starting manually (WSL#13236 workaround)" + $nixUser = (& wsl.exe -d $Distro -- /bin/sh -lc 'whoami' 2>&1 | Out-String).Trim() -replace '(?s).*\n','' + if (-not $nixUser) { $nixUser = 'nixos' } + # The helper ships on the control volume alongside this script. + # WSL sees Windows drives at /mnt//, so resolve the + # volume letter and hand the path straight to sh. + $helper = Join-Path $PSScriptRoot '..\helpers\start-nix-daemon.sh' + $volLetter = (Split-Path (Split-Path $PSScriptRoot) -Qualifier) -replace ':$','' + $wslHelper = "/mnt/$($volLetter.ToLower())/devcell/helpers/start-nix-daemon.sh" + Write-DevcellLog "helper: $helper (WSL path: $wslHelper)" + $combo = (& wsl.exe -d $Distro -u root -- /bin/sh -c "sh '$wslHelper' '$nixUser'" 2>&1 | Out-String).Trim() + Write-DevcellLog "manual daemon + store test: $combo" + $daemonStarted = $true + if ($combo -match 'STORE_EXIT=0' -and $combo -match '/nix/store/') { + $storePath = ($combo -split "`n" | Where-Object { $_ -match '^/nix/store/' } | Select-Object -First 1) + break + } + } + Start-Sleep -Seconds 20 + } while ((Get-Date) -lt $deadline) + if (-not $storePath) { + throw "nix-store --add never succeeded after $attempt attempts - nix-daemon may not be running or the socket is not accessible to the cell user" + } + "store writable after $attempt attempts: $storePath" +} + +Invoke-DevcellStep 'enable flakes for the cell user' { + wsl.exe -d $Distro -- /bin/sh -lc 'mkdir -p ~/.config/nix && printf "experimental-features = nix-command flakes\n" > ~/.config/nix/nix.conf' + Assert-DevcellExitCode -What 'writing ~/.config/nix/nix.conf' + 'flakes enabled' +} + +Invoke-DevcellStep 'report identity and versions' { + (& wsl.exe -d $Distro -- /bin/sh -lc 'whoami; nix --version; nixos-version' 2>&1 | Out-String).Trim() +} diff --git a/internal/vm/qemu/guest/stages/nixos-import.ps1 b/internal/vm/qemu/guest/stages/nixos-import.ps1 new file mode 100644 index 0000000..4b9c4d7 --- /dev/null +++ b/internal/vm/qemu/guest/stages/nixos-import.ps1 @@ -0,0 +1,79 @@ +# Import NixOS-WSL as a WSL2 distro, or prove the existing one boots. +# +# Ordering matters: check the registry FIRST. Run 20260803T075624 spent 84s +# on the GitHub releases API and a 577MB asset check before discovering the +# distro was already imported — pointless work and a needless network +# dependency on every resumed run. +param( + [string]$Distro = 'NixOS', + [string]$LogName = '004-devenv-WSL.log' +) +Import-Module (Join-Path $PSScriptRoot '..\Devcell.psm1') -Force +Initialize-DevcellLogging -LogName $LogName | Out-Null +$env:WSL_UTF8 = '1' +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + +$registered = $false +Invoke-DevcellStep "check whether $Distro is already registered" { + $list = (& wsl.exe --list --quiet 2>&1 | Out-String) + $script:registered = ($list -match [regex]::Escape($Distro)) + if ($script:registered) { "$Distro already registered - no import needed" } + else { "$Distro not registered - will import" } +} + +if (-not $registered) { + Invoke-DevcellStep 'set WSL default version to 2' { + # NixOS-WSL does not support WSL1. + wsl.exe --set-default-version 2 + Assert-DevcellExitCode -What 'wsl --set-default-version 2' + 'default version is 2' + } + + $img = '' + Invoke-DevcellStep 'fetch the NixOS-WSL release image' { + $rel = Invoke-RestMethod -Uri 'https://api.github.com/repos/nix-community/NixOS-WSL/releases/latest' -UseBasicParsing + # One image per architecture: the x86_64 nixos.wsl imports cleanly on + # ARM64 and then every exec dies with ENOEXEC (errno 8). + $assetName = if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64') { 'nixos.aarch64.wsl' } else { 'nixos.wsl' } + $asset = $rel.assets | Where-Object { $_.name -eq $assetName } | Select-Object -First 1 + if (-not $asset) { throw "no $assetName asset in the latest NixOS-WSL release" } + $script:img = Join-Path $env:TEMP $asset.name + if (-not (Test-Path $script:img)) { + Invoke-WebRequest -Uri $asset.browser_download_url -OutFile $script:img -UseBasicParsing + "downloaded $($asset.name) from $($rel.tag_name)" + } else { + "using cached $($asset.name) ($((Get-Item $script:img).Length) bytes)" + } + } + + Invoke-DevcellStep "import $Distro as WSL2" { + wsl.exe --install --from-file $script:img --no-launch + if ($LASTEXITCODE -ne 0) { + 'wsl --install --from-file failed - falling back to wsl --import' + New-Item -ItemType Directory -Path "C:\wsl\$Distro" -Force | Out-Null + wsl.exe --import $Distro "C:\wsl\$Distro" $script:img --version 2 + Assert-DevcellExitCode -What "wsl --import $Distro" + } + "imported $Distro" + } +} + +if ($registered) { + # Already imported (a resumed run from a checkpoint image). Booting the + # utility VM just to print nixos-version costs ~7 minutes under TCG and + # proves nothing the next stages do not: "verify nix in NixOS-WSL" runs + # inside this distro and fails loudly if it is broken. Registration is + # all this stage can add here. + Invoke-DevcellStep "$Distro already present - list only" { + (& wsl.exe --list --verbose 2>&1 | Out-String).Trim() + } + Write-DevcellLog 'DEVCELL-NO-CHANGE' + return +} + +Invoke-DevcellStep "prove the freshly imported $Distro boots" { + (& wsl.exe --list --verbose 2>&1 | Out-String).Trim() + $version = (& wsl.exe -d $Distro -- nixos-version 2>&1 | Out-String).Trim() + Assert-DevcellExitCode -What "nixos-version inside $Distro" + "nixos-version: $version" +} diff --git a/internal/vm/qemu/guest/stages/wsl-adopt-user.ps1 b/internal/vm/qemu/guest/stages/wsl-adopt-user.ps1 new file mode 100644 index 0000000..88e6686 --- /dev/null +++ b/internal/vm/qemu/guest/stages/wsl-adopt-user.ps1 @@ -0,0 +1,119 @@ +# Make the distro run as the CELL's user, after home-manager has activated. +# +# Every devcell engine presents the host's user inside the cell. Docker does it +# in the entrypoint: the nix profile is built for a fixed user (`devcell`) and +# the session user is created at runtime with the dotfiles rewritten onto its +# home. WSL had no equivalent step, so `whoami` inside the distro answered +# "nixos" (run 20260803T231223). +# +# Why this runs AFTER activation, never before: +# home-manager's activation script ends in `checkUsername `, where +# is baked in from home.username at build time. nixhome pins +# wslUser.username = "nixos" (nixhome/flake.nix), so activating as anyone +# else fails with +# Error: USER is set to "dmitry" but we expect "nixos" +# Activation is a ONE-TIME build step, though: afterwards the result is +# store paths plus symlinks under /home/nixos, and the guard never runs +# again — so the host user can be introduced safely once it is done. +# +# Procedure: https://nix-community.github.io/NixOS-WSL/how-to/change-username.html +# nixos-rebuild BOOT (not switch — the docs are explicit that switch +# misconfigures the account), then cycle the distro. +param( + [Parameter(Mandatory = $true)][string]$User, + [string]$From = 'nixos', + [string]$Distro = 'NixOS', + [string]$LogName = '005-devenv-home-manager.log' +) +Import-Module (Join-Path $PSScriptRoot '..\Devcell.psm1') -Force +Initialize-DevcellLogging -LogName $LogName | Out-Null +$env:WSL_UTF8 = '1' + +$current = '' +Invoke-DevcellStep 'read the distro current user' { + $script:current = (& wsl.exe -d $Distro -- /bin/sh -lc 'echo $USER' 2>&1 | Out-String).Trim() + "distro user before: $script:current" +} + +if ($current -eq $User) { + Write-DevcellLog "distro already runs as $User" + Write-DevcellLog 'DEVCELL-NO-CHANGE' + return +} + +Invoke-DevcellStep "declare $User in /etc/nixos" { + $cfg = @" +{ config, lib, pkgs, ... }: +{ + # mkForce: NixOS-WSL's stock configuration.nix pins wsl.defaultUser = + # "nixos" at normal priority; without force the rebuild dies on + # "conflicting definition values" (run 20260804). + wsl.defaultUser = lib.mkForce "$User"; + users.users."$User" = { + isNormalUser = true; + home = "/home/$User"; + extraGroups = [ "wheel" ]; + }; + security.sudo.wheelNeedsPassword = false; +} +"@ + $b64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($cfg)) + wsl.exe -d $Distro -u root -- /bin/sh -lc "echo $b64 | base64 -d > /etc/nixos/devcell-user.nix" + Assert-DevcellExitCode -What 'writing /etc/nixos/devcell-user.nix' + wsl.exe -d $Distro -u root -- /bin/sh -lc 'grep -q devcell-user.nix /etc/nixos/configuration.nix || sed -i "s|imports = \[|imports = [ ./devcell-user.nix|" /etc/nixos/configuration.nix; grep -n imports /etc/nixos/configuration.nix' + Assert-DevcellExitCode -What 'wiring devcell-user.nix into configuration.nix' + "declared $User" +} + +Invoke-DevcellStep 'nixos-rebuild boot' { + # boot, NOT switch — via the shipped helper, which ensures the nix-daemon + # in the SAME wsl.exe session (WSL#13236: no earlier stage's daemon + # survives) and runs the rebuild with NIX_REMOTE=daemon: root's local + # store mode dies on temproots ENOENT in this image while every + # daemon-mediated build works (3 failed + 1 green run, 20260805). + $volLetter = (Split-Path (Split-Path $PSScriptRoot) -Qualifier) -replace ':$','' + $volMount = "/mnt/$($volLetter.ToLower())" + wsl.exe -d $Distro -u root -- /bin/sh -c "mkdir -p $volMount; mountpoint -q $volMount || mount -t drvfs ${volLetter}: $volMount" + Assert-DevcellExitCode -What "mounting the control volume at $volMount" + $helper = "$volMount/devcell/helpers/nixos-rebuild-boot.sh" + (& wsl.exe -d $Distro -u root -- /bin/sh -c "sh '$helper' '$From'" 2>&1 | Out-String).Trim() + Assert-DevcellExitCode -What 'nixos-rebuild boot (via rebuild helper)' + 'new generation built' +} + +Invoke-DevcellStep "carry the activated profile into /home/$User" { + # The home-manager result is symlinks into /nix/store, which are absolute + # — so copying them preserves a working environment. `cp -a` keeps them as + # links rather than dereferencing gigabytes of store closure. + # No chown yet: the user does not exist until the new generation boots, + # so a chown here dies with "invalid spec" (run 20260805). + wsl.exe -d $Distro -u root -- /bin/sh -lc "mkdir -p /home/$User && cp -a /home/$From/. /home/$User/" + Assert-DevcellExitCode -What "copying /home/$From to /home/$User" + "profile carried from $From" +} + +Invoke-DevcellStep 'cycle the distro so the new user takes effect' { + wsl.exe --terminate $Distro + wsl.exe -d $Distro --user root -- /bin/sh -lc 'true' + wsl.exe --terminate $Distro + 'cycled' +} + +Invoke-DevcellStep "own /home/$User now the user exists" { + # Only the booted generation carries the account — chown works here and + # nowhere earlier. + wsl.exe -d $Distro -u root -- /bin/sh -lc "chown -R ${User}: /home/$User" + Assert-DevcellExitCode -What "chown /home/$User" + "home owned by $User" +} + +Invoke-DevcellStep "verify the distro runs as $User with a working nix env" { + # Identity AND environment: a rename that leaves the cell without nix is + # not a success. home-manager's own CLI is the profile's canary. + $after = (& wsl.exe -d $Distro -- /bin/sh -lc 'whoami; echo "HOME=$HOME"; nix --version; home-manager --version' 2>&1 | Out-String).Trim() + Assert-DevcellExitCode -What "nix env for $User" + if ($after -notmatch "^$([regex]::Escape($User))") { + throw "distro user is still not ${User}: $after" + } + "distro identity after: $after" +} diff --git a/internal/vm/qemu/guest/stages/wsl-engine-install.ps1 b/internal/vm/qemu/guest/stages/wsl-engine-install.ps1 new file mode 100644 index 0000000..f8eb18f --- /dev/null +++ b/internal/vm/qemu/guest/stages/wsl-engine-install.ps1 @@ -0,0 +1,61 @@ +# Install the WSL engine MSI and tune WSL for an emulated host. +# +# The inbox wsl.exe on current Win11 is a stub; the engine ships as a +# separate MSI from the microsoft/WSL releases. Installing it tears down the +# SSH session, so the stage runs disconnect-tolerant and reboot-terminated. +param( + [string]$LogName = '004-devenv-WSL.log' +) +Import-Module (Join-Path $PSScriptRoot '..\Devcell.psm1') -Force +Initialize-DevcellLogging -LogName $LogName | Out-Null +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$env:WSL_UTF8 = '1' + +Invoke-DevcellStep 'write .wslconfig for an emulated host' { + # WSL's defaults assume real hardware. Under TCG the utility-VM kernel + # needs far more than the default 30s KernelBootTimeout (WslCoreConfig.h) + # — 15 min was still short on a loaded host — and WSLg's vGPU has no + # partitionable GPU here; its hot-add was the last HCS operation before + # wslservice died with E_UNEXPECTED. + $cfg = @( + '[wsl2]', 'processors=4', 'memory=4GB', + 'kernelBootTimeout=3600000', 'distributionStartTimeout=3600000', + 'gpuSupport=false', 'guiApplications=false' + ) -join "`n" + Set-Content -Path (Join-Path $env:USERPROFILE '.wslconfig') -Value $cfg -Encoding ascii + 'wrote ' + (Join-Path $env:USERPROFILE '.wslconfig') +} + +$status = '' +Invoke-DevcellStep 'probe the WSL engine' { + $script:status = (& wsl.exe --status 2>&1 | Out-String) + 'wsl --status exit ' + $LASTEXITCODE + $script:status.Trim() +} + +if ($script:status -notmatch 'not installed') { + Write-DevcellLog 'wsl engine already present' + # Tell the runner nothing changed: the reboot this stage declares exists + # for the MSI install path, and a TCG reboot costs ~8 minutes. + Write-DevcellLog 'DEVCELL-NO-CHANGE' + return +} + +Invoke-DevcellStep 'register the engine (wsl --install --no-distribution)' { + wsl.exe --install --no-distribution + 'exit ' + $LASTEXITCODE +} + +Invoke-DevcellStep 'install the engine MSI if registration did not take' { + $probe = (& wsl.exe --status 2>&1 | Out-String) + if ($probe -notmatch 'not installed') { return 'engine registered' } + $rel = Invoke-RestMethod -Uri 'https://api.github.com/repos/microsoft/WSL/releases/latest' -UseBasicParsing + $asset = $rel.assets | Where-Object { $_.name -like '*arm64.msi' } | Select-Object -First 1 + if (-not $asset) { throw 'no arm64.msi asset in the latest microsoft/WSL release' } + $msi = Join-Path $env:TEMP $asset.name + if (-not (Test-Path $msi)) { Invoke-WebRequest -Uri $asset.browser_download_url -OutFile $msi -UseBasicParsing } + "installing $($asset.name) - the SSH session may drop here" + $p = Start-Process msiexec -ArgumentList '/i', $msi, '/qn', '/norestart' -Wait -PassThru + Assert-DevcellExitCode -What ('msiexec ' + $asset.name) -Code $p.ExitCode + 'installed ' + $asset.name +} diff --git a/internal/vm/qemu/guest/stages/wsl-user.ps1 b/internal/vm/qemu/guest/stages/wsl-user.ps1 new file mode 100644 index 0000000..c3768bb --- /dev/null +++ b/internal/vm/qemu/guest/stages/wsl-user.ps1 @@ -0,0 +1,74 @@ +# Make the distro run as the cell's user. +# +# NixOS-WSL ships with "nixos", but every devcell engine runs the cell as the +# HOST's user, and the project share is linked at /home//dev/... Leaving +# the default makes the distro's user and the cell's paths disagree, and +# home-manager refuses to activate a config whose username does not match the +# invoking user. +# +# Procedure: https://nix-community.github.io/NixOS-WSL/how-to/change-username.html +# nixos-rebuild BOOT (not switch — the docs are explicit that switch +# misconfigures the account), then cycle the distro so the new generation's +# user takes effect. +param( + [Parameter(Mandatory = $true)][string]$User, + [string]$Distro = 'NixOS', + [string]$LogName = '004-devenv-WSL.log' +) +Import-Module (Join-Path $PSScriptRoot '..\Devcell.psm1') -Force +Initialize-DevcellLogging -LogName $LogName | Out-Null +$env:WSL_UTF8 = '1' + +$current = '' +Invoke-DevcellStep 'read the distro current user' { + $script:current = (& wsl.exe -d $Distro -- /bin/sh -lc 'echo $USER' 2>&1 | Out-String).Trim() + "distro user before: $script:current" +} + +if ($current -eq $User) { + Write-DevcellLog "distro already runs as $User" + Write-DevcellLog 'DEVCELL-NO-CHANGE' + return +} + +Invoke-DevcellStep "declare $User in /etc/nixos" { + $cfg = @" +{ config, lib, pkgs, ... }: +{ + wsl.defaultUser = "$User"; + users.users."$User" = { + isNormalUser = true; + home = "/home/$User"; + extraGroups = [ "wheel" ]; + }; + security.sudo.wheelNeedsPassword = false; +} +"@ + $b64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($cfg)) + wsl.exe -d $Distro -u root -- /bin/sh -lc "echo $b64 | base64 -d > /etc/nixos/devcell-user.nix" + Assert-DevcellExitCode -What 'writing /etc/nixos/devcell-user.nix' + wsl.exe -d $Distro -u root -- /bin/sh -lc 'grep -q devcell-user.nix /etc/nixos/configuration.nix || sed -i "s|imports = \[|imports = [ ./devcell-user.nix|" /etc/nixos/configuration.nix; grep -n imports /etc/nixos/configuration.nix' + Assert-DevcellExitCode -What 'wiring devcell-user.nix into configuration.nix' + "declared $User" +} + +Invoke-DevcellStep 'nixos-rebuild boot' { + # boot, NOT switch. This builds a generation inside the WSL2 VM under + # double emulation — the slowest step of this stage. + wsl.exe -d $Distro -u root -- /bin/sh -lc 'nixos-rebuild boot 2>&1 | tail -20' + Assert-DevcellExitCode -What 'nixos-rebuild boot' + 'new generation built' +} + +Invoke-DevcellStep 'cycle the distro so the new user takes effect' { + wsl.exe --terminate $Distro + wsl.exe -d $Distro --user root -- /bin/true + wsl.exe --terminate $Distro + 'cycled' +} + +Invoke-DevcellStep "verify the distro now runs as $User" { + $after = (& wsl.exe -d $Distro -- /bin/sh -lc 'echo $USER; echo $HOME' 2>&1 | Out-String).Trim() + if ($after -notmatch [regex]::Escape($User)) { throw "distro user is still not ${User}: $after" } + "distro user after: $after" +} diff --git a/internal/vm/qemu/guest/stages/wsl2-enable.ps1 b/internal/vm/qemu/guest/stages/wsl2-enable.ps1 new file mode 100644 index 0000000..e18c7cb --- /dev/null +++ b/internal/vm/qemu/guest/stages/wsl2-enable.ps1 @@ -0,0 +1,27 @@ +# Enable the two Windows features WSL2 requires. +# +# NixOS-WSL does not support WSL1 (https://nix-community.github.io/NixOS-WSL/install.html), +# so VirtualMachinePlatform is required, not optional. The reboot belongs to +# the caller, which watches SSH drop and come back. +param( + [string]$LogName = '001-devenv-WSL.log' +) +Import-Module (Join-Path $PSScriptRoot '..\Devcell.psm1') -Force +Initialize-DevcellLogging -LogName $LogName | Out-Null + +$changed = $false +Invoke-DevcellStep 'enable WSL2 features' { + foreach ($f in @('Microsoft-Windows-Subsystem-Linux', 'VirtualMachinePlatform')) { + $before = (Get-WindowsOptionalFeature -Online -FeatureName $f).State + if ($before -eq 'Enabled') { "$f already enabled"; continue } + $r = Enable-WindowsOptionalFeature -Online -FeatureName $f -All -NoRestart + $script:changed = $true + "$f enabled, restart needed: $($r.RestartNeeded)" + } + foreach ($f in @('Microsoft-Windows-Subsystem-Linux', 'VirtualMachinePlatform')) { + "state ${f}: $((Get-WindowsOptionalFeature -Online -FeatureName $f).State)" + } +} + +# Both features already on: nothing to activate, so skip the ~8min TCG reboot. +if (-not $changed) { Write-DevcellLog 'DEVCELL-NO-CHANGE' } diff --git a/internal/vm/qemu/guestfs.go b/internal/vm/qemu/guestfs.go new file mode 100644 index 0000000..46335ad --- /dev/null +++ b/internal/vm/qemu/guestfs.go @@ -0,0 +1,144 @@ +package qemu + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "embed" + "fmt" + "io/fs" + "os" + "path" + "path/filepath" + "strings" +) + +// guestFS is the guest-side PowerShell tree: a shared module plus one script +// per stage. Unlike templates/, nothing here is rendered — it is real +// PowerShell, embedded verbatim and delivered on the per-run control volume. +// +// That distinction is the point of CELL-402: Go-interpolated PowerShell is +// never linted, never runnable standalone, and fails only on a live guest +// minutes-to-hours in (lost quotes killed a 40-minute pipeline; an +// interpolated colon broke icacls). Real files remove the bug class. +// +//go:embed guest +var guestFS embed.FS + +// GuestControlDir is where the guest tree lands on the control volume. +const GuestControlDir = "/devcell" + +// GuestFile returns one file from the embedded guest tree, addressed the way +// stages refer to it ("Devcell.psm1", "stages/wsl2-enable.ps1"). +func GuestFile(name string) ([]byte, error) { + data, err := guestFS.ReadFile(path.Join("guest", name)) + if err != nil { + return nil, fmt.Errorf("guest file %s: %w", name, err) + } + return data, nil +} + +// GuestPayload returns the whole guest tree keyed by its path on the control +// volume, ready for BuildControlVolume. Everything ships every run: the +// volume is built on the host and attached at boot, so it can never drift +// from the repo the way a copy written into the qcow2 would. +func GuestPayload() (map[string][]byte, error) { + payload := map[string][]byte{} + err := fs.WalkDir(guestFS, "guest", func(p string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + data, readErr := guestFS.ReadFile(p) + if readErr != nil { + return readErr + } + payload[GuestControlDir+"/"+strings.TrimPrefix(p, "guest/")] = data + return nil + }) + if err != nil { + return nil, fmt.Errorf("collecting guest payload: %w", err) + } + return payload, nil +} + +// NixhomeTarball packs a nixhome directory for control-volume delivery, all +// contents under a top-level "nixhome/" so extraction recreates the layout. +// +// A tarball, not a live reference: activating straight from the project +// share fails twice over — nix ingests the surrounding repo as a dirty +// git+file input, and the share's symlinks (36 in the icewm theme alone) die +// on readlink across virtiofs+drvfs (run 20260804). Inside a tarball the +// symlinks are just entries; extracted onto the distro's ext4 they work. +func NixhomeTarball(dir string) ([]byte, error) { + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + + err := filepath.WalkDir(dir, func(p string, d os.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(dir, p) + if err != nil { + return err + } + name := path.Join("nixhome", filepath.ToSlash(rel)) + info, err := d.Info() + if err != nil { + return err + } + var link string + if info.Mode()&os.ModeSymlink != 0 { + if link, err = os.Readlink(p); err != nil { + return err + } + } + hdr, err := tar.FileInfoHeader(info, link) + if err != nil { + return err + } + hdr.Name = name + if d.IsDir() { + hdr.Name += "/" + } + if err := tw.WriteHeader(hdr); err != nil { + return err + } + if info.Mode().IsRegular() { + data, err := os.ReadFile(p) + if err != nil { + return err + } + if _, err := tw.Write(data); err != nil { + return err + } + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("packing nixhome from %s: %w", dir, err) + } + if err := tw.Close(); err != nil { + return nil, err + } + if err := gz.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// GuestPayloadWithNixhome is GuestPayload plus the nixhome tarball the +// home-manager stage extracts inside the distro — one control volume carries +// both the scripts and the config they activate. +func GuestPayloadWithNixhome(nixhomeDir string) (map[string][]byte, error) { + payload, err := GuestPayload() + if err != nil { + return nil, err + } + tgz, err := NixhomeTarball(nixhomeDir) + if err != nil { + return nil, err + } + payload[GuestControlDir+"/nixhome.tgz"] = tgz + return payload, nil +} diff --git a/internal/vm/qemu/guestfs_test.go b/internal/vm/qemu/guestfs_test.go new file mode 100644 index 0000000..e17b104 --- /dev/null +++ b/internal/vm/qemu/guestfs_test.go @@ -0,0 +1,362 @@ +package qemu + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The guest tree is REAL PowerShell (no Go templating): it is linted, +// runnable standalone on a guest, and free of the interpolation bug class +// that cost four multi-hour runs (CELL-402). +func TestGuestModule_ExportsTheSharedHelpers(t *testing.T) { + mod, err := GuestFile("Devcell.psm1") + require.NoError(t, err, "the module must ship in the embedded guest tree") + s := string(mod) + + for _, fn := range []string{ + "function Write-DevcellLog", + "function Invoke-DevcellStep", + "function Get-DevcellControlVolume", + "function Assert-DevcellExitCode", + } { + assert.Contains(t, s, fn, "the module must define %s", fn) + } + assert.Contains(t, s, "Add-Content", + "per-line append is what makes a running stage readable (proven 20260803T073911)") + assert.NotContains(t, s, "{{", "guest code must contain no Go template syntax") +} + +// Every stage script is a real parameterised PowerShell file. +func TestGuestStage_IsRealPowerShellWithParams(t *testing.T) { + src, err := GuestFile("stages/wsl2-enable.ps1") + require.NoError(t, err) + s := string(src) + + assert.True(t, strings.HasPrefix(strings.TrimSpace(s), "#") || strings.Contains(s, "param("), + "a stage script starts with a comment header or a param block") + assert.Contains(t, s, "Import-Module", "stages consume the shared module") + assert.Contains(t, s, "Invoke-DevcellStep", "stage work is wrapped so it is timed and logged") + assert.NotContains(t, s, "{{", "no Go template syntax in guest code") +} + +// The control volume must carry the module and every stage script the stage +// table references — a missing file must fail the build, not a guest an hour +// into a run. +func TestGuestPayload_CarriesModuleAndEveryReferencedStage(t *testing.T) { + payload, err := GuestPayload() + require.NoError(t, err) + + assert.Contains(t, payload, "/devcell/Devcell.psm1") + for _, st := range devEnvStages("dmitry", "devcell", "Z:") { + if st.ScriptFile == "" { + continue // still on the legacy rendered path + } + assert.Contains(t, payload, "/devcell/stages/"+st.ScriptFile, + "stage %q references %s, which must ship on the control volume", st.Name, st.ScriptFile) + } +} + +// Resumed runs boot from a checkpoint that already carries the distro. +// Checking the registry FIRST is what makes those runs cheap: run +// 20260803T075624 spent 84s on the releases API and a 577MB asset check +// before discovering the distro was already imported. +func TestNixOSImportStage_ChecksRegistryBeforeNetwork(t *testing.T) { + src, err := GuestFile("stages/nixos-import.ps1") + require.NoError(t, err) + s := string(src) + + registryAt := strings.Index(s, "wsl.exe --list --quiet") + apiAt := strings.Index(s, "api.github.com") + require.Positive(t, registryAt, "the stage must consult the WSL registry") + require.Positive(t, apiAt, "the stage must know how to fetch the image") + assert.Less(t, registryAt, apiAt, + "the registry check must come BEFORE any network call") + + assert.Contains(t, s, "if (-not $registered)", + "fetch and import must be skipped entirely when the distro exists") + assert.Contains(t, s, "nixos.aarch64.wsl", "ARM64 guests need the aarch64 image") +} + +// The nix-daemon helper is a shell script that nix-verify invokes inside WSL +// when systemd socket activation is broken (WSL#13236). It must ship on the +// control volume so the stage can reference it by path instead of encoding it +// inline (which was the bug class that broke every quoting attempt). +func TestGuestPayload_CarriesNixDaemonHelper(t *testing.T) { + payload, err := GuestPayload() + require.NoError(t, err) + + const key = "/devcell/helpers/start-nix-daemon.sh" + assert.Contains(t, payload, key, + "the nix-daemon helper must ship on the control volume") + sh := string(payload[key]) + assert.Contains(t, sh, "nix-daemon", "the helper starts the daemon") + assert.Contains(t, sh, "SOCKET_OK", "the helper reports socket readiness") + assert.Contains(t, sh, "STORE_EXIT", "the helper reports the store-write result") + assert.Contains(t, sh, "TARGET_USER", "the helper accepts the WSL user as an argument") +} + +// The user declaration must override NixOS-WSL's stock configuration.nix, +// which pins wsl.defaultUser = "nixos" at normal priority. Without mkForce +// nixos-rebuild dies on "conflicting definition values" (run 20260804, +// first-ever E2E of this stage) and the distro never adopts the host user. +func TestAdoptUserStage_ForcesDefaultUserOverride(t *testing.T) { + src, err := GuestFile("stages/wsl-adopt-user.ps1") + require.NoError(t, err) + s := string(src) + + assert.Contains(t, s, "lib.mkForce", + "wsl.defaultUser must be forced past the stock config's own definition") + assert.Regexp(t, `wsl\.defaultUser\s*=\s*lib\.mkForce`, s, + "the force must apply to wsl.defaultUser specifically") +} + +// nix-verify references the helper by its control volume path, not inline. +func TestNixVerifyStage_UsesControlVolumeHelper(t *testing.T) { + src, err := GuestFile("stages/nix-verify.ps1") + require.NoError(t, err) + s := string(src) + + assert.Contains(t, s, "start-nix-daemon.sh", + "the daemon fallback must reference the shipped helper, not inline the script") + assert.Contains(t, s, "helpers", + "the helper lives in the helpers/ directory on the control volume") +} + +// Stages that declare a reboot must be able to withdraw it: on a resumed +// run the work is usually already done, and a TCG reboot costs ~8 minutes. +func TestRebootingStages_CanReportNoChange(t *testing.T) { + for _, f := range []string{"stages/wsl-engine-install.ps1", "stages/wsl2-enable.ps1"} { + src, err := GuestFile(f) + require.NoError(t, err) + assert.Contains(t, string(src), NoChangeMarker, + "%s declares RebootAfter, so it must signal a no-op run", f) + } +} + +// The home-manager stage is the last one in the default span still rendered +// from Go, and the only stage that has never executed in ANY recorded run — +// the riskiest code in the pipeline living in its most fragile form. As a real +// .ps1 it is covered by the host-side pwsh parser gate before its first run. +func TestHomeManagerStage_IsFileBacked(t *testing.T) { + var stage GuestStage + for _, st := range DevEnvStages("dmitry", "devcell", "Z:") { + if st.Name == "activate nixhome home-manager" { + stage = st + } + } + require.NotEmpty(t, stage.Name, "stage not found") + + assert.Equal(t, "home-manager.ps1", stage.ScriptFile, + "the stage must run a real PowerShell file, not Go-rendered text") + assert.Empty(t, stage.Script, "the legacy rendered payload must be gone from the table") + assert.Equal(t, WSLDistroUser, stage.Args["User"]) + assert.Equal(t, NixOSWSLDistro, stage.Args["Distro"]) + assert.Equal(t, "Z:", stage.Args["Drive"]) +} + +// The details below each cost a multi-hour run to isolate. They are asserted +// on the FILE so a future edit cannot quietly drop one. +func TestHomeManagerStage_KeepsTheHardWonInvocationDetails(t *testing.T) { + src, err := GuestFile("stages/home-manager.ps1") + require.NoError(t, err) + s := string(src) + + // The nix invocation now lives in the activation helper (one wsl.exe + // session with the daemon), so the flag invariants are asserted THERE. + helper, err := GuestFile("helpers/activate-home-manager.sh") + require.NoError(t, err) + h := string(helper) + + // Run 20260802T112212: inner double quotes do not survive + // PowerShell -> wsl.exe -> sh -lc, and nix then reports "no subcommand + // specified". The features must travel as REPEATED flags. + assert.Equal(t, 2, strings.Count(h, "--extra-experimental-features"), + "nix-command and flakes must be two separate flags, never one quoted pair") + assert.Contains(t, h, "release-26.05", + "the home-manager runner is pinned to the branch matching this NixOS") + // Run 20260802: nix is on PATH only via /etc/profile in NixOS-WSL, so a + // bare `wsl -- nix` exits 127 on a perfectly working distro. + assert.Contains(t, s, "/bin/sh -lc", "every nix call needs a login shell") + // The repo path must be derived from the $User PARAMETER, never baked in: + // the whole point of the file-backed stage is that the distro user is + // passed, not interpolated by Go. + assert.Contains(t, s, `"/home/$User/dev/dimmkirr/devcell"`, + "the repo path is built from the parameter the caller passes") +} + +// The share is the stage's only external dependency and its failure was +// SWALLOWED: `mount ... 2>/dev/null; true` meant a missing Z: surfaced ~30 +// minutes later as an unexplained `ls .../nixhome` error instead of at the +// mount itself. +func TestHomeManagerStage_DoesNotSwallowTheMountFailure(t *testing.T) { + src, err := GuestFile("stages/home-manager.ps1") + require.NoError(t, err) + s := string(src) + + mountAt := strings.Index(s, "drvfs") + require.Positive(t, mountAt, "the stage must still mount the share") + assert.NotContains(t, s[mountAt:mountAt+200], "2>/dev/null", + "a failed mount must be reported, not discarded") + assert.Contains(t, s, "Assert-DevcellExitCode", + "native commands do not throw — only $LASTEXITCODE knows they failed") +} + +// On a resumed run the distro is already imported, and booting the utility +// VM just to print nixos-version costs ~7 minutes under TCG while proving +// nothing the later "verify nix" stage does not. The import stage must do +// the minimum that only it can do: register the distro. +func TestNixOSImportStage_SkipsRedundantBootWhenAlreadyRegistered(t *testing.T) { + src, err := GuestFile("stages/nixos-import.ps1") + require.NoError(t, err) + s := string(src) + + assert.Contains(t, s, "if ($registered) {", + "an already-registered distro must short-circuit") + skipAt := strings.Index(s, "if ($registered) {") + proveAt := strings.Index(s, "prove the freshly imported") + require.Positive(t, proveAt) + assert.Less(t, skipAt, proveAt, + "the short-circuit must come before the verification boot") + assert.Contains(t, s, NoChangeMarker, "a no-op import must not cost a reboot either") +} + +// --- Stage 13/14 fixes proven interactively on 20260804-05 (first-ever E2E) --- + +// The home-manager activation and the nix-daemon MUST share one wsl.exe +// process tree: WSL kills background processes when the session ends, so the +// daemon nix-verify started is dead by the time this stage runs. The helper +// carries the whole sequence — daemon ensure, nixhome extraction to ext4, +// switch as the distro user. +func TestGuestPayload_CarriesHomeManagerActivationHelper(t *testing.T) { + payload, err := GuestPayload() + require.NoError(t, err) + + const key = "/devcell/helpers/activate-home-manager.sh" + require.Contains(t, payload, key, + "the activation helper must ship on the control volume") + sh := string(payload[key]) + assert.Contains(t, sh, "nix-daemon", "the helper ensures the daemon in-session") + assert.Contains(t, sh, "tar -xzf", + "nixhome must be extracted to ext4 — symlinks in the virtiofs+drvfs share do not readlink (run 20260804)") + assert.Contains(t, sh, "home-manager/release-26.05", "the pinned runner branch") + assert.Contains(t, sh, "switch -b backup", "the standalone-flake activation form") + assert.Contains(t, sh, "wsl-base", "the WSL flake attribute family") +} + +// The stage must hand the work to the helper in ONE wsl.exe call, and feed it +// the nixhome tarball from the control volume — never the share path, which +// nix rejects (dirty git tree ingestion + readlink failures, run 20260804). +func TestHomeManagerStage_ActivatesViaControlVolumeHelper(t *testing.T) { + src, err := GuestFile("stages/home-manager.ps1") + require.NoError(t, err) + s := string(src) + + assert.Contains(t, s, "activate-home-manager.sh", + "activation must go through the shipped helper (daemon + switch, one session)") + assert.Contains(t, s, "nixhome.tgz", + "nixhome travels as a tarball; activating from the share path fails on symlinks") + assert.NotContains(t, s, "--flake ./nixhome", + "the share path must never be the flake source") +} + +// NixhomeTarball is what puts nixhome.tgz on the control volume. Symlinks +// are the reason the tarball exists at all — flattening them would silently +// reintroduce the bug class the tarball solves. +func TestNixhomeTarball_PreservesSymlinks(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "icons"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "flake.nix"), []byte("{}"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "icons", "a.xpm"), []byte("x"), 0o644)) + require.NoError(t, os.Symlink("a.xpm", filepath.Join(dir, "icons", "b.xpm"))) + + data, err := NixhomeTarball(dir) + require.NoError(t, err) + + gz, err := gzip.NewReader(bytes.NewReader(data)) + require.NoError(t, err) + tr := tar.NewReader(gz) + entries := map[string]byte{} + links := map[string]string{} + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + require.NoError(t, err) + entries[hdr.Name] = hdr.Typeflag + if hdr.Typeflag == tar.TypeSymlink { + links[hdr.Name] = hdr.Linkname + } + } + assert.Contains(t, entries, "nixhome/flake.nix", + "contents must sit under nixhome/ so extraction recreates the expected layout") + require.Contains(t, links, "nixhome/icons/b.xpm", "the symlink must survive as a symlink") + assert.Equal(t, "a.xpm", links["nixhome/icons/b.xpm"], "with its original target") +} + +// nixos-rebuild as root uses the LOCAL store by default, and this image's +// local-mode temproot handling is broken (ENOENT on +// /nix/var/nix/temproots/ across three runs, 20260805). Every +// daemon-mediated operation worked, so the rebuild must run with +// NIX_REMOTE=daemon — and the daemon ensured in the same session. +func TestGuestPayload_CarriesRebuildBootHelper(t *testing.T) { + payload, err := GuestPayload() + require.NoError(t, err) + + const key = "/devcell/helpers/nixos-rebuild-boot.sh" + require.Contains(t, payload, key, + "the rebuild helper must ship on the control volume") + sh := string(payload[key]) + assert.Contains(t, sh, "NIX_REMOTE=daemon", + "root's local-mode store is broken in this image; the daemon path is the proven one") + assert.Contains(t, sh, "temproots", + "the image ships without /nix/var/nix/temproots") + assert.Contains(t, sh, "nix-daemon", "the daemon must be ensured in this same session") + assert.Contains(t, sh, "nixos-rebuild boot", + "boot, never switch — the upstream change-username procedure is explicit") +} + +// The adopt stage's rebuild must go through the helper, and the home +// ownership fix must wait for the cycle: the new user does not exist until +// the new generation boots, so a pre-cycle chown dies with "invalid spec" +// (run 20260805) and the assert would sink the stage. +func TestAdoptUserStage_RebuildViaHelperAndChownAfterCycle(t *testing.T) { + src, err := GuestFile("stages/wsl-adopt-user.ps1") + require.NoError(t, err) + s := string(src) + + assert.Contains(t, s, "nixos-rebuild-boot.sh", + "the rebuild needs the daemon + NIX_REMOTE=daemon wrapper") + + cycleAt := strings.Index(s, "wsl.exe --terminate") + chownAt := strings.Index(s, "chown -R") + require.Positive(t, cycleAt, "the stage must cycle the distro") + require.Positive(t, chownAt, "the stage must own the carried home to the new user") + assert.Less(t, cycleAt, chownAt, + "chown must follow the cycle — the user only exists once the new generation boots") +} + +// The tarball must ride the same control volume as the stage that consumes +// it — a payload without it means stage 13 fails an hour into a build. +func TestGuestPayloadWithNixhome_CarriesTheTarball(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "flake.nix"), []byte("{}"), 0o644)) + + payload, err := GuestPayloadWithNixhome(dir) + require.NoError(t, err) + + require.Contains(t, payload, "/devcell/nixhome.tgz", + "nixhome.tgz must land beside the stage scripts") + assert.Contains(t, payload, "/devcell/helpers/activate-home-manager.sh", + "the base guest tree must still be present") + assert.NotEmpty(t, payload["/devcell/nixhome.tgz"]) +} diff --git a/internal/vm/qemu/guestlogvolume_helper_test.go b/internal/vm/qemu/guestlogvolume_helper_test.go new file mode 100644 index 0000000..feb7b74 --- /dev/null +++ b/internal/vm/qemu/guestlogvolume_helper_test.go @@ -0,0 +1,35 @@ +package qemu + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// attachGuestLogVolume is the one-call guest-logging setup for any QEMU test: +// it builds a FAT log volume in workDir, registers collection of logNames +// into resultsDir when the test ends (pass or fail), and returns the image +// path to put in Spec.LogVolumePath. +// +// Guests locate the volume by the GuestLogVolumeMarker file and write logs +// next to it; collected files land in resultsDir as guest-. +func attachGuestLogVolume(t *testing.T, workDir, resultsDir string, logNames []string) string { + t.Helper() + img := filepath.Join(workDir, "guest-logs.img") + payload, payloadErr := GuestPayloadWithNixhome(filepath.Join(repoRoot(t), "nixhome")) + require.NoError(t, payloadErr, "the guest tree must embed and nixhome must pack") + require.NoError(t, BuildControlVolume(img, payload), + "the control volume carries the guest module, stage scripts and nixhome.tgz in, logs out") + t.Cleanup(func() { + for _, l := range CollectVolumeLogs(img, logNames) { + if l.Err != nil { + t.Logf("log volume %s: %v", l.Name, l.Err) + continue + } + writeArtifact(t, resultsDir, "guest-"+l.Name, string(l.Content)) + t.Logf("log volume %s: %d bytes saved", l.Name, len(l.Content)) + } + }) + return img +} diff --git a/internal/vm/qemu/guestps1_invariants_test.go b/internal/vm/qemu/guestps1_invariants_test.go new file mode 100644 index 0000000..e4621a9 --- /dev/null +++ b/internal/vm/qemu/guestps1_invariants_test.go @@ -0,0 +1,89 @@ +package qemu + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// These assert the scripts that ACTUALLY RUN. +// +// The WSL stages are file-backed (`ScriptFile:` in devEnvStages), so +// guest/stages/*.ps1 is what reaches the guest. The Go generators +// (GenerateNixVerifyScript, GenerateHomeManagerScript) render +// templates/devenv/*.tmpl and have no production callers left after the +// CELL-402 migration — so assertions against them can pass while the shipped +// script is broken. Run 20260803T231223 is the worked example: the `| tail` +// exit-code bug existed in both copies, and no test caught it in either. + +func readStage(t *testing.T, name string) string { + t.Helper() + b, err := os.ReadFile(filepath.Join("guest", "stages", name)) + require.NoError(t, err, "the stage table references this file by name") + return string(b) +} + +// $? in a pipeline is the LAST command's status. `home-manager switch ... | +// tail -40` therefore reported success while nix died on a permission error, +// and the stage logged "ok in 36s" (run 20260803T231223). +func TestShippedHomeManagerStage_PipeCannotSwallowAFailedActivation(t *testing.T) { + s := readStage(t, "home-manager.ps1") + + piped := regexp.MustCompile(`switch[^\n]*[^|]\|[^|]\s*\w`) + if loc := piped.FindString(s); loc != "" { + assert.True(t, + strings.Contains(s, "pipefail") || strings.Contains(s, "PIPESTATUS"), + "the shipped activation pipes its output (%q) without pipefail/"+ + "PIPESTATUS — a failed switch reports success", loc) + } +} + +// NixOS is a multi-user store; nix-daemon mediates writes and WSL only starts +// it when /etc/wsl.conf asks for systemd. `nix --version` answers on a store +// the user cannot write, so verifying with it certifies a distro that cannot +// build — as it did 13 minutes before the activation failed on +// /nix/var/nix/db/big-lock. +func TestShippedNixVerifyStage_ProvesTheStoreIsWritable(t *testing.T) { + s := readStage(t, "nix-verify.ps1") + + assert.True(t, + strings.Contains(s, "nix-daemon") || strings.Contains(s, "systemctl") || + strings.Contains(s, "systemd"), + "the stage must record whether nix-daemon is reachable") + + assert.True(t, + strings.Contains(s, "nix-store --add") || strings.Contains(s, "nix build") || + strings.Contains(s, "nix store add"), + "verification must exercise a real store WRITE as the invoking user") +} + +// systemd needs TIME, not just configuration. +// +// Run 20260803T235230: /etc/wsl.conf already had systemd=true and `ps -p 1` +// answered `systemd`, yet +// +// systemctl is-system-running -> Failed to connect to system scope bus +// wsl -> Failed to start the systemd user session +// +// and nix-daemon never came up, so the store stayed unwritable. Booting +// systemd inside a WSL2 utility VM under TCG double emulation took >532s for +// the first command alone. A single probe reads a half-booted system as a +// broken one; the stage has to wait for the daemon the way it waits for SSH. +func TestShippedNixVerifyStage_WaitsForTheDaemonRatherThanProbingOnce(t *testing.T) { + s := readStage(t, "nix-verify.ps1") + + assert.Contains(t, s, "nix-daemon", + "sanity: the stage concerns the daemon") + + assert.True(t, + strings.Contains(s, "while") || strings.Contains(s, "for (") || + strings.Contains(s, "do {") || strings.Contains(s, "Start-Sleep"), + "the stage must POLL for nix-daemon readiness — under TCG systemd is "+ + "still coming up when the first probe runs, and a one-shot check "+ + "fails a distro that would have been fine seconds later") +} diff --git a/internal/vm/qemu/guestps1_test.go b/internal/vm/qemu/guestps1_test.go new file mode 100644 index 0000000..8f36a5f --- /dev/null +++ b/internal/vm/qemu/guestps1_test.go @@ -0,0 +1,122 @@ +package qemu + +import ( + "io/fs" + "os" + "os/exec" + "path" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Syntax gate for the guest PowerShell tree (CELL-402). +// +// Go-templated PowerShell cost four multi-hour runs: lost quotes turned a nix +// invocation into "no subcommand", an interpolated colon broke icacls, and +// quote stripping killed two more. Every one of those was a SYNTAX fault that +// a parser would have caught in milliseconds — but the only parser that +// counts is PowerShell's own, and the guest was the only place it existed. +// So each fault cost a full Windows boot under TCG to discover. +// +// Now that stages are real .ps1 files, pwsh on the HOST can parse them before +// any VM starts. This is the cheapest deterministic check in the project and +// it gates the 12 stages still to convert. + +// pwshPath returns the host PowerShell binary, or "" when none is installed. +// Absence is a skip rather than a failure: the suite must stay green on a +// machine that has no pwsh, while the check runs wherever one exists. +func pwshPath() string { + if p, err := exec.LookPath("pwsh"); err == nil { + return p + } + // The devcell container installs it into the default nix profile. + const nixPwsh = "/nix/var/nix/profiles/default/bin/pwsh" + if _, err := os.Stat(nixPwsh); err == nil { + return nixPwsh + } + return "" +} + +// parsePowerShell returns the parse errors pwsh reports for one file, or an +// empty string when it parses clean. +func parsePowerShell(t *testing.T, pwsh, file string) string { + t.Helper() + // The path is inlined rather than passed as an argument: `pwsh -Command + //