Adoption
How a repo moves commands from native execution onto the sandbox one at a time, with no rewrite and no risk. Adoption is itself a design surface: if switching a command in is harder than the speedup is worth, nobody switches. The bar is near-zero barrier, fully reversible, per-command granularity — never "migrate the repo," always "migrate one command."
How it works
flowchart LR
cmd["virrun -- <cmd>"] --> prefixed{"prefix present?"}
prefixed -->|no| native["runs native\n(virrun never involved)"]
prefixed -->|yes| config["resolveVirrunConfiguration\nvirrun.config.*"]
config --> resolve["resolveBackend"]
resolve -->|"host supports it"| backend["selected backend\n(os / vfs / native)"]
resolve -->|"unsupported host\n(no bwrap, no WSL node)"| fallback["degrade to native\nnever error the build"]
The virrun -- <cmd> prefix is the switch: every prefixed command is sandboxed, and opting a command in or out is adding or removing the prefix — a reviewable one-token edit. There is no allowlist and no on/off env flag. The committed config only decides which backend a prefixed command runs through (configuration). virrun does inject a vitest-style VIRRUN=true into every command's environment (read via isVirrunEnabled) so a test or tool can detect it runs under virrun — but that is an output virrun sets, never an input that gates routing.
Levels (escalating opt-in, each reversible)
- Explicit prefix — wrap a single invocation (
virrun -- pnpm test). Nothing else changes; this is how every command is first tried and benchmarked. Drop the prefix → native again. - package.json script — bake the prefix into one script (
"test": "virrun -- vitest") so collaborators get it for free. Granularity is per-script: adopttestfirst, leavebuildnative until measured. - Config backend selection — commit
virrun.config.*to pin which backend prefixed commands run through, reviewable in one place instead of implied by the host'sautodefault.
A fourth level — a transparent PATH shim with zero prefix — was measured unviable and dropped: pnpm prepends ./node_modules/.bin (and the workspace .bin) to the front of PATH before running a script, so a shim dir can never intercept a script-local binary (vitest, eslint, tsgo all resolve from .bin). Transparent routing was also the only mechanism that would have needed a committed allowlist; since it is off the table, virrun carries none. See whole-repo routing for the successor idea.
Auto-fallback (the safety net)
Adoption is only zero-risk if a sandbox path that cannot run becomes native: an unsupported host (no bubblewrap, or WSL without a Linux Node.js) degrades backend selection to native before a run starts — the worst case of adopting a command is "no speedup," never "broken build." The os backend never falls back at run time, though: once selected, an in-run failure is a hard error, because a silent un-isolated run would be a wrong answer disguised as success (execution backends).
CLI subcommands
The CLI is built on citty, so every command has --help. The bare virrun -- <cmd> prefix is shorthand for virrun run.
| Command | What it does |
|---|---|
virrun -- <cmd> | Default passthrough — forks a warm snapshot on the os backend (write-back on), else execs natively. Alias of virrun run. |
virrun run -- <cmd> | Explicit form of the default; --ephemeral keeps the vanishing fork, --no-cache skips the task cache. |
virrun exec -- <cmd> | Forced plain exec — runs the command directly, skipping any warm-cache fork (the cold sibling of run). |
virrun warm | Provisions the os backend's warm cache (dependency snapshot + prepare layer) for the current lockfile ahead of time. |
virrun doctor | Probes each os-backend prerequisite (bubblewrap ≥ 0.10.0, WSL Linux node, python3, host tar, the real overlay-mount verdict) and prints an aligned per-check report; exits non-zero on a gap. |
virrun init [--backend] | Writes the JSON config variant selecting the backend (--force to overwrite); hand-write virrun.config.ts for platform branching. |
virrun cache ls | Lists the repo-local dependency store and host-global warm snapshots / prepare layers / task cache. |
virrun cache clean [--all] | Removes the repo-local .virrun cache; --all also clears the host-global snapshots, prepare layers, task cache, and win32 source mirrors. |
Dogfooding (this repo)
Esposter is the first consumer — dogfooding is the test corpus, not a separate effort. The committed root virrun.config.ts branches win32 → os, else native (configuration). Read-only verification scripts (eslint ., oxfmt --check, tsgo, vitest run) carry the prefix — on win32 they get caching + isolation and never need the network. The mutating dev-loop siblings (format → oxfmt, lint:fix → oxlint --fix/eslint --fix) carry it too, wrapping each underlying step so their edits flow back to the host (write-back). Watch-mode watch:packages and network commands (outdated:dependencies) run native: the sandbox can't cache them and would only add the prepare-rebuild + mirror tax. pnpm install is never on the ladder — the os install feeds the fork snapshot, not host disk (materialize node_modules).
Key files
Paths relative to packages/virrun/src/.
| File | Role |
|---|---|
services/cli/ (citty commands) | run/exec/warm/init/cache/doctor subcommands |
services/cli/doctor/probeOsBackendChecks.ts | the per-prerequisite doctor probes |
services/configuration/resolveVirrunConfiguration.ts | config discovery + loading (unconfig) |
services/configuration/isVirrunEnabled.ts | reads the injected VIRRUN=true signal |
Notes
- One token to opt in, one token to opt out — symmetry is the whole point. What's adopted lives in version control as the prefix on a script; the backend choice lives in the config — both reviewable and revertible like any code change.
- The package-facing how-to lives in
packages/virrun/readme/getting-started.md(published npm docs) — including the CLI's provisioning output behavior (one-time stderr provisioning line, stdout kept clean for piped callers); this page is the design rationale.