Skip to content
Contributing

Contributing

The rules for working in the repo, for people and coding agents alike, are in AGENTS.md.

Building from source

devvm is built with Go, pinned in mise.toml. With mise installed:

mise install
mise exec -- go build -o devvm ./cmd/devvm
./devvm --help

The binary has devvm’s own files (assets/) built in, so it runs from anywhere: link to it from your PATH, or copy it there.

ln -s "$PWD/devvm" ~/.local/bin/devvm

After editing anything in assets/, build again before a manual run: a binary keeps the files it was built with.

Tools and tasks

mise installs every tool at the version pinned in mise.toml, checked against mise.lock:

ToolUsed for
gobuilding and testing
golangci-lintGo linters and formatters (.golangci.yml)
gotestsumrunning the tests
govulncheckchecking dependencies for known vulnerabilities
goreleaserrelease builds (Maintaining)
hugo-extendedthe docs site
vale, markdownlint-cli2, lycheelinting the docs
noderunning markdownlint-cli2
shellchecklinting shell scripts
actionlint, zizmorlinting the GitHub workflows, and checking them for security problems
minisignsigning releases

Run the tasks with mise run <task>:

TaskDoes
fmtformats Go code
lintruns lint:code and lint:docs
lint:coderuns golangci-lint, shellcheck, actionlint and zizmor
lint:docsruns the docs linters, the message link check and the command line check, and builds the site, which fails on any warning
testruns the tests with the race detector, then checks each package’s coverage against its baseline
coverageruns the tests, then raises each package’s coverage baseline to what they reach
vulnruns govulncheck
deadcodelists functions nothing calls outside the tests
cli-docsgenerates the CLI reference
docsgenerates the CLI reference, then serves the docs site
e2eruns the end-to-end tests against real VMs

mise run test needs a C compiler for the race detector: Xcode Command Line Tools on macOS, gcc on Linux. It gives each package 30 minutes rather than go test’s 10, because the CLI tests run the built binary against many stubs and take far longer on a busy machine. Both test tasks run scripts/test.sh, which gives the tests a throwaway HOME.

Before and after every change, mise run fmt && mise run lint && mise run test must pass.

CI runs lint and test on macOS and Linux. A change that touches only the docs and Markdown files skips them: the Docs workflow runs lint:docs and builds the site instead, and deploys it on every push to main.

Go lint rules

golangci-lint enforces the Go conventions in .golangci.yml:

RuleEnforces
forbidigoonly internal/ui prints (fmt.Print*, os.Stdout, os.Stderr); only cmd/devvm calls os.Exit; only internal/ui and cmd/devvm make a context (context.Background, context.TODO), every other function takes its caller’s; only internal/platform reads the host’s CPUs, memory and free space, which the rest reads through platform.Host; only internal/cli calls testbuild.On; only internal/daemon and internal/platform signal a process by its pid (os.FindProcess, syscall.Kill, unix.Kill, os.Process’s Kill and Signal); only internal/fetch makes an HTTP client or a transport (http.Client, http.DefaultClient, http.Transport, http.DefaultTransport, http.Get and the like), which the rest names as fetch.Client; nothing calls os.UserHomeDir, because the home folder is paths.Dirs.Home; only internal/state calls os.WriteFile or os.Create or opens a file with os.O_TRUNC, and the rest writes a file whole through state.WriteFile, or state.WriteFileMode for a mode other than 600; nothing but daemon.Ready changes devvm’s own environment (os.Setenv, os.Unsetenv, os.Clearenv and syscall’s), and a command gets its own through its Env
depguardthe allowed modules; internal/proxy uses only the standard library and internal/daemon, which uses only the standard library too
nolintlintevery //nolint names its linters and says why, and is still needed
reassignno package reassigns another package’s variables, tests included
reviveat most 100 statements or 160 lines a function (function-length), and a cognitive complexity of 75 (cognitive-complexity)

A fresh context elsewhere, such as for a cleanup that outlives a canceled run, or a lookup that many requests share, takes //nolint:forbidigo // <why>.

testdata/lint/forbidigo.go breaks each forbidigo rule once, on a line that ends in // want forbidigo. mise run lint:code lints it with forbidigo alone and fails unless forbidigo reports exactly those lines, so a rule whose pattern stops matching fails there. A new rule gets its line in the file. ./... leaves testdata out, so the file never fails the lint run of the code itself.

The structure test

internal/structure parses every Go file in the repo, tests and every platform’s included, generated files left out, and checks what linters don’t. mise run test runs it. Each failure starts with its rule and the file and line:

RuleChecks
max-methodsno type has more than 24 methods, value and pointer receivers together, and no interface declares more than 24, not counting the interfaces it embeds; a larger one splits by what its methods do
no-initno func init()
package-varsa package-level var is a blank interface check, an Err*/err* sentinel from errors.New or fmt.Errorf, a regexp.MustCompile, template.Must or sync.Once* result, a //go:embed file, or listed in allowedVars; anything else becomes a field, a const or a local
layerswhich packages import which, in non-test files (tests may wire packages together)
ownersin non-test files, only its owner runs a host command that commands lists, only the files execSites lists make a host command, only guardOwner asserts a backend package’s type, only envOwner reads a DEVVM_ variable, but the settings envSettings lists, only its file in envOwners reads HOME, only its owner’s string literals hold a text that literals lists, only its owner’s calls to package strings split or join on a separator that separators lists, only pathsOwner builds a path in devvm’s data or config folder, and only its file in callers calls a function that callers lists (details)

The layers:

  • Only cmd/devvm and internal/cli import internal/ui; only internal/cli imports internal/vm; only cmd/devvm imports internal/cli.
  • internal/backend/... imports none of internal/vm, build, tools, netpolicy, secrets, project, gitprotect, sshconfig, sshagent, trust or vmssh, so the port forwarder and the link sessions stay devvm’s on every backend.
  • Only internal/backend/registry and internal/cli import a backend (lima); only tests import backendtest and internal/stubtest and the packages below it, which import only internal/msg and each other. The stubs’ binary, internal/stubtest/stub, and its state, internal/stubtest/stubstate, import nothing else of devvm’s, because the binary stands in for the tools devvm runs and is built apart from it.
  • internal/proxy imports no other devvm package but internal/daemon.
  • assets, locales, internal/platform, internal/msg/gen, internal/testbuild, internal/hostgit, internal/shell, internal/vmname, internal/daemon, internal/fetch, internal/jsonc and internal/redact import no devvm package; internal/agentlogin only internal/fetch, internal/jsonc, internal/proxy and internal/state, because it holds agents’ logins; internal/msg only locales; internal/paths only internal/msg; internal/state only internal/msg, internal/paths, internal/platform and internal/vmname; internal/image only assets, internal/msg and internal/paths; internal/devcontainer only internal/image, internal/jsonc, internal/msg and internal/shell.

Everything it holds the code to is in devvm() in internal/structure/structure_test.go:

  • largeTypes lists the types allowed more methods, which are paths.Dirs and ui.UI, each with its count as its maximum and why. A backend isn’t one of them, because its methods are split by backend.Backend’s roles (how a backend plugs in). One that grows fails; one that shrinks fails until its maximum is lowered.
  • allowedVars lists the other package-level vars, by package folder and name, each with why: values set by -ldflags, fixed tables, test fixtures. An entry whose var is gone fails.
  • layers is the layering table: a layer’s packages (from, but not exceptFrom) import none of to (but exceptTo); x/... is x and the packages below it.
  • commands maps a host command to the one package that runs it. execSites lists the files that make a host command with exec.Command or exec.CommandContext, each with why, and a listed file that no longer makes one fails. guardOwner names internal/backend/guard.go, the one file that asserts a backend’s type. envOwner names the one file that reads devvm’s DEVVM_ variables. envSettings lists the variables read elsewhere, each with the files that read it and why. A listed file that no longer reads its variable fails. envOwners maps another variable to the one file that reads it, which for HOME is internal/paths/dirs.go.
  • literals maps a text that only one package’s string literals may hold to that package and what the others use instead.
  • callers maps a function to the one file outside its package that calls it, and a listed file that no longer calls it fails. A method is listed as pkg.Type.Name, such as internal/paths.Dirs.AgentSettings, and any call of a method by that name outside its package counts, because the check reads no types, so a listed method’s name is its type’s alone.

TestRulesFail breaks each rule, and each layer, on small sources and checks the failure names it.

One owner for each concern

Each concern in devvm has one owner, the package or function that does it, such as internal/hostgit for every git command devvm runs on the host, internal/paths for every folder and file devvm uses, internal/settings for every default (settings.Read and settings.Save, over config.toml and settings.json, with each value’s parser), and locales/en.toml for every message. Before you add a helper, a parser, a path, a file reader, a message or a table, find its owner and extend it. A second copy drifts from the first, and then one of them is wrong. AGENTS.md’s layout table names the owners, and so do the failures of the checks below.

The checks on owners

An owner gets a check only when a second copy would be a bug or a security hole, and only when the check is exact, so that it never fails on code that’s right. Each check’s failure names the owner:

CheckOwnerAllowed elsewhere
forbidigo, testbuild.Oninternal/cli, whose readOverrides is the only code that asks whether this is a test buildnowhere: other packages take the overrides as fields
forbidigo, os.FindProcess, syscall.Kill, unix.Kill and os.Process’s Kill and Signalinternal/daemon, which signals a background process only while its pid file and its arguments say it’s still that process (Proc), or through the process it started (Handle)internal/platform; tests, which signal the children they started; and a child that hasn’t been waited for yet, whose pid is still its own, with a nolint saying so
forbidigo, HTTP clientsinternal/fetch, whose NewClient sets each client’s timeout and redirect policy (HTTPSOnly or SameScheme), and whose Retry, Transient, ReadCapped, Get, Resolve and Download try again after the failures that may pass, cap what a body or a download may hold, follow a HEAD’s redirects to where they end and check a download’s SHA-256 before anything uses it. fetch.Client is its own type, so a client that NewClient didn’t make, such as &fetch.Client{}, fails every request, and a caller changes a client’s transport through its Transport methodinternal/proxy, which is the standard library only, and tests, which make their own servers’ clients
forbidigo, os.WriteFile, os.Create and os.O_TRUNCinternal/state, whose WriteFile and WriteFileMode write a file whole through a temporary file in its folder, renamed over it, so a reader never sees half of itinternal/proxy, which imports only the standard library and has its own writer, writeAtomic; internal/msg/gen, which imports no devvm package; internal/stubtest; tests; and, with a nolint saying why, a file written another way: a log that a process writes as it runs, a download that streams into its file, and internal/daemon’s pid and status files, each written in one write that a reader takes as missing until its line ends
forbidigo, os.Setenv, os.Unsetenv, os.Clearenv and syscall’seach command’s own environment, its exec.Cmd’s Env, because every command and background process devvm starts inherits devvm’s. A VM’s SSH agent is one: the finder’s target carries its socket, which goes to the backend in backend.ExecOpts.Agent, backend.ShellOpts.Agent and Confinement.AgentSocket, and Lima sets SSH_AUTH_SOCK on that VM’s limactl commands alone, so a run that reaches several VMs never gives one another’sdaemon.Ready in internal/daemon/status.go, which takes the status file’s name out of a background process’s own environment, and tests, which use t.Setenv
the structure test’s owners, host commandsthe runners: the UI’s LoggedCmd, which a package takes as a field and which runs a command into the run log, a backend’s Runner, and internal/hostgit. A host command that one of them doesn’t run is made only in the files execSites lists: those that make the command a runner then runs, such as Lima’s limactl and mise’s, and those whose command can’t go into the run log, because it streams for as long as a link, the port forwarder or the desktop’s relay lasts, asks on the terminal, outlives the run, or carries a secrettests
the structure test’s owners, a backend’s other interfacesbackend.As in internal/backend/guard.go, because the registry’s backends are behind its guard, whose type has only backend.Backend’s methods, so a type assertion to backend.Forgetter or *lima.Lima on one is always falsetests
the structure test’s owners, gitinternal/hostgit, which runs git without the repo’s fsmonitor, hooks or filters, over ssh only, without the environment’s GIT_* variables and promptstests, which run git to make their repos
forbidigo, os.UserHomeDir, and the structure test’s owners, HOMEpaths.FromEnv in internal/paths/dirs.go, which reads HOME once into paths.Dirs.Home, so a test or an e2e run that sets its own HOME moves every path devvm takes from ittests
the structure test’s owners, DEVVM_ variablesreadOverrides in internal/cli/overrides.go, which reads a test build’s stand-ins, while a release reads nonethe settings in envSettings, and tests
the structure test’s owners, shell quotinginternal/shell, whose Quote and QuoteJoin quote words for a POSIX shell, so a value never runs as a commandtests
the structure test’s owners, the VM-name grammarinternal/vmname, whose IsVM tells a project VM’s name from an image’s, so gc never takes a project VM for an imagetests
the structure test’s owners, image namesinternal/image, whose Name is an image’s name and splits it into its layers (Layers, Parent, Has), whose NameOf joins layers into one, and whose ParseName reads the name the user gives, which build.Names’ Parse checks against the layers there are. devvm build, devvm rebuild and devvm images rm read their names there once, and the rest of devvm passes the image.Name ontests
the structure test’s owners, state.ReadHash and project.Parentsthe finder in internal/vm/finder.go, whose target finds the VM every command acts on: the folder’s own, the nearest parent folder’s under --here and --parent (resolveFolder), or one named (devvm rm VM). It finds a folder’s VM by the state that records the folder (finder.state, by the path’s hash first, then recorded), offers to move a VM whose folder moved there (moveHere in internal/vm/move.go, the match here.movedFrom), and names a new VM by the first of the folder’s names whose VM folder is free (newName, project.Nth). Its need says what the command needs of the VM, and need.check is the one place that refuses one that isn’t there, one made for an earlier folder at its path, one whose remake didn’t finish and one whose creation didn’t, the last with refuseUnfinished, which every start (starter.bringUp in internal/vm/start.go) makes too. It reads the folder devvm runs in only then, so the commands that act on no folder’s VM run in a folder that’s gone. The VM it finds is a target (internal/vm/target.go). It holds what the VM’s backend told the finder, which each start and stop of the VM then updates, and it runs what a command does with that VM: exec, shell, stop, running, ownSSH and the name of its ssh config entryinternal/state, and tests
the structure test’s owners, state.ReadAllthe inventory in internal/vm/inventory.go, which reads every VM’s state (ReadStates), and with ReadInventory the backends’ lists too, each within its wait, for every listing of VMs: devvm ls, devvm space, devvm images and devvm gc, the ssh config, devvm gc --orphans, the links, devvm secret’s list, finding a VM by its name and the mounts that devvm’s SSH agent checks a key against. Its one failure policy is that a backend that can’t list fails a listing (Inventory.Err) only when some VM’s state names it, and that a state that can’t be read is a row of its own, which the readers leave out and ToolsInUse and Mounts fail on. state.VMNames, which fails on such a state, stays for the readers below internal/vm: netpolicy, ports and sshconfig.ChooseAliasinternal/state, and tests
the structure test’s owners, paths.Dirs.AgentSettings and paths.Dirs.AgentLoginsdevvm’s agent store: agentStore in internal/vm/agents.go for the agents’ settings, which the mounts, the setup’s copies, a remake’s home and devvm agent import take, and secrets.LoginFile in internal/secrets/secrets.go for their logins, which the setup’s copies and the proxy read, so nothing reads an agent’s files from the home folder but vm.AgentImportinternal/paths, and tests
the structure test’s owners, paths in devvm’s foldersinternal/paths, whose Dirs and VM name every file and folder devvm keeps, so the layout, its golden listing in testdata/script/layout.txtar and a VM’s teardown miss none. A filepath.Join onto a Data or Config field fails, and so does an os.MkdirTemp or os.CreateTemp there whose name is a literal; a name from paths, such as paths.ToolsBuildPrefix, passestests

forbidigo matches only the function a call names, so the checks that look at a call’s arguments are in the structure test. It finds a host command in an exec.Command or exec.CommandContext call that names it, as a literal or as a constant, whatever name the file imports os/exec under. It finds a DEVVM_ variable in a call to any function or field named Getenv or LookupEnv, in any case, so t.Getenv and a getenv parameter count as well as os.Getenv, and it finds HOME the same way. The settings envSettings lists are the ones users set, DEVVM_NO_TOOL_CHECK, DEVVM_NO_RELEASE_CHECK, DEVVM_LIMA_HOME, DEVVM_SECRET_SERVICE and DEVVM_FROM; names mode, DEVVM_MESSAGES, which any build has; and the variables devvm sets for its own processes, DEVVM_GUESTFWD for devvm _qemu and DEVVM_DAEMON_STATUS for each background process internal/daemon starts.

The structure test also finds a copy of an owner’s code by its text, in literals. A string literal outside tests that holds '\'', the escaped quote a shell quoter writes, belongs in internal/shell, and one that holds [0-9a-f]{14}, the hash at the end of a project VM’s name, belongs in internal/vmname. A quote written as a rune, such as '\'' in Go, isn’t a string literal, so it doesn’t count. In the same way, separators finds a copy of a parser by what it splits on: a call to any function of package strings with + as an argument, as a literal or as a constant, splits or joins an image’s layers, so it belongs in internal/image. A call with another argument that holds a +, such as strings.ContainsAny(arg, "+,"), doesn’t count.

The shared tools have one owner too, the table image.SharedTools in internal/image/tool.go. Each entry, an image.ToolDef, holds a tool’s name, its kind (a toolchain or an agent), its members (the tools in the lock that its name stands for, such as pnpm and bun for node), the files in a project folder that call for it, and its installer, which is mise for every tool but rust, which rustup installs. image.Toolchains, image.Agents, image.GroupTools, the files that make devvm choose a toolchain for a new VM, the lock’s check for downloads (image.NoDownload) and the devcontainer.json features that devvm maps to a toolchain all come from it. To add a shared tool, you add its entry, its members to assets/shared-tools/mise.toml, its policy as assets/shared-tools/<name>.policy, and a section to its user page, using/toolchains.md or using/coding-agents.md. The agreement tests fail until the table and those files match.

The scripts that devvm’s builds run with bash have one owner for the machine they run on, assets/prelude.sh, whose $devvm_arch, $devvm_libc and devvm_sha give each script its architecture, its C library and its pinned checksum for each download (the scripts’ prelude). A script reads them rather than run uname -m or look for apk itself, and checks that the prelude came first with : "${devvm_arch:?}" for each variable it reads, which also tells shellcheck that the variable is set. Go reads each of those scripts through image.OwnScript, which puts the prelude before it, so a recipe, a tool image’s key and the conversion’s copy of oci/build.sh all hold the two together. TestPreludeUsers in internal/image fails when a script that doesn’t get the prelude, such as assets/oci/bootify.sh or assets/guest/’s, uses it, or when a script reads a variable without that check. testdata/script/prelude.txtar checks which pin devvm_sha picks from each script’s pins on arm64 and amd64, with glibc and with musl. A CLI test or a Go test that sources one of these scripts sources assets/prelude.sh first.

internal/paths also owns how a path on the host is resolved and compared with a folder, in resolve.go, whose comment says which resolver each question takes. paths.Physical resolves every symlink as far as the path exists, for where a folder or file really is, such as a project folder against devvm’s own folders or an SSH key against the folders VMs mount. paths.PhysicalParent resolves only the folder’s symlinks and keeps the last element as named, for a path that may itself be a symlink devvm mustn’t follow, such as a key’s recorded path or a Seatbelt rule. filepath.EvalSymlinks itself, which fails when the path doesn’t exist, is for a path that must exist, such as the folder devvm runs in. Then paths.Within tells whether a path is a folder or inside it, and paths.Below whether it’s inside it only. Both compare names, so both paths are resolved the same way first.

A VM’s state, vm.json, has one owner too, internal/state, which reads and writes the whole file and knows only its core fields. Every other package that keeps something of a VM’s owns one section of it, with its own type, its name (Section), and an Of that reads it through state.Section: internal/netpolicy owns net, internal/gitprotect git, internal/ports ports, internal/sshagent agent, internal/tools tools, internal/secrets secrets, and internal/vm size, clone, devcontainer, desktop and links (internal/vm/state.go). A section’s type keeps what the user asked for at its top level and what devvm made under made. A change goes through state.VM.Update, which takes the VM’s lock, reads the file again, runs the change and writes the file once: the change sets its section with state.SetSection and works out a list’s new value from the file as it is then, never from what the command read earlier, so a change another devvm made meanwhile is kept. A change that fails writes nothing. Update holds one VM’s lock, so a change takes no other lock inside it, and a change to several VMs updates each in turn. state.VM.Claim is Update that also holds the claims lock, for what no two VMs may have (ports.Allocator chooses every VM’s host port through it), and state.VM.Create writes a new VM’s whole state once, its ssh alias chosen under the claims lock. A build VM has no state, so ports.Allocator.Build chooses its ssh port through state.Claims, which holds the claims lock alone. A VM’s alias is one vmname.IsAlias accepts, or devvm doesn’t use it. internal/vm’s checkState reads every section of the folder’s VM’s state as the VM is found, so a section that can’t be read stops the command instead of reading as empty. A new section adds its check there.

Agreement tests

When two places must agree but can’t be one, a test checks that they agree. TestEveryPartHasAPolicy checks that every image part has its policy, TestSharedToolsPolicies that the policies in assets/shared-tools/ are one for each shared tool in image.SharedTools, TestConfigTools that assets/shared-tools/mise.toml lists each shared tool’s members and nothing else, TestBuiltinPolicies that every policy devvm ships parses, TestEveryMessageIsUsed that every message has a caller, TestDocLinks that every doc link is a message and a heading, TestPreludeUsers that only the scripts that get the prelude use it, and TestReferenceIsCurrent that the CLI reference matches the commands. A new pair of places that must agree gets such a test.

The consolidation pass

A review of a body of work, and the review before a release, look across the code for copies:

  • two parsers of one format
  • a path built outside internal/paths
  • a list of files kept beside the folder that holds them
  • a value encoded in a string
  • two messages that say one thing
  • a comment that says to keep two places in sync
  • a test that copies a constant

Each copy gets one of three outcomes: extend the owner, make one owner, or leave it. Leave it when the only shared helper would be the wrong one, because a copy costs less than the wrong helper, and wait for a third copy before making one. The pass is part of those reviews, not a step every change runs.

Changing the state layout

State files describes the layout, and the design note says why it’s built that way. Every path in it comes from internal/paths. paths.Dirs names the shared folders and files, and Dirs.VM(name) gives a VM’s paths.VM, whose methods name each file in the VM’s folder: Dirs.VMDir(hash), vms/<hash>/, for a project VM, and run/builds/<name>/ for a build VM. A new file of a VM’s gets a method on paths.VM, so it lands in the VM’s folder, and vmFolders.teardown in internal/vm/teardown.go deletes it with the folder, with no list of files to update. A new socket goes in Dirs.Run(), named by a hash, and TestSocketsFit checks the longest path against paths.SocketMax. A new record that only devvm writes is JSON, read with encoding/json, which ignores fields it doesn’t know, and written whole with state.WriteFile. A new default goes in internal/settings, which reads config.toml strictly and saves settings.json, and a new lock is an OS file lock (platform.LockFile and its kin) on a file that paths names.

The data folder and the config folder each hold a version file with their layout version, state.Layout in internal/state/layout.go. Before each command that reads them, state.CheckLayout takes version.lock in the data folder and compares both files with state.Layout. A folder without the file gets this devvm’s version, the same version goes on, and a newer one stops the command with layout_newer, because a newer devvm changed the folders. An older one runs the migrations from that version to this one, in order, and then writes state.Layout into both files. skipsLayout in internal/cli/root.go lists the commands that skip the check: help, completion, the version and devvm’s own hidden commands.

testdata/script/layout.txtar makes two VMs on the allowlist tier, a forwarded port, a link and a secret, lists every file and folder under the data and config folders, and compares the lists with the ones at the end of the script, one for macOS and one for Linux. A change to the layout, such as a new file, a renamed folder or a file that moves, changes a listing, so the test fails until you update it. When it does, decide whether the change needs a new layout version:

  1. A change that an older devvm would read wrongly, or a file that moves, raises state.Layout by one.
  2. It adds a state.Migration at the end of migrations(), which changes the folders from the version before to the new one. A migration takes paths.Dirs, and a run that it fails stops before the versions change, so the next run tries it again. TestMigrations checks that there’s one migration for each version before this one, and TestCheckLayout runs fake ones.
  3. It updates the listings in layout.txtar.

A new file that an older devvm doesn’t need to know about, or a field added to a JSON file, which an older devvm ignores, keeps the version and changes only the listings.

How a backend plugs in

devvm runs every VM on Lima, but the rest of devvm reaches Lima only through the backend.Backend interface in internal/backend, so another backend is an addition rather than a change to the callers. A backend lives in a package of its own under internal/backend, and it does four things:

  • It implements backend.Backend and returns errors as messages, as AGENTS.md says, instead of ending the run. Backend is the backend’s Name and Caps and four roles, each an interface of its own that every backend implements in full:

    • backend.Host is what the backend needs of this machine and where it keeps its VMs there. Problems lists what the host lacks for it, and backend.Check(ctx, b) returns the first of them, so a backend doesn’t write a check of its own. NameMax is the longest VM name it takes, Location is where its VMs live when that can change between runs (backend.LocationOf), and DiskDirs and Floors are the folders its disks are in and the free space it wants there (backend.Space). A backend whose VMs can’t move returns "" from Location, and one whose disks aren’t on this machine returns no DiskDirs.
    • backend.Images builds images and the shared tools’ images and keeps the images. BuildImage runs the spec’s Recipe (image.Recipe, which internal/build puts together and image.Input hashes) as it is, each image.Step with its arguments and as root when it says so. Origin is the backend’s own steps that base starts with, such as the Ubuntu image Lima pinned on this machine, a --from image’s conversion, base’s disk size (backend.BaseDiskGiB) and base.yaml. Base’s size is backend.BaseCPUs, backend.BaseMemoryGiB and backend.BaseDiskGiB, never read from a backend’s files. internal/build puts them first in base’s recipe, as its Origin, so base’s key hashes them, and BuildImage is given them back and runs those rather than reading its own files again. PinOrigin pins what base starts from on this machine, which internal/build asks for before it keys a build (backend.PinFirst): on Lima, that’s the newest Ubuntu cloud image (state.UbuntuPin, found by ubuntu.Finder). A base build’s ImageSpec.Packages sends its packages into the VM’s backend.PackagesDir before the steps and takes them out after, which internal/build/packages.go records or replays and checks, and a step that exits backend.ExitUnreachable fails as build_sources_unreachable.
    • backend.Machines makes, starts, stops, renames and deletes VMs, lists them, and runs commands and shells in them. Running says how a VM runs now, in one backend.Run: the ID of its current start, whether it’s confined to its proxy, and its lifeline, the file that the VM’s proxy and port forwarder follow. The backend runs no port forwarder of its own, and SSH says how ssh, links and devvm’s forwarder (vmssh.Forwards) reach a running VM’s sshd, a target that vmssh.FromTarget checks. All three get it through vmssh.Checked, which checks the review lets the backend’s ssh through, checks the target, and adds the VM’s host keys for its current start, reading the record (vmssh.ReadKeys) or pinning the keys first (vmssh.PinKeys). A VM whose ssh isn’t usable fails with a *vmssh.UnusableError and one without host keys with vmssh.ErrNoHostKeys, which the ssh config skips on.
    • backend.Housekeeping is what devvm space and devvm gc ask: Usage, Strays, and DevvmCache and DeleteCache for its own caches. A backend that can’t tell returns none.

    backend.Forgetter is the one role a backend may leave out, for the files it keeps for a VM besides the VM. Lima keeps none. Lima’s type, lima.Lima, holds the backend’s fields, Name, Caps and the helpers its roles share, and it embeds one type for each role, host, images, machines and housekeeping, each over the Lima it’s part of. backendtest.Fake is split the same way. So no type has more methods than the structure test allows, and a new method goes on the type of its role.

  • It declares Caps, saying for each of backend.Features whether it has the feature natively, emulates it, or lacks it (features). It lists every feature by hand, None included, and Registry.Register refuses Caps that leave one out or rate a feature better than the one Feature.BuiltOn says it’s built on (Caps.Check). Callers check what a VM needs with the registry’s Review.Require before they act, and an emulation stays refused until its review passes.

  • It’s registered in registry.New (internal/backend/registry), the only place besides internal/cli that imports a backend. The registry hands the backend out behind its guard, which checks every CreateVM, Start, BuildImage and BuildTools call before the backend sees it. The guard refuses a spec that needs a feature Review.Require would refuse, using the specs’ own Features, and a build spec whose ImageSpec.Validate or ToolsSpec.Validate fails, such as one with a network that isn’t a build’s or a key that isn’t one. So a caller that forgets Require still can’t reach a backend with a feature it lacks, and the backend doesn’t check those specs itself. The guard has every role’s methods, its own in place of the backend’s for those calls, so a guarded backend handed on as a backend.Images or a backend.Machines is still guarded. The guard’s type has only backend.Backend’s methods, so code that looks for an optional role, such as backend.Forgetter, uses backend.As instead of a type assertion, and the structure test fails on a type assertion to a backend package’s type anywhere but the guard.

  • It names its images by key, and an image’s key hashes the backend’s name, so two backends never share an image.

  • It passes the conformance suite, backendtest.Conformance(t, factory), which checks the contract that backend.Backend’s documentation gives. The backend’s tests call it with a factory that returns a new backendtest.Subject for each check, and each check runs as a subtest. The subject is the backend, holding one image, whose key is Image, and no VMs. Dir is a folder of the check’s own for the folders its VMs mount, Boots says whether its VMs start, and Restart starts a running VM again outside Start, as a command of the user’s own would. The suite checks every role’s answers and the empty answers each role allows, that a VM is made, renamed and deleted as its spec says, and that a missing VM is backend.ErrNotFound. It also checks that Running gives a stopped VM its lifeline with an empty ID, that every start gets another ID, that a VM is Confined only while it runs on a start Start made with NetProxy, and that the registry’s guard still refuses what the backend lacks when the backend is used as backend.Images or backend.Machines alone. When Boots is false, the checks that need a running VM are skipped with the reason, and when Restart is nil, so is the one that needs it. backendtest.Fake runs the suite in internal/backend/backendtest, and TestConformance in internal/backend/lima runs it on Lima twice: on the limactl stub, whose VMs start, and on real Lima’s dummy instances, which can’t boot, so the checks that need a running VM are left to the end-to-end tests. The real Lima run skips when limactl isn’t on PATH. TestConformanceFinds checks the suite itself: each of a set of fakes that breaks the contract in one method fails the check meant to catch it.

The registry’s For chooses a VM’s backend: a VM that exists gets the one its state records in backend=, and a new VM gets backend.Default, which is lima. Tests set the registry’s NewVMs to give new VMs a fake backend (backendtest.Fake). The command line has no way to choose a backend, so a second backend would also add one, following the cli skill, together with its column in the tables that list VMs, images and caches. --json output already names each VM’s, image’s and shared tool’s backend.

A new backend gets a design note and a threat check with the security-audit skill before any of its emulations is marked reviewed, and that check covers what Lima gets from its VMs’ design. A VM started outside devvm must not forward an agent devvm didn’t choose, a VM whose root can change its own network must not count as confined, a secret the proxy needs goes in a private file and never in a command’s arguments, and a socket on this machine goes in a private folder.

CLI conventions

devvm’s commands follow one set of rules, so that a user who knows a few of them can guess the rest. A bare verb acts on this folder’s VM, anything else goes under its noun, and a bare noun shows or lists. A setting shows bare, changes with a value and takes -g for what new VMs get. The cli skill holds every rule, with a checklist for adding a command and a review for changing one, and its judgment.md explains, with examples, the rules that only a reviewer can check. The reasoning behind most of them is in the Command Line Interface Guidelines, and Cobra’s own mechanics are in Cobra’s docs.

The tests check every rule that a machine can, and mise run test runs them. The cli skill says which test checks each rule and which rules a reviewer checks by hand, and The CLI rule tests says what each test checks. After changing a command’s Use, Short, Long, Example or flags, run mise run cli-docs and commit the CLI reference it writes.

Each command gets the run’s services from one app, in internal/cli/app.go, which NewRoot makes for the run’s UI and hands to every command’s constructor. The app holds devvm’s folders (dirs), a test build’s overrides, the secrets store, the backends’ registry, the shared tools, the environment of the commands on this folder’s VM (vmEnv) and the image builder of the backend new VMs get (builder, the VM environment’s Builder). It makes each one the first time a command asks for it and keeps that one, or its error, for the rest of the run, so the retention that runs before a command and the command itself share them. A command on this folder’s VM runs in a.env, which returns the command’s failure with its message for an error that isn’t one. A new command takes its services from the app rather than making its own.

A command fails by returning its error from RunE, as failed(err, what) when the error may not be a message: what makes the message from the error’s text, such as msg.LsFailed. execute shows what the command returned in showFailure, and it’s the one place a failure is printed. A backend.LoggedError shows the end of the run log, then its message (ErrorLogged). A message, or an error that carries one, shows as it is, and any other error shows in what’s message, or in output_failed when the command didn’t say what failed. The run then exits with 1, and ui.Main runs the cleanups. Once the run is interrupted, a failure prints nothing, because the interrupt is the reason. The root’s layout check (checkLayout) returns its failure the same way, from PersistentPreRunE. Only the UI ends a run itself, where no command can return: a question that gets no answer, and a run log that can’t be opened.

INT, TERM and HUP cancel the run’s context in ui.Main, which then waits for the logged command that’s running to end. When there are cleanups to run, it also waits for the command’s own code to return, for up to 2 seconds (returnGrace), before it runs them and exits with 130. So a cleanup never undoes a command’s work while that command still runs, and a command that can’t stop delays the exit by no more than those 2 seconds. A question waiting for its answer isn’t waited for at all, because reading its answer doesn’t end at Ctrl+C, and an answer read once the run is interrupted is refused, so nothing goes ahead after it. A run with no cleanups exits at once, because nothing could undo its work, so the proxy and devvm’s other background processes stop as soon as they’re told to. A second signal during the cleanups is ignored, so they aren’t stopped halfway.

Command arguments

Each command checks its arguments and flags before it does anything else, so a usage error never looks for a VM, applies the retention settings or changes a file. internal/cli/args.go holds the checks:

  • A command’s Args is one of noArgs, exactArgs(n), maxArgs(n) and minArgs(n), never Cobra’s own validators, whose errors are in English. Each one names the arguments the command’s Use shows (KEY_FILE, [all|none|LIST]), and with ValidArgs set, it refuses any other value. noArgs on a command with commands of its own says the word isn’t one of them and lists them.
  • Values are checked in Args too. A command adds its own check with cobra.MatchAll, such as settings.ParsePorts for devvm ports or netpolicy.Rule for devvm network allow, returning the same message the command would fail with later.
  • Flag values are checked by name in checkFlagValue, because one flag name has one meaning: --cpus, --memory, --disk, --ports, --lifetime, --older-than, --tools, --agents and --layers. Flags that can’t be given together are marked with exclusive(cmd, a, b), which checkFlags checks first, in place of Cobra’s MarkFlagsMutuallyExclusive alone.
  • The parser’s own flag errors (an unknown flag, one without its value, a value its type refuses) become messages in flagError, set as the root’s SetFlagErrorFunc.

checkCommands wraps every command’s Args in these checks when NewRoot builds the tree. A message one of them returns is a usage error: Execute prints it, then usage_help naming the command’s own --help, and exits with 2. usage_error is only the fallback for a parser error devvm doesn’t know. What a command’s RunE returns once it ran is a failure, which execute shows and exits with 1. TestUsageErrorsEveryCommand runs every command with an unknown flag, without the argument it needs, with too many and with each flag’s value blank, and checks that each is a usage error and nothing runs.

devvm itself takes no arguments, so a layer may have a command’s name. The root’s Args is noArgs, which for the root runs checkWord (internal/cli/word.go): a word that isn’t a command is a usage error. When the word, or each name in it joined with + or a comma, is a part’s name, from the same partNames that checkPartFlag reads (image.Toolchains, image.Agents and the build env’s layers), the error is no_command_part, with the flags that choose them. Any other word gets the closest command, from Cobra’s SuggestionsFor with SuggestionsMinimumDistance 2 (msg.SuggestDistance, which msg.Closest also uses). devvm’s own help command and the root’s help func run the same check, so devvm help WORD and devvm WORD --help fail as devvm WORD does. The help func can’t return an error, so it panics with a wordPanic that execute recovers. --tools, --agents and --layers are checked in checkFlagValue through checkPartFlag: none alone, or a list of the part’s names. A name of another part’s is refused with that part’s flag (part_other_flag), and any other name it doesn’t know with the closest name (vm.CheckPart).

The CLI rule tests

The Go tests in internal/cli walk the command tree, so a command that breaks one of the command line’s rules fails mise run test, whether it’s new or changed. Each list of exceptions is kept in its test, and every entry says why:

  • TestEveryCommandsHelp checks that every command users see has a Short in sentence case without a full stop, a Long of one to three sentences, an Example and a docs page in commandDocs, and that each of the root’s commands is in one of commandGroups. devvm help is the exception, as Cobra lists it apart and the CLI reference leaves it out.
  • TestPersistentFlags checks that every command takes -h/--help, --no-input and -v/--verbose and has no other persistent flag. TestFlagNames checks that each shorthand is in its list, -h, -v, -f, -g and -n, for one flag only, that no command takes --yes, and that a flag has one help text wherever it appears, except the names in manyUses. TestFlagNamesHaveOneMeaning checks that a flag name has the same shorthand, type and value everywhere. TestDurationFlags checks that a flag whose value is a DURATION is read with state.ParseDuration in checkFlagValue, and that no flag is a Go time.Duration.
  • TestShowCommandsTakeJSON checks that every command users see takes --json or is in withoutJSON, and TestJSONShapes checks that every command with --json has a shape.
  • TestCommandNames checks that command names are lower case without aliases, that no command is named list, show, get, delete, remove or ls but devvm ls, that a command named unX has a sibling X, and that each argument in a Use is a PLACEHOLDER or a word, with |, [] and ... only. TestUseMatchesArgs runs every command with the arguments its Use shows, from argSamples, and with none when they’re optional, and checks that it runs, so its Args takes what its help shows. TestUnknownWord checks that devvm WORD, devvm WORD --help and devvm help WORD fail alike for a word that isn’t a command, and that a toolchain’s, an agent’s or a layer’s name, alone or joined with others, points to its flag while any other word gets the closest command.
  • TestCommandLines reads every devvm command line in the repo: the docs, the generated CLI reference among them, the README, AGENTS.md, locales/en.toml, Go’s comments and strings, assets/, install.sh, the CLI tests and their stubs, the scripts, the workflows and the skills. It checks that each one’s command exists, that each flag is one that command takes, that a placeholder is one its Use shows and a flag’s value the one its help shows, such as DURATION, and that a word after a command with commands of its own isn’t a command from elsewhere in the tree. A command line written to be typed, in a code block, a code span or a CLI test’s exec, also has its values checked with checkFlagValue, and when it has nothing to fill in, it runs in a tree whose commands do nothing, where it mustn’t be a usage error. Prose has only its command and its flags checked, because its words after the command are English. The lines written to show a refusal are in refused, each with why, and a CLI test’s ! exec lines are left out, as they’re meant to fail. mise run lint:docs runs it too, so a docs change that names a command that doesn’t exist fails there. TestCheckCommandLine checks that it finds each kind of stale line.
  • TestQuestionsDeclareAnswers in internal/cli finds every question the code makes, outside tests. A question is a msg.Question, which Ask, Confirm, Answer and Secret take, and only two methods of a message make one: AnsweredBy("--force") names the flag or command that answers it, and RefusedWith(msg.X(...)) gives a question that no flag answers the refusal that says what to do instead. The test checks that each AnsweredBy names a string, that each flag in it is one its command takes and each command one devvm has, and that each RefusedWith gives a message, so no question is asked without its answer declared. TestCheckAnswer checks that it finds each kind of wrong answer.

Go test helpers

Go tests are table tests next to the code they test, and they share a few helpers, so each one reads the same way:

  • stubtest.New(t) puts the stubs first on PATH for one test, as the CLI tests have them, with a new folder under /tmp as their STUB_DIR and its calls file as their STUB_LOG. The folder goes when the test ends. stubtest.New(t, "ssh") puts only the stubs it names on PATH, so the host’s other commands, such as git, stay. Its Calls returns the calls the stubs logged, Reset forgets them, and Touch, Write and Read work on the files the stubs read, such as a switch, a file whose presence makes a stub fail. VM, Size and VMs set and read Lima’s instances in the stubs’ state, and Keychain and SetKeychain the Keychain’s items. stubtest.Command(t, "mise") returns the path of one stub, for a test that runs it by its path, as DEVVM_HOST_MISE does.
  • stubtest.ExecRunner is a backend’s runner that runs each command as it is, with no run log, so a backend under test runs the stubs.
  • msg.IDOf(err) returns the ID of the message an error carries, or "" when it carries none, and msg.NamesOf(err) returns the message’s names, as names mode prints them, or the error’s own text when it isn’t a message. A test compares either with what it expects.
  • A msg.Fake is the msg.Reporter a package under test prints and asks through. Its zero value is quiet: it prints nothing, has no terminal, so it refuses every question as the UI does where it can’t ask, and runs a logged command as it is. Its fields change that: OnInfo, OnWarn and the other On funcs are given each message or question of their kind, with a question’s context going to OnNote first, OnAny every message, and Terminal, Picks, Interrupt and the writers stand for the terminal, the picker, an interrupt and the output.
  • A msg.Recorder keeps the messages it’s given. Set a msg.Fake’s OnInfo or OnWarn to the recorder’s Add, then compare its Names or its IDs.
  • backendtest.Stored is the fake backend with its disks on the host, for the free-space checks.

CLI tests

CLI tests are testscript scripts in testdata/script/*.txtar, run by internal/cli/script_test.go against a freshly built devvm. Call it with exec devvm …:

# An unknown flag fails.
! exec devvm --nope
stderr '^devvm: flag_unknown command=devvm flag=--nope$'

Scripts see messages by their names, so the example checks which message devvm printed, not its wording. A script that checks a message’s text sets env DEVVM_MESSAGES= first.

Run one script with mise exec -- go test ./internal/cli -run TestScript/<name>.

Each script runs in a sandbox under /tmp, never the real folders:

VariablePoints to
HOMEhome/
XDG_DATA_HOMEdata/
XDG_CONFIG_HOMEconfig/
DEVVM_LIMA_HOME, LIMA_HOMElima/ (short, for Lima’s socket paths)
DATA, VMS, RUNdevvm’s data folder, data/devvm/, and its vms/ and run/ folders
CFGdevvm’s config folder, config/devvm/
DEVVM_MESSAGESnames, so messages print as their names
STUB_DIRstub/, for stubs’ state
STUB_LOGstub/calls, the stubs’ call log
DEVVM_SECRET_SERVICEdevvm-test, the Keychain service name
DEVVM_UBUNTU_URLubuntu/, with a dated release as Ubuntu signed it
DEVVM_KVMstub/kvm, a file standing in for Linux’s /dev/kvm
DEVVM_TEST_CPUS, DEVVM_TEST_MEMORY_GIB, DEVVM_TEST_FREE_GIB8, 16 and 500: a large host, whatever runs the tests (details)
DEVVM_HOST_MISEthe mise stub, which resolves the shared tools in place of devvm’s own mise
MISE_STUB_LOCKtestdata/mise.lock, the lock the mise stub writes
DEVVM_BINthe folder of the built devvm, alone
DEVVM_REPOthe checkout, for the scripts in scripts/, which tests copy into a throwaway repo before running, and the scripts in assets/, which tests source to check their pieces

The built devvm is a test build, so it reads the stand-ins above that a release ignores, such as DEVVM_KVM and DEVVM_HOST_MISE. A script answers a question in one of two ways: ttyin -stdin FILE gives the next exec a terminal whose input is the file, so devvm asks as it does in yours, and env DEVVM_PIPED_ANSWERS=1 with stdin FILE has each question read its answer as a line of the file instead, without its prompt. Without either, devvm has no terminal, so it refuses every question, as a script’s devvm does. It has the checkout’s assets/ embedded, as a release has, and no test build reads them from elsewhere: a script that needs other files for devvm (layers, features, policies) puts them in the user’s folders in the sandbox, as a user would.

$WORK, the script’s own folder, is the current folder. project NAME makes the folder $WORK/NAME and goes to it, as a project’s folder, and project NAME git makes it a git repo too. Once devvm has made the folder’s VM, vm_name VAR NAME finds it among the states in $VMS and sets $VAR to the VM’s name, which ends in an ID the test can’t know, so a script can pass it to a stub (exec limactl stop $WEB). It also sets $VAR_dir to the VM’s folder, $VMS/<hash>, so a script reads the VM’s state as $WEB_dir/vm.json and its network log as $WEB_dir/logs/network.jsonl. catg GLOB prints the one file a glob matches, and fails when the glob matches none or more than one, so a check never reads two files by mistake. vm_state GLOB prints the one VM state a glob matches as sorted KEY=VALUE lines for stdout to match: a section’s fields are dotted (net.on=true, ports.host.ssh=23456), a list of strings is joined with spaces (tools.names=node claude), a list of objects is indexed (links.links.0.name=db), and anything but a string is JSON. Negated, ! catg GLOB and ! vm_name VAR NAME pass when there isn’t exactly one. json_set GLOB KEY=VALUE… sets string fields in each JSON file the glob matches and keeps the rest, or makes the file when a glob without wildcards matches none. KEY:=JSON sets any JSON value, and a dotted key sets a field of an object, which it makes when it’s missing. Scripts write devvm’s JSON records with it, such as a tool image’s record (json_set $SHARE/node-*.json built=2020-01-01T00:00:00Z), because a record’s times are in the file, where touch can’t change them. A VM state a script writes as a fixture needs version, id, name, image, dir and backend, created for a VM whose creation finished, and a hash of its own for its folder (json_set $VMS/12345612345601/vm.json version:=1 id=x name=devvm-live-12345612345601 image=base dir=/p/live backend=lima created=2020-01-01T00:00:00Z net.on:=false).

Tests never touch the network. Downloads are served from a test server (net/http/httptest) or read from a file:// folder. DEVVM_UBUNTU_URL replaces https://cloud-images.ubuntu.com/releases/resolute, where devvm finds Ubuntu’s dated releases: point it at a folder of release-YYYYMMDD/ folders, each with its SHA256SUMS and SHA256SUMS.gpg, as file:///path/to/folder. The CLI tests serve internal/ubuntu/testdata/resolute, a release as Ubuntu published and signed it, and Go tests sign their own releases with a key they make (internal/ubuntu/pgp_test.go). devvm’s own releases, which devvm upgrade installs, come from internal/upgrade/upgradetest: a TLS test server laid out as GitHub’s releases, with a minisign key of the test’s own that signs each release’s SHA256SUMS as minisign does. A script starts one with release_server [-badsig] LATEST [VERSION...], which serves a release of each version, the first the newest, each devvm in it a script that prints its version, and with -badsig signs them with another key. It points a test build at the server with DEVVM_REPO_URL, at its key with DEVVM_RELEASE_KEY and at its certificate with DEVVM_RELEASE_CA, and DEVVM_TEST_VERSION gives the test build a release’s version. Container registries (--from) are a TLS test server in Go tests; scripts use pinned digests, which need no lookup, or set HTTPS_PROXY to a closed port, so lookups fail as offline ones do.

A command left running in the background (exec devvm … &name&) can be interrupted with kill -INT name. wait_file FILE waits up to 10 seconds for a file to appear, such as one a stub writes when it gets somewhere. A script that runs a copy of devvm makes it with cp_exe SRC DST, not cp: on Linux, a command another script starts while the copy is being written keeps the file open for writing until it runs, so running the copy then fails with “text file busy”, and cp_exe writes the file while no command can start.

Stubs

The stubs stand in for every host command devvm runs, so tests never run the real tools: limactl, docker, security, ssh, ssh-add, ssh-agent, ssh-keygen, qemu-img, qemu-system-aarch64, qemu-system-x86_64, chflags, tmutil, sw_vers, uname, open, code and mise. They are one Go program, internal/stubtest/stub, which does what the command it’s run as would do: the binary is named stub, each command’s name is a link to it, and it reads its name from its argv[0]. internal/stubtest builds it once, into $TMPDIR/devvm-stubs-<hash> with its links, where the hash covers the stub’s sources and Go’s version. Every test process, and every later run until the sources change, finds that folder and uses it, so the binary builds only once, and a folder of an older build is removed once it’s a day old. TestScript puts that folder on PATH right after devvm’s, ahead of the real commands.

Each stub logs its call to $STUB_LOG as one line, its name and its arguments joined by spaces. The stub writes the line itself, in one write to the log opened for appending, so lines from stubs running at once never mix, and its arguments never go to another process’s command line. That matters because endpoint security tools such as CrowdStrike Falcon kill a process whose arguments look like an ssh option next to a proxy URL with a password, which is what a start’s limactl edit passes. stub_calls prints the log for stdout or cmp to check, ! stub_calls fails if anything was logged, and stub_reset empties the log, so the next check sees only the calls after it:

exec devvm …
stub_calls
cmp stdout calls

-- calls --
limactl list

uname and sw_vers pretend to be another host when these are set, and run the real command otherwise:

VariablePrinted by
STUB_UNAME_Suname -s
STUB_UNAME_Muname -m
STUB_SW_VERSsw_vers -productVersion

What the stubs keep between their calls is their state, $STUB_DIR/state.json, which internal/stubtest/stubstate reads and writes as typed JSON under a file lock, so stubs running at once never lose each other’s changes. It holds Lima’s instances, each with its status, size and config, the Keychain’s items, the SSH agent and its keys, and Time Machine’s exclusions. Scripts read and change it with the stub command, and Go tests with the stubtest.Stubs methods VM, Size, VMs, Keychain and SetKeychain:

CommandWhat it does
stub vm NAME STATUSLima lists the instance NAME in STATUS, such as Stopped, Running or Broken, with Lima’s default size when it’s new
stub vmsprints Lima’s instances, one NAME STATUS line each, sorted
stub size NAME CPUS MEMORY DISKgives an instance its size, with the memory and disk in bytes
stub keychain ACCOUNTprints the Keychain’s item for ACCOUNT, and fails when there’s none
stub keychain-set ACCOUNTsaves the item for ACCOUNT from its input, so the value is in no process’s arguments
stub agentprints the SSH agent’s keys as ssh-add -l lists them, and fails when no agent runs
stub agent-stopstops the SSH agent
stub namesprints every stub’s name

A switch is a file a test makes in $STUB_DIR to change what a stub does, such as start-fails, which makes limactl start fail. The comment above each stub’s function lists its switches, and every switch is named for the command and what happens to it: -fails, -hangs, -fails-on and -waits-on, where an -on switch holds the text that a call’s arguments must contain. A stub that hangs touches $STUB_DIR/hanging first, so wait_file can wait for it. The files a stub writes in $STUB_DIR are what a test checks, such as $STUB_DIR/sync.tar, what a VM’s setup was sent.

limactl keeps its instances in the state and fails or hangs on request. It keeps each instance’s size and config as create, clone and edit set them, and a contract test checks that it does so as real Lima does. Its shell reaches the guest, the VM the stubs make believe in, which ssh reaches too. One module, guest.go, answers whatever devvm runs in a VM, with one set of switches whichever way devvm gets there. limactl shell is also the shell devvm opens in a VM, which then ends, so a script sees it as devvm’s last call. In the shared tools’ build VM, its shell writes the images build.sh is asked for into the build’s folder (or, on request, a symlink instead), and it keeps what a VM’s setup is sent as $STUB_DIR/sync.tar. A base build’s packages go in as $STUB_DIR/packages-in.tar, and what the build gives back is $STUB_DIR/packages-out.tar, or no packages. In the --from build VM, its shell writes what oci/build.sh would (or, on request, a hostile disk, command line, C library or symlink), and a call naming $STUB_DIR/interrupt-on’s text interrupts devvm, as Ctrl+C would. The VM’s ssh host keys are one made-up key for each VM, unless $STUB_DIR/hostkeys-fails exists.

docker stands in only for what --from asks of it. It has an image only when $STUB_DIR/docker-image holds its ID, its save writes a tarball naming that image (or $STUB_DIR/docker-saved’s, a tag that moved), and it isn’t running when $STUB_DIR/docker-down exists. ssh answers a port forwarder’s master with $STUB_DIR/guest-ports, and a link session’s master listens on its control socket. Either master outlives the process that started it, as ssh’s does, until the sandbox’s cleanup stops it or 5 minutes pass. A direct connection from a VM (the self-check’s) fails, as a confined VM’s does, unless $STUB_DIR/net-direct exists. A --clone VM’s git runs on the host, on $STUB_DIR/vmfs/<folder>, and ssh pushes to and fetches from there (it fails with $STUB_DIR/ssh-fails). security stands in for the Keychain and tmutil for Time Machine, both keeping their items in the state, never in the real ones. mise writes $MISE_STUB_LOCK as the lock ($MISE_STUB_OLDER_LOCK for a list of versions), lists $MISE_STUB_VERSIONS, and fails like mise offline with MISE_STUB_FAIL set. chflags sets no flags, so .git protection leaves test folders deletable. qemu-img and QEMU are there so Linux hosts pass devvm’s checks.

To add a stub, write its function in internal/stubtest/stub, with a comment that says what it does and lists its switches, and add it to stubs() under the command’s name. It logs its call with c.log() first, keeps what it must remember in the state through c.update, and reads its switches with c.on and c.text. A new command the state needs goes in control. To add a switch, read it in the stub’s function, name it for what it does as above, and add it to that function’s comment. Each stub has unit tests in stub_test.go, which run it in the test’s own process. Where a stub prints what devvm parses, such as limactl list --json, the golden fixtures check that it prints what the real tool prints.

devvm runs in $WORK or a folder made in it (mkdir, then cd), never in HOME or devvm’s own folders, which it refuses as project folders.

Coverage

mise run test measures each package’s test coverage, then fails when a package’s coverage is more than a point below its line in the baseline, so coverage only goes up. That point of room is there because the same tests can cover a little less from one run to the next, with timing or the tools a machine has installed. A package inside it passes and keeps its line. Each system has a baseline of its own, testdata/coverage/darwin.txt and testdata/coverage/linux.txt, because macOS and Linux run different code and skip different tests. On a system without one, mise run test says so and checks nothing. A package’s coverage counts its own Go tests and the CLI tests together. scripts/test.sh sets CLI_GOCOVERDIR, so TestScript builds devvm with -cover for every package of devvm’s, and each devvm a script runs writes its counters to that script’s own folder inside it. Scripts run in parallel, and devvm runs that started at the same moment in one folder would race to write the same meta-data file, so one of them would print coverage meta-data emit failed on stderr and fail any script that checks it. go tool covdata then merges every script’s folder with the Go tests’ own counters. That way internal/cli, and code that only the CLI tests reach, such as a backend’s real runner, count as tested. The end-to-end tests don’t count.

When a change raises a package’s coverage, raise its baseline in the same commit. Run mise run coverage, which runs the tests as mise run test does, then writes each package’s coverage to this system’s baseline where it went up, and commit the file. A change that raises coverage on both systems raises both baselines, so run it on a Linux host too. It never lowers a line: a package within a point below its baseline keeps the line it has, and when a package is more than a point below, it fails and writes nothing. A new package gets its line from mise run coverage too, and a package that’s gone loses its line there, because mise run test fails on both until then. coverage.txtar checks scripts/coverage.sh, which compares the coverage with the baseline.

There’s no target percentage: a package is judged by what’s untested in it, not by its number.

End-to-end tests

mise run e2e runs devvm against real VMs, so it needs Lima and takes minutes: run it in the background. It runs TestE2E in tests/e2e, with the run’s flags after --. First it checks that this machine has every command the chosen sections use, and it names each one that’s missing with what installs it (the list). Then it builds devvm from the checkout into its sandbox, its files embedded, and runs every check through that binary:

Lima home~/.lt-e2e-go (or $DEVVM_E2E_LIMA_HOME); it refuses ~/.lima and devvm’s own
State/tmp/devvm-go-e2e (XDG_DATA_HOME, XDG_CONFIG_HOME)
Keychainservice devvm-go-e2e (DEVVM_SECRET_SERVICE), fake tokens only, deleted at the end
Lock/tmp/devvm-go-e2e.lock, a symlink to the pid of the run that holds it, so only one run uses the sandbox at a time
mise run e2e                        # every section but desktop and from
mise run e2e -- --from              # every section, from too, and base with every feature: the longest
mise run e2e -- --only images,gc    # just these sections
mise run e2e -- --clean             # delete the sandbox's cached images and tool images first
mise run e2e -- --every-feature     # base with every feature, as a run with from builds it
mise run e2e -- --slots 3           # the sections spread over 3 sandboxes that run at once
mise run e2e -- --slot 1            # run in slot 1's sandbox, its images as it has them
mise run e2e -- --newest-base       # base from Ubuntu's newest image and packages, in a sandbox of its own
mise run e2e -- --golden            # also capture the stubs' golden fixtures from the real tools

End-to-end tests in Go says how to run go test yourself, write a check and add a section.

--newest-base builds base as a user’s first build does, in place of the pinned release that the other runs use, and Maintaining says when to run it and what to do when it fails. --golden writes the golden fixtures of this system into the checkout, for you to commit.

The sections so far:

  • images: building base and fixture layers, rebuilds, a failed script and Ctrl+C during a build, then base’s recorded packages, each in the cache under its SHA-256, and base built again under the same key with ubuntu-mirror set to an address nothing answers on, whose apt reads only the build’s own folder of packages.

  • project: devvm in a new project folder (under /tmp/devvm-go-e2e/p) makes a VM from base and opens a shell there, which reads its commands from devvm’s input. The folder and the shared tools are mounted and nothing else is, there’s no SSH agent, no Docker (no docker layer) and the hostname is the folder’s. The ssh config lists it as devvm-web with its host keys pinned; through an ~/.ssh/config of the run’s own that includes it under a Host * forwarding the agent, ssh devvm-web works, forwards no agent (a throwaway one), and refuses a changed host key. .git is locked (macOS) and git in the VM can still commit. devvm stop stops it and drops its entry, and on macOS reports and locks a submodule git folder the VM added. TestE2EExecQuoting in internal/backend/registry, which runs only there (DEVVM_E2E_VM names the VM), checks that arguments with spaces, quotes, $, newlines and globs, a folder and input reach the guest as given, through the Exec of the backend the VM’s state records, so one test covers every backend. Then a VM from a devcontainer.json, with no ports by default: its forwardPorts said and not forwarded, none reaching this machine, its containerEnv in the shell and its commands, its postCreateCommand run once in the folder, its postStartCommand at each start (again after devvm restart), nothing written to the folder, and a changed file warned about without changing the VM. Then a claude VM with a fake agent config, history, git identity and token in a home of its own: devvm agent import copies the agent’s settings into the sandbox’s agent store without the history or the links out of its folder, the store’s folder is mounted read-only and linked into the VM’s home, and the home folder’s isn’t mounted, its settings are copied and its history isn’t, the VM gets the git identity, the timezone and its hostname, and, open as it is, the token’s placeholder and never the token. devvm agent save without a terminal shows the VM’s changed settings.json as a diff and its new commands folder’s file, then refuses and saves nothing, and after a yes it saves both into the store, but neither the VM’s fake login beside them nor the folder’s link out of it. Then SSH agents, with a home of their own and throwaway keys: a --ssh-agent VM uses a throwaway agent standing for yours; after devvm ssh-agent add and devvm ssh-agent devvm it keeps yours, in its shell and by name, until a restart, then uses only devvm’s own agent’s key, in its shells and by name; after devvm ssh-agent off, none.

  • gc: a VM from a folder named img-keep, which is named like an image, and a fixture layer edited after its build: devvm gc deletes the stale image and its record and an old tool image no VM lists (once there’s a tools lock), and keeps base and the VM.

  • tools: first, the shared tools resolve to upstream’s newest versions, with devvm’s pinned mise (downloaded once), each download with its checksum. This section is the only one that uses the newest versions, and no other step checks for them (DEVVM_NO_TOOL_CHECK). At the section’s end, e2e puts the pinned lock back. Then a node project’s VM: node, npm, pnpm, bun and yarn at the locked versions, each a read-only EROFS image, the share read-only even for root, npm i -g into the VM’s home, a non-login shell over SSH, an .nvmrc’s own version, the tools mounted at boot without devvm, and a second node VM that builds nothing.

  • network: a VM with Claude Code, Codex and opencode and --network allowlist, a fake token in devvm’s environment and a fake ~/.claude.json and Codex login in a home of its own, which devvm agent import copies into the sandbox’s agent store, only yours to read. Its proxy runs and the self-check passed; its password was in no host process’s arguments while devvm launched it (ps, sampled), and the guest has it; ssh devvm-net reaches it; direct connections fail, even as root, to an address and to other ports; an allowed host works and others, or other ports, are blocked; a line in the VM’s policy file that reads as a deny but isn’t a rule makes the running proxy refuse an allowed host, with no allow command in devvm network log --blocked, devvm network rm warns of it, and the host works again once it’s gone; DNS fails at once; the host’s loopback services are out of reach, directly or through the proxy; the proxy refuses requests without the VM’s password; the token is nowhere in the VM, which has its own CA and the next one, its own placeholder and logins’ copies with placeholders only; the proxy puts the token in for its host (for the VM’s own placeholder only), an MCP server’s header and Codex’s key for theirs, over TLS the VM verifies; a second allowlist VM has CAs of its own, each VM trusts only its own and has no key, each proxy serves and presents only its own VM’s CAs, and devvm rm deletes one VM’s CAs and leaves the other’s; the home folder’s and the store’s login files are unchanged. devvm agent save saves the VM’s changed ~/.claude.json with the store’s MCP header in place of its placeholder, and once the VM changes that server’s URL it leaves the file out, so the store keeps the server as it was. devvm secret set github --stdin saves a fake token (in the Keychain on macOS, under the run’s service, found through a link to ~/Library/Keychains in the VM’s home), read from a file so it’s in no process’s arguments, and devvm secret lists it saved, while asking devvm secret for a get subcommand is refused without printing it; secret use github binds it to the running proxy at once, and on the next launch the VM has its own placeholder in GH_TOKEN, never the token, which the proxy puts in for api.github.com; secret unuse and rm take the binding and the token away. TestE2ESelfCheck in internal/netpolicy runs the self-check on the VM: it passes, then, once the VM is started outside devvm, it fails and stops the VM. devvm stop stops the VM and its proxy at once. devvm network open makes its next start direct, its proxy set in the guest and answering, and the open VM holds no real secret either: the token is nowhere in it, its environment and Codex’s login hold only its placeholders, its proxy puts the token in over TLS the VM verifies with its own CA, and a request that goes around the proxy carries only the placeholder and gets a 401 from the token’s host. Then devvm network internet and devvm network none change its tier at once, the VM running on. Restarted behind the run’s company proxy on this machine’s loopback, named in the shell’s HTTPS_PROXY with a fake password that the check reads from a file, the guest has the proxy and reaches GitHub through it, while devvm’s run logs hold the password only as <password> and Lima’s ha.stderr.log only as stars. devvm network allowlist while it runs open shows it unenforced until devvm restart starts it confined; devvm network open while it runs confined keeps it on its proxy until it next starts. Behind a company proxy of the run’s own that fakes OpenAI’s token endpoint with a certificate from the run’s CA, a login made in the VM is captured: the store gets the fake tokens and API key, and the VM gets placeholders. Then devvm agent login codex runs the real Codex’s login in the VM, curl on this machine opens its callback with the state Codex’s address names, through the forward of localhost:1455, devvm names the account the store saved, the VM’s Codex holds only placeholders, and the forward is gone once the login ends, and gone too after an interrupted login. devvm agent login opencode --device-code runs the real opencode 2’s Codex device code login in the VM, which the fake endpoint answers too, devvm says the store saved the login, and opencode auth export in the VM shows only placeholders. A second VM with opencode has OPENCODE_DISABLE_AUTOUPDATE=1 in its login shell, makes its database and imports a credential of its own, then devvm agent login opencode --api-key --provider openrouter --stdin saves a fake key from the first VM, and at the second VM’s next launch its opencode has devvm’s entry with the key’s placeholder, never the key, beside its own credential, and once its database is deleted and made again, the next launch gives it the key’s placeholder again. The same company proxy fakes Claude’s token endpoint and Anthropic’s API, which logs the token each request brings: claude setup-token’s request, as curl, makes the fake token the claude secret and gives the VM its placeholder, and the API gets the token for it. Then devvm agent login claude runs the real Claude Code’s claude auth login in the VM, its code pasted on devvm’s input with the state its address names, the store keeps the fake login and the claude secret is gone, the VM’s ~/.claude/.credentials.json holds only placeholders and its environment no token, and once the store’s access token is due, the VM’s next request makes the host refresh it at Claude’s endpoint through the company proxy. This machine’s TLS doesn’t trust the run’s CA, so that refresh fails, the store keeps its login and the proxy’s log says so, and TestClaudeOAuthGet checks a refresh that succeeds.

  • nix: the nix layer. A copy of assets/layers/nix.sh as a layer of the run’s own, built behind a company proxy of the run’s own, reaches Nix’s releases through it and leaves no proxy settings for nix-daemon (and not the company proxy’s password) anywhere on its image’s disk. Then a folder with package.json, flake.nix and .envrc gets a VM with node on the nix layer (--layers nix) on the allowlist tier: Nix and direnv at their pins, a store that’s root’s, sandbox on with no fallback, trusted-users root alone and an untrusted user’s --option substituters ignored, nix-daemon with the VM’s own proxy in devvm’s drop-in alone, nix run nixpkgs#hello from Nix’s cache, a build in Nix’s sandbox, sudo nix and nix over ssh, the flake’s dev shell (nixpkgs at a pinned revision) loaded by direnv in an interactive shell, its node ahead of the shared node prompt after prompt and gone outside the folder, nix-direnv’s cache in the VM’s home with no .direnv in the folder, a fixed-output download from a host that isn’t allowed refused with 403 and named by devvm-why, and after devvm network open and devvm restart, no drop-in and no proxy for the daemon.

  • from: run with a docker that isn’t running, so nothing comes from Docker. Its container images go by their pinned digests, as node:24@sha256:…, so devvm looks them up nowhere, except the company proxy’s alpine:3.21, which keeps its tag so that devvm’s registry lookup is checked too. A VM from node:24 with node, python and Claude Code on the docker and nix layers (Debian 12, in a home of its own) boots, with its base keyed by the pinned digest, its node, python and claude at the locked versions, GNU tar as its commands find it, its tool images built in a clone of devvm’s own Ubuntu base when the run builds them, the docker layer’s Docker from Docker’s Debian repository, and the nix layer’s Nix under systemd running nix run nixpkgs#hello and a build in Nix’s sandbox; a VM from alpine:3 with node on the docker, nix and desktop layers runs node and pnpm from musl tool images, built in its own base and keyed and recorded by its digest, the docker layer’s Docker on OpenRC, the desktop layer’s programs from Alpine’s packages, with no crashed services, and the nix layer’s Nix as an OpenRC service, with nix run nixpkgs#hello and a sandboxed build, then after devvm network allowlist and devvm restart a download only its daemon makes from a host that isn’t allowed refused with the proxy’s 403, and with GNU tar as its commands find it, devvm history import puts an export into it, while an export with a path outside the agents’ history imports nothing there; base builds from ubuntu:26.04; and the build VM and its temporary folders are gone.

  • link: links between VMs, each check the end-to-end proof of a row of the threat check in the design note. It runs devvm with a soft limit of 256 open files, macOS’s default, as the link sessions it starts get that limit too, and it makes two small open targets, each forwarding no port and running servers on its own 127.0.0.1 (/var/tmp/e2e-link.pl, in perl, which every base has), and a client that forwards every port. Each of the three reserves the link checks’ ports (ip_local_reserved_ports), so Linux never gives one to a connection’s own end, where a connection that just ended would hold it for a minute and a later server or ssh -R couldn’t bind it. sshd -T in each VM shows remote forwards allowed on 127.77.*:* alone, at the address the host asks for, and the client’s own Lima rules ignore every port. A port the target doesn’t publish is refused by devvm link add, and once a port is published, devvm link add on the running client reaches it within seconds as NAME:PORT and NAME.devvm.internal:PORT, by curl and by a raw TCP client (bash’s /dev/tcp), while with --as NAME the target’s folder name doesn’t resolve. Two targets on one port are each reached by their own name, port 80 is linked on port 80, devvm link and devvm link publish list what they hold with "version": 1 in their --json, and devvm network allowlist is refused for the client while it links to open targets. A published port that isn’t linked has nothing listening for it, devvm link unpublish refuses the link’s next connection, logs it and names the client, and devvm link rm takes the listener away within seconds. In the client, sshd listens only on its own port and the links’ addresses, and nothing from a link listens on 127.0.0.1, ::1 or every address, so this machine gets no forward of a link, while a server of the client’s own on 127.0.0.1 with a link’s port is forwarded as usual. Each link’s socket is mode 600 in the run folder of mode 700, and the link session and every process it starts listen on no TCP port and connect only to the VMs’ ssh ports. ssh -R into the client binds nothing outside 127.77.0.0/16 (rfwd_links). A program in the client that holds a link’s address first fails that link alone with link_port_taken, 256 connections at once, past the socket’s backlog of 128 on macOS, are all carried, and a 257th is refused and logged as link_limit. Root in the client then loosens its sshd and the link session connects again: with PermitListen any the client binds only the links’ addresses, because the host asks for nothing else, and with GatewayPorts yes too, the link session still listens on no TCP port of this machine and connects only to the VMs’ sshd. devvm network log shows the links’ connections and refusals. A stopped target refuses the connection, logs link_target_stopped and stays stopped, and the client’s launch warns of it. Once it’s restarted, the link works again. devvm rm of the target removes the client’s link, names the client and removes its sockets, and a VM made again in that folder isn’t linked. Then the client and one target get the allowlist tier, the target while it runs. Until the target restarts, devvm ls shows it unenforced, the client’s next connection is refused and logged as unenforced while the target’s server runs, and devvm link add and the client’s launch warn of it (link_target_unenforced). Once both have restarted, the client reaches its link by curl through its proxy settings and by a raw TCP client while a direct connection fails. A link added while the client runs is reached at once by curl through its proxy, by GET and CONNECT, with no link name in NO_PROXY and the client’s boot and host keys unchanged, and the client’s proxy refuses names, ports and addresses that aren’t on its own list, while the target’s proxy refuses the client’s link name. devvm link add refuses an open target, devvm network open is refused for the target, and with the rule broken by hand in the target’s state the next connection is refused and the client’s launch warns and runs without the link until the rule holds again. In the --from tier, an Alpine client reaches its link to port 80. devvm rm of the client leaves no link session, socket, pid file, log or link state.

Every section but tools uses the shared tools’ versions pinned in tests/e2e-mise.lock, for both architectures’ glibc and musl VMs, and the --from checks start from container images pinned by digest in tests/e2e/from_test.go (fromNodeDigest, fromAlpineDigest and fromUbuntuDigest). The images built from them stay valid until a pin moves. base starts from the dated release of Ubuntu’s cloud image that e2eUbuntuRelease in tests/e2e/sandbox_test.go names, given to devvm as DEVVM_UBUNTU_RELEASE, in place of the newest that a user’s devvm pins, so base’s key stays the same between runs and on every machine of one architecture. Its packages are those its key recorded at its first build in the sandbox, which stay in the sandbox’s package cache through --clean, so base built again under the same key installs the same packages until assets/ or the release changes its key. scripts/bump.sh e2e-tools moves the lock to upstream’s newest versions, resolved with devvm’s pinned host mise, scripts/bump.sh e2e-from moves the digests to what Docker Hub has for each tag, and scripts/bump.sh e2e-ubuntu moves the release to the newest Ubuntu lists; scripts/bump.sh --check shows them against upstream. The lock’s first line is the SHA-256 of the assets/shared-tools/mise.toml it was resolved from, and a run stops at once when that file has changed since, until scripts/bump.sh e2e-tools resolves the lock again.

base, the --from bases and the shared tool images stay cached between runs, except what a rebuild or gc deletes: every stale image in the sandbox (other selections’ bases, whose features differ, among them) and every unused tool image (an earlier from run’s musl ones, and the tools section’s newest versions where they differ from the pins); every other VM a run makes is deleted at its end, and its project folders are unlocked and deleted. Each check prints [n/N], how long it took and the time left as the last complete run of each of its sections measured it, and a heartbeat every minute shows the latest output. e2e keeps each section’s times in a file of its own in slot 0’s state folder, timings/plain/SECTION, or timings/features/SECTION for a run whose base has every feature, because that run also checks base’s features. So a run of any selection, in any slot, estimates from and compares with the sections it runs. A check fails on its first failure, except the few marked Flaky, which flake on timing, such as the network section’s check that the proxy’s password was in no host process’s arguments, and the link section’s 256-connection check and its stopped-target check. When one of these fails, e2e shows its output and runs it once more, and a second failure fails it. Its result says retried once, its line in the timings has a third field, retried, and the summary counts the retried checks and names each one. A check a run has nothing for is counted as SKIP: base’s features, with base cached. A section that runs fewer checks than its last complete run with the same tests/e2e Go files fails the run, because an error ended it early, and e2e keeps the times it had. After you edit those files, each section’s first complete run sets the count the next runs compare with. Checks look for devvm’s messages with devvm _msg, never by their wording. The Go tests that need a real VM or desktop, TestE2EExecQuoting, TestE2ESelfCheck and TestE2EDesktop, skip unless their DEVVM_E2E_* variables are set, and e2e runs each of them through s.GoE2E, which sets those variables in the test’s environment, so a value such as the desktop’s password is in no process’s arguments.

Each check’s command has a time limit, counted from when it starts: three times the check’s longest time in its section’s last complete run, at least 2 minutes, or 10 minutes for a check with no time yet. The checks that build base or a --from base, which Builds() marks, get at least an hour, because their last time may have been a cached build. The steps between two checks, such as the launches that build tool images, get an hour too. When a check runs past its limit, the heartbeat stops the processes it started, with TERM as Ctrl+C would and then KILL 30 seconds later, and the check fails with the heartbeat’s last line in its output. When the steps before a check run past their limit, the heartbeat stops their processes the same way, and that check fails without running. Either way, the run goes on to the next check and then to its usual cleanup.

Slots

A host with spare cores and memory can run the sections at once, each set in a sandbox of its own called a slot. mise run e2e -- --slots N spreads the chosen sections over N slots, from 1 to 9, and runs each slot as a --slot K run of its own, all at the same time. The default is one slot, which is the run described above. Slot 0 is the sandbox in the table, and slot K adds -K to its Lima home, state folder, Keychain service and lock, as ~/.lt-e2e-go-1, /tmp/devvm-go-e2e-1, devvm-go-e2e-1 and /tmp/devvm-go-e2e-1.lock. Each fixed port the checks use is slot 0’s plus 100 times the slot, so slot 1’s servers use 47260 where slot 0’s use 47160. Slot 0’s ports run from 47160 to 47199, from 47240 to 47249, and 47613, 18765 and 18766, so no two slots ever share one.

e2e places the longest sections first, each in the slot with the least time so far, using each section’s time from its last complete run. A section that has never run gets a guess, from 2 minutes for gc and clone to 30 minutes for from. link always goes in the same slot as from when both run, because in the --from tier link’s Alpine client starts from the image that from builds. Each slot runs its own sections in the usual order, and a slot left without a section isn’t started. When go isn’t on your PATH, e2e finds mise’s Go once and puts its folder first on the slots’ PATH, because slots that each ran mise exec at the same time would each install the repo’s tools over one another. With --from, e2e passes --every-feature to every slot, so each slot’s base has every feature, as a single --from run’s has.

Before the slots start, e2e takes every slot’s lock and gives each other slot what slot 0 has and it lacks: every image’s Lima instance and record, every tool image and its record, and devvm’s caches. It clones the large files and never copies them whole: on macOS each one is an APFS clone, which needs both folders on one APFS volume, and on Linux a reflink. The records and the --from digests are small files, so e2e copies them. When a file can’t be cloned, as on a Linux host whose /tmp is tmpfs, e2e says so and that slot builds its own. With --clean, nothing is cloned, and each slot deletes its own cached images instead. A --slot K run of your own uses that slot’s images as they are.

Each slot’s output appears as it comes, each line led by its slot’s number, such as [1], and stays in the slot’s e2e.log. When every slot has ended, e2e prints each slot’s sections, results and time, then the sums: checks passed, failed, skipped and retried, the failed checks by name, and the time the whole run took. The run fails when any slot fails or ends without its results. TERM or Ctrl+C stops every slot with TERM, and each one cleans up as a single run does.

Testing on other hosts says how many slots each kind of host runs. Testing on other hosts also covers Linux hosts, x86_64 and the checks to run by hand.

Messages

Every message devvm prints is a table in locales/en.toml, and Go code calls it through a function generated from it. internal/msg renders messages but never prints them; the run’s ui.UI does. Only cmd/devvm and internal/cli hold it. Every other package prints and asks through the one msg.Reporter interface, which ui.UI implements: a package’s type embeds it, its constructor takes it, and cli gives it the run’s UI. Where a package’s warnings also say where the run log is, as Lima’s and the shared tools’ do, its type has its own Warn method over the Reporter’s. A Go test gives a package a msg.Fake instead, a Reporter that prints nothing and refuses every question unless the test sets its fields, and records what it says with a msg.Recorder.

Prose is never built with fmt: every sentence devvm shows the user is a message. Data output, such as a table or --json, isn’t a message, and neither is text in a script that runs somewhere else.

The locale files are in go-i18n’s TOML format, with only go-i18n’s own message fields, so go-i18n’s loader reads them as they are and its goi18n tools work on them:

[no_vm]
other = "There's no VM for {{.dir}}. Run devvm to make one."

[gc_tools]
one = "{{.count}} shared tool image is unused ({{.size}})."
other = "{{.count}} shared tool images are unused ({{.size}})."

[gc_tools_no_vm]
description = "tools lists the tool images, as \"node 24.21.0, claude 2.1.295\""
one = "{{.count}} current shared tool image no VM has ({{.size}}): {{.tools}}."
other = "{{.count}} current shared tool images no VM has ({{.size}}): {{.tools}}."
  • The ID is the table’s name, in snake_case.
  • other is the text: a Go template whose fields are snake_case ({{.vm_name}}). Only plain fields are allowed, no logic.
  • one is the text for a count of 1, when the wording changes with the count. Both texts then take {{.count}}.
  • description is an optional note for translators.
  • Nothing else: the generator refuses keys go-i18n doesn’t know, nested tables, plural forms English doesn’t use (zero, two, few, many) and leftDelim/rightDelim.
  • Voice: a message’s text follows the docs-voice skill’s Messages rules, which set its length (an error or a warning has at most two sentences, and a prompt at most two lines of context), and the mechanism goes in its doc link rather than its text.
  • One line: a value renders on one line without control or bidi characters, because msg.OneLine cleans every value as the message renders. Text from a VM that devvm shows as data goes through msg.Printable instead, which keeps its newlines and tabs. Colors says what the two drop.

Doc links

A message links a docs page through the docs map in internal/msg/docs.go, from message ID to a page in docs/content/docs and a heading on it. The page is its path without .md, and a section’s own page, its _index.md, is the section’s folder:

var docs = map[string]string{
    "vm_outdated":   "using/images#outdated-vms",
    "backend_lacks": "backends#when-a-backend-lacks-something",
}

devvm prints the page’s address on the docs site on the line after the text.

After editing en.toml, run:

mise exec -- go generate ./internal/msg

It writes internal/msg/messages_gen.go, with one function per message. The parameters are the template’s fields in sorted order, in camelCase, and count int for plural messages: [no_vm] becomes msg.NoVM(dir string) msg.M, and a value that’s missing or extra is a compile error. msg.M is also an error: one made from another error carries it with .Because(err), so errors.Is and errors.As see through it, while the text stays the message’s own.

The tests fail when:

  • messages_gen.go is stale;
  • a message doesn’t render with sample values, or shows <no value>;
  • a generated function is never called outside the generated file and the tests;
  • a doc link names a message that isn’t in en.toml, or a page or heading that doesn’t exist. mise run lint runs this check too.

Questions

A prompt asks a msg.Question, never a bare message, so every question declares what answers it where devvm can’t ask: without a terminal, with --no-input, or once the input ends. A message becomes a question in one of two ways. msg.X(...).AnsweredBy("--force") names the flag or command that answers it, and its refusal is no_input, which names that answer and shows the question first when the prompt wasn’t printed. RefusedWith(msg.Y(...)) makes a question that no flag answers, and its refusal is the message given, which says what to do instead. About(...) adds the messages said before the question, which devvm shows only when it asks.

The Reporter’s Ask, Confirm, Answer and Secret take the question and, where devvm can’t ask it, return its *msg.Refusal, an error whose Msg is the refusal’s message. A command returns it as it would any failure, and a caller with something to fall back on does that instead, so no caller checks for a terminal itself. TestQuestionsDeclareAnswers checks every question in the code, as The CLI rule tests says, and a CLI test answers one with ttyin or DEVVM_PIPED_ANSWERS=1, as CLI tests says.

Names mode

With DEVVM_MESSAGES=names, a message prints as its ID and its values in sorted key order, so tests check which message devvm printed and with what, never its wording or language:

! exec devvm _msg nope
stderr 'msg_unknown name=nope$'

Every CLI test runs in names mode unless it sets env DEVVM_MESSAGES=.

Names mode belongs to internal/ui, which reads it once, with the rest of a stream’s style, in New: a msg.M itself always renders as its text, so its String and Error are the same whatever the environment says. Whatever shows a message as data, a table’s cell or a --json field, goes through the UI’s Text, or a Table’s, to get the names in names mode. New takes the function it reads the environment with, os.Getenv for devvm’s own UI, so a Go test makes a UI in names mode without setting the environment and can run in parallel; cli’s testUI does. A Go test that checks a message without a UI compares Names, msg.IDOf or msg.NamesOf.

devvm _msg NAME key=value… prints a message’s text (never its names), for end-to-end tests that look for it in a VM’s output. It’s hidden from the help.

Colors

Only internal/ui adds color, through a style for each stream that onTerminal makes, and colorOn decides each one: NO_COLOR or DEVVM_MESSAGES=names turns it off, then FORCE_COLOR or CLICOLOR_FORCE (but 0) turns it on, then TERM=dumb turns it off, and otherwise the stream gets color only when it’s a terminal. Names mode stays plain in a terminal too, so end-to-end checks that run devvm under script read the names as they are. A UI from New, as tests make, has no terminal, so it’s plain unless the environment it reads sets FORCE_COLOR or CLICOLOR_FORCE, which is how cli’s tests get a colored one.

The colors are SGR codes from the 16 base colors only, which the theme maps to its own shades. devvm uses no bright colors (90–97), because Solarized maps them to greys and bright black is Solarized Dark’s background, and it uses no backgrounds, 256 colors or truecolor. The codes go on labels, links, log text and a message’s values, never on the template’s own words:

  • A message’s values are bold (1), on the status line and in prompts too. msg.M.Segments renders the template with each value between two private-use runes, U+E000 and U+E001, after dropping those runes from the values, so a value can’t start or end a marking of its own. It returns the text cut into segments, each one the template’s words or a value, beside the doc link. Joined, the segments are Text, and String and names mode don’t change. Every value goes through msg.OneLine as the message renders, in Text, Segments and Names alike, so a value has no newline or other control character, whoever made it, bold or not. The log end that ErrorLogged prints is the message on one line without bold, because bold inside dim would cancel it.
  • Error’s devvm: label is bold red (1;31), and Warn’s is bold yellow (1;33).
  • A doc link is underlined blue (4;34). msg.M.Segments gives the doc link apart from the text, so ui colors the link alone.
  • Info’s ==> is bold (1).
  • The run log’s end that ErrorLogged prints, with its --- lines, the log text on the status line, and the log’s lines that -v streams (SetVerbose: LoggedCmd and LogWriter copy the log to stderr through msg.Printable, with no status line) are dim (2).
  • The done mark that a slow step leaves is green (32).

The codes are added after msg.Printable and msg.OneLine, so they’re the only ones devvm prints. Those two are devvm’s one cleaner: they drop C0 control characters but newline and tab (which OneLine drops too), DEL and C1, whether in UTF-8 or as a lone byte, and the bidi controls such as U+202E, which reorder the text after them so a name could read as another. The status line, the run log’s end, Pick, devvm network log and the port forwarder’s log use them as well. The status line cuts and counts its plain text with style.fit before painting it, so it still fits the terminal’s width and clears the right number of rows. The run log is never colored.

Tables on Stdout() are colored as stdout is. A command gets a ui.Table from u.Table() and adds a header, rows of ui.Cells and a totals row, and cli never reads the environment itself. Each cell has a ui.Role: Good is green (32), Attention yellow (33), Bad red (31), Secondary dim (2), and Plain stays in the default color, while the header and totals rows are bold. Table pads each cell on its plain text before painting it, because text/tabwriter counts the codes’ bytes as width, and a plain table is byte for byte what text/tabwriter writes with a padding of 2 (TestTableLikeTabwriter). The roles for each table’s values are next to the code that writes it:

  • devvm images: current is good, stale, unfinished, unused and not built need attention, and digests and keys are secondary.
  • devvm ls: Running and the .git protections on, strict and clone are good, Stopped needs attention, and any other status, such as Broken, is bad, as is the tier unenforced, while a confined tier, internet, allowlist or none, is good and open stays plain.
  • devvm space: only copy and leftover in KEPT need attention.
  • devvm doctor: native is good, emulated needs attention, and unreviewed and none are bad.

Every command’s --json output is for scripts and stays plain.

mise run test unsets NO_COLOR, FORCE_COLOR and CLICOLOR_FORCE, and the end-to-end tests unset the last two, so your own settings never change what they check.

Translations

A translation is locales/<language>.toml (de.toml) with the same tables, one and other translated (and the language’s other plural forms, such as few), loaded by go-i18n the same way. Doc links are the same in every language: the docs site is English. devvm picks the language from the first of LC_ALL, LC_MESSAGES and LANG that is set (de_DE.UTF-8 is German); C and POSIX are English. A message missing from a translation is shown in English.

JSON output

Every command that shows or lists something takes --json, which jsonFlag in internal/cli/json.go adds. The command builds a struct of its own with snake_case json tags, next to the code that writes its table, and writes it with printJSON, which puts "version": 1 (jsonVersion) first, writes HTML characters as they are and refuses anything but an object. A list it prints is never nil, so it’s [] rather than null, and a field that doesn’t apply is omitempty. Sizes are bytes unless the field’s name gives the unit, as in memory_gib.

The root command’s PersistentPreRun calls u.DataOnly() for any command given --json, so Say, Info and LogNote print to stderr from then on, and stdout gets only the JSON. A settings command that also changes its setting with a value wraps its Args in showsOnly, which refuses the value with --json (json_shows_only) before anything else runs.

TestJSONShapes in internal/cli/json_test.go holds each command’s shape, a path: type line for each field of a sample with every field set. A field renamed, removed or given another type fails it. Shapes only gain fields, so a new field goes into the sample and the test, and any other change raises jsonVersion. A new command that shows or lists something gets --json, a row in that test, a CLI test and a section on its page with an example, which output for scripts links to.

Writing docs

The site is Hugo with the Hextra theme, built from docs/. Hextra is a Hugo module pinned in docs/go.mod; update it with hugo mod get github.com/imfing/hextra@<version> in docs/.

  • Pages are Markdown files in docs/content/docs/, one per topic, in a folder per section of the sidebar. The sections run in the order a new user needs them: getting-started/, using/, safety/, backends/, cli/, internals/, then contributing/. Each folder’s _index.md is the section’s own page, which lists its pages. Update the page for any behavior you change.
  • What goes where: the user pages, in getting-started/, using/, safety/ and backends/, say what devvm does, what you type and what you see, and they name no repo paths, Go identifiers or message names. How it works, in internals/, explains how devvm does it, for readers who want to check a claim, and it holds the mechanisms and the message names that come from them. Contributing, in contributing/, covers changing devvm, so repo paths, Go identifiers, version bumps, releases and tests go there. A user page links once to its How it works page where there is one.
  • Front matter sets title, shown as the page’s heading, and weight, the page’s place in its section, or a section’s place in the sidebar for an _index.md. Start the body at ##.
  • Links to other pages point at their files ([Contributing](_index.md)), so the linters can check them.
  • Diagrams are Mermaid code blocks, fenced with mermaid, which the browser draws when the page loads. docs/layouts/_partials/scripts/mermaid.html takes the place of Hextra’s partial of the same name. It loads the Mermaid version that hugo.yaml pins in params.mermaid.base, which Hugo fetches when it builds the site, and it gives every diagram the same theme, so a diagram never sets its own colors or font. The theme uses the site’s palette: panels on the surface color with blue borders, lime arrows, and text in Departure Mono at 14px, larger than the home page’s art so labels read easily. It uses Mermaid’s classic look, because its newer neo look draws a decision’s hexagon 70px taller than its label. A diagram can set its own layout in front matter, as Staying safe’s three columns set minNodeWidth, but never its own colors or font. Mermaid draws each diagram at its own size and never scales it to fit, and a diagram wider than the page scrolls sideways. Keep labels short, and break a long one with <br>. In a label, write < as #lt; and > as #gt;. A diagram goes where it helps the reader follow the text, with at most one sentence to introduce it, and the text keeps every fact the diagram shows.
  • Style: succinct and direct. Commands are written as devvm x or in backticks, never in quotes. No please, simply or note that.
  • The home page is docs/content/_index.md, on Hextra’s hextra-home layout: its hero shortcodes, then plain Markdown. custom.css styles it through the one div that the layout wraps its content in. After the hero, the page has a quick start, what’s supported, why to use devvm with its feature cards, a short FAQ, and a closing call to action with the hero’s buttons. It keeps a marketing tone, but each of its claims matches the docs page it links to, it uses the same terms, and it says each thing once.
  • The quick start shows the three commands to run first, each after a comment short enough to fit a phone’s width, because a comment after its command would scroll out of view there.
  • Feature cards on the home page come from two shortcodes in docs/layouts/_shortcodes/, which make a {{< features >}} grid of {{< feature title="Ready in seconds" icon="high-voltage" art="copies" >}} cards, each closed with {{< /feature >}}. A card shows its title as a heading, after the pixel icon its icon names, drawn in the title’s own color at 24px, then the Markdown inside it, then its illustration, which is optional. The cards are in one centered column on smaller screens. From 1024px wide, they go into two columns, the first, third and so on on the left and the rest on the right, and each card is as tall as its content, so no card leaves a gap under it. features places them: feature renders its card through docs/layouts/_partials/feature-card.html and hands it over, and each card’s order style keeps the page’s order when the columns stack. What’s supported uses the same cards with smaller illustrations, and Quick start uses them with a terminal in each card. {{< features columns="3" >}} puts three in a row from 1024px wide, and level="3" on a card makes its title an h3, under the section’s own heading.
  • Art beside a section’s text comes from {{< beside art="blast-radius" >}}, closed with {{< /beside >}}, in docs/layouts/_shortcodes/beside.html. It renders the Markdown inside it, then the illustration it names. From 1024px wide, the text and the drawing each take half the column, with the drawing on the right, centered on the text. On narrower screens, the drawing goes below the text. Why use devvm? uses it.
  • Install commands come from {{< install >}} in docs/layouts/_shortcodes/install.html, which the home page and Installing use. The commands are written in one place, docs/layouts/_partials/install-tabs.html, which the tabs and the pages’ Markdown copies both read. It renders Hextra’s tabs with a macOS tab, which has the Homebrew command, and a Linux tab, which has the install script. The macOS tab is selected in the HTML, so it shows when JavaScript is off. A small script in docs/layouts/_partials/install-os.html selects the Linux tab as the page loads when the browser’s platform or user agent string says Linux, apart from Android. To check the Linux tab in headless Chrome, pass --user-agent= with a Linux user agent string. custom.css styles the tabs on a blue line, with the selected tab in lime.
  • Other content per operating system goes in {{< os-tabs >}}, with a {{< os-tab name="macOS" >}} and then a {{< os-tab name="Linux" >}} inside it, each holding Markdown, as Installing’s requirements tables do. It uses the same tabs, styles and script as the install commands, so the visitor’s system is selected, and the build fails unless the tabs are macOS’s, then Linux’s.
  • Colors: the site is dark only (hugo.yaml’s params.theme). Its palette is in docs/assets/css/custom.css: a black background and one lime accent, #7fff00, which sets Hextra’s primary color. The home page’s illustrations also use #0086b3, the blue that Hextra’s syntax highlighting gives commands like cd, and #ff5370 (--devvm-red), the JustBlack theme’s red, which marks only what an attack has compromised. The red’s contrast is 6.7:1 on the black and 6.0:1 on the surface color, and it’s much darker than the lime, so the two stay apart for readers who can’t tell red from green.
  • Fonts: Instrument Sans for text and Martian Mono for code, from Google Fonts (docs/layouts/_partials/custom/head-end.html); Departure Mono, a pixel font in docs/static/fonts/ with its license, for the logo, the home page’s headings, badge and buttons. Departure Mono is sharp at multiples of 11px and has one weight.
  • The logo in the navbar is the branding page’s devvm-logo-on-dark.svg at its own size, which hugo.yaml’s params.navbar.logo names, with the site’s title as its alt text. Right-clicking it opens the branding page instead of the browser’s menu, through a script in docs/layouts/_partials/custom/footer.html. A left click, or Enter on the focused logo, still goes to the home page.
  • The branding page is docs/content/branding.md, at /branding/, on its own layout, docs/layouts/branding.html, which is Hextra’s home layout with the home page’s look and the script that copies a swatch’s hex. Its front matter keeps it out of the sidebar. It has the logo as box-drawing text in a code block, the logo’s SVGs and PNGs, the icons, the social card, the palette, the fonts, how to use the logo and the credits.
    • Its downloads are in docs/static/branding/, and {{< brand-asset >}} tiles inside {{< brand-assets >}} show each one on its background, with a link to each file. The build fails when a file doesn’t exist, and it reads each PNG’s size for its link. devvm-logo-on-dark.svg and devvm-logo-on-light.svg are the logo, drawn on a pixel grid as the favicon is, made from Departure Mono’s 11px glyphs. The prompt and devvm are at twice the glyphs’ size, the box is in 1-pixel lines, and the lock, a block with a keyhole under a 2-pixel arch, sits on the box’s bottom-right corner, with the box’s lines stopping short of it so that the arch stands clear. The version for light backgrounds is black, with its prompt in the dark lime, #408000 (--devvm-accent-dark), which is the lime’s hue at half its lightness, with a contrast of 4.9:1 on white, where the lime has 1.2:1. Each logo’s PNG is its SVG at four times its size on a transparent background, and devvm-icon-64.png to devvm-icon-1024.png are docs/static/favicon.svg at 4 to 64 times its size. After changing an SVG, render its PNGs again with headless Chrome’s --screenshot at the exact size, passing --default-background-color=00000000 for the logos’ transparency, and look at each one.
    • The palette comes from {{< palette >}}, which reads the colors that docs/data/palette.yaml lists, with each one’s name and use, and takes each hex from its variable in custom.css, so the page always shows the site’s colors. The build fails when a variable isn’t there. A new color goes in both files.
    • The fonts each come from {{< font-sample >}}, which shows a line of text in the font, with a link to the license the site serves for Departure Mono.
  • The favicon is the same mark drawn on a 16-pixel grid in docs/static/favicon.svg: the box, a lime prompt in its middle and a small lock centered on its bottom-right corner, overhanging it by 2 pixels each way, its shackle a closed arch, with the box’s lines stopping a pixel short of the lock, on black, with a pixel of black around the whole mark. Hextra links its icons by name, and the ones in docs/static/ take the place of its own: favicon-16x16.png and favicon-32x32.png, favicon.ico, which holds those two PNGs, apple-touch-icon.png, which is the grid at 160px with 10px of black around it, and the two android-chrome-*.png icons that site.webmanifest lists. Each PNG is the SVG at a whole multiple of its size, so its pixels stay sharp. The social card in docs/static/images/social-card.png is the 1200x630 image shown with a link to the site when someone shares it, and hugo.yaml’s params.images names it, so every page gets it as its og:image and twitter:image. It has devvm-logo-on-dark.svg at twice its size, the home page’s headline and tagline in Departure Mono, and the site’s colors. After changing the SVG or the card’s text, render the PNGs again with headless Chrome’s --screenshot at the image’s exact size, and look at each one.
  • The illustrations on the home page are SVGs in docs/assets/home/, and {{< art "name" >}} inlines one into the page, so that it gets the page’s fonts and colors. A feature card’s art parameter inlines one the same way, through docs/layouts/_partials/art.html. Each one is drawn on Departure Mono’s 11px cell, 7 pixels wide and 14 tall, so its text and shapes are sharpest at its own size or at twice its size. The art shortcode shows it at twice its size, 728px wide. A card shows it as wide as the card, at whatever scale that takes.
    • Each card’s drawing shows what its text says, and nothing more. The big cards’ drawings are 364 by 140 pixels, or 168 for isolated.svg: zero-config.svg shows a dev server’s forwarded port, devvm code and a commit authored by you, copies.svg the base image and its copies, isolated.svg the mount and the proxy, agents.svg the agents’ mounts, the history that stays and the token, and make-it-yours.svg the stack of base, your layer and the VM, with --from. The What’s supported cards’ drawings are 224 by 70 pixels: toolchains.svg, coding-agents.svg and layers.svg, which shows the image docker+nix as a stack and the built-in layers beside it. The Quick start cards’ drawings are terminals of 254 by 181 pixels, which a card shows at most twice their size: step-cd.svg types cd ~/some-project on your machine, step-pick.svg types devvm, shows a short version of the new VM’s summary and presses enter at Customize it? [y/N], and step-agent.svg types claude in the VM’s shell. Why use devvm?’s drawing, blast-radius.svg, is 364 by 210 pixels and shows the same four attacks as its text on two machines. Without devvm, they light up on your machine, and the project, ~/.aws, ~/.ssh and another project turn red one after another. With devvm, they light up in the VM, each one’s path toward your machine ends at the VM’s border with a cross, and your machine’s folders stay as they are.
    • Every text element holds one run of characters at its own x, because Hugo’s --minify removes the spaces between tspan elements. A gap of a cell is 7 pixels added to x.
    • Animations are CSS keyframes in custom.css, which classes in the SVGs turn on. pulse dims and brightens a lime arrow every 3 seconds, and pulse-2 and pulse-3 start a part of the arrow 0.3 and 0.6 seconds later, so the pulse runs along it. pulse-slow does the same every 4 seconds. lit lights a text’s fill and lit-chip a chip’s opacity for most of a 4-second loop, and lit-2, lit-3 and lit-4 start them 0.5, 1 and 1.5 seconds later, so they light one after another. blink turns a cursor on and off every 2 seconds. mover marks a block that travels a path, mover-token along the token’s line, under the proxy and down to the API in 3 seconds, and mover-egress under the proxy, down to the allowlist and back, then out to the internet in 4 seconds. A block is drawn before the shapes it passes under. attack-1 to attack-4, path-1 to path-4 and reach-1 to reach-4 tell blast-radius.svg’s story in an 8-second loop, and every part of the drawing stays in place all along, so the story only changes colours and draws lines. Each attack’s chip turns from gray to lime at 0.4, 1.6, 2.8 and 4 seconds. In the VM, its path then draws itself down to the VM’s border, and when it gets there, the cross at the border turns lime and, on the machine without devvm, the folder it reaches turns red. Everything holds from 5.2 seconds, and from 6.6 seconds the colours go back and the paths draw back up, so the drawing rests from 7.2 seconds until the story starts again. Each step has its own keyframes, so that all of them end together. The SVG draws each part in its end colour, and --rest in custom.css gives the colour it has before its step, so a drawing without animation shows the end of the story. The Quick start terminals each loop on their own. type marks a cover over a command, which shrinks from the right one cell at a time while caret moves the cursor along, both in --chars steps across --width, and caret-blink keeps the cursor solid while it types and makes it blink after. In step-pick.svg, pick-summary shows the summary once devvm is typed, pick-ask shows the question after it, and key lights enter as it’s pressed. Their styles in custom.css are each part’s end state too, and their keyframes start from the beginning. When the visitor’s system asks for reduced motion, prefers-reduced-motion turns every animation off and hides the blocks.
    • To see an animation’s state in a screenshot, pause the animations with a stylesheet of your own and set each class’s animation-delay to minus the time you want, because headless Chrome’s --virtual-time-budget barely moves CSS animations.
  • Icons are pixel icons in docs/assets/icons/, one SVG per icon. Most come from Dinkie Icons, which is MIT licensed, and the folder keeps its license and a README that names the version they were copied from. The penguin, prompt, warning and light bulb icons are drawn for devvm in the same style. {{< icon "rocket" >}} inlines one through docs/layouts/_partials/icon.html, so it takes the text’s color, and the build fails when no icon has that name. An icon is 0.75em square, which makes each of its pixels one screen pixel in 16px text, and it sits on the text’s baseline. Screen readers skip it, unless a second argument gives it a label, as in {{< icon "warning" "Warning" >}}, for an icon that says something the text doesn’t. To add a Dinkie icon, copy its path data from the same version of the @dinkie-icons/react npm package into a new file named after the icon in kebab case, with the same svg attributes as the others. A new icon of your own uses the same 12-pixel grid and 1-pixel lines, and the README lists it.
    • Each section’s icon is chosen once, in docs/data/sections.yaml, which maps a section’s folder under docs/ to an icon’s name, as in getting-started: rocket. A new section gets a line there. The section cards and the sidebar both read it, so they always show the same icon, and the build fails when the list names a section or an icon that doesn’t exist.
    • Section cards on the docs’ first page are Hextra’s {{< cards >}} holding a {{< section-card path="getting-started" >}} for each section, with its one-line description inside, closed with {{< /section-card >}}. The shortcode takes the section’s title and link from its page under docs/, and its icon from sections.yaml, and the build fails when there’s no such page. Hextra’s card takes an icon only from Hextra’s own set, so the shortcode puts ours in the card’s title, at 24px. It’s dimmed as Hextra dims its own cards’ icons, and it turns lime on hover.
    • The sidebar’s top-level items show their section’s icon at 12px, in the link’s color, so it’s gray, and lime for the section you’re in. Hextra’s sidebar has no icons, so docs/layouts/_partials/custom/head-end.html writes a CSS rule for each section when the site builds, keyed to the section page’s address, with the icon from docs/assets/icons/ as a mask. Nothing is generated into the repo, so there’s no step to rerun.
    • The OS tabs of install and os-tabs show the Apple icon before macOS and the penguin before Linux, which docs/layouts/_partials/os-tab-name.html adds to each tab’s name.
    • ✓ and ✗ go before a table cell’s word: {{< yes >}} yes puts a lime check before it, and {{< no >}} doesn't a gray cross. They go only in cells that are a plain yes or no, or what stands for one, such as the install script’s offers it, never in cells that are sentences, and the word stays, because screen readers skip the icon. Icons in tables are 12px, because a table’s text is smaller than 16px.
    • Buttons take an icon before their text: docs/layouts/_shortcodes/hextra/hero-button.html is Hextra’s hero-button with an icon parameter, as in {{< hextra/hero-button text="Install" link="docs/getting-started/install/" icon="download" >}}, drawn at 24px like the headings’ icons. The 404 page’s two buttons have their icons in its layout, a rocket for Getting started and the open book for the docs, as the home page’s Read the docs has it.
    • The navbar’s GitHub link uses Dinkie’s GitHub icon, which docs/data/icons.yaml adds to Hextra’s own icons as dinkie-github, and hugo.yaml’s menu names. Hextra’s icon partial reads only that data, so an icon for the navbar goes there as its SVG, not in docs/assets/icons/.
  • Heading icons: a heading gets an icon before its text with an icon attribute, as in ## Quick start {icon="rocket"}, which the home page’s section headings use. docs/layouts/_markup/render-heading.html takes the place of Hextra’s heading render hook and adds the icon after Hugo makes the heading’s anchor, so the anchor comes from the text alone, such as quick-start. On the home page, custom.css draws these icons in lime at 24px, so each icon pixel is two screen pixels, as each of Departure Mono’s pixels is at 22px.
  • Callouts are GitHub’s alerts: a blockquote whose first line is [!NOTE], [!TIP] or [!WARNING], which GitHub shows the same way. docs/layouts/_markup/render-blockquote-alert.html takes the place of Hextra’s alerts and draws each one on the surface color with a line down its left side, and a title with its icon: information-circled in blue for a note, the light bulb in the soft lime for a tip, and warning in lime for a warning. Any other type fails the build. Hextra’s own callout shortcode isn’t used. Use a callout only for a real warning, such as what the VM’s boundary rests on, or a tip that saves the reader trouble, such as keeping a VM on a strict tier, and move the sentence into it instead of repeating it.
  • For agents and other tools that read the site, hugo.yaml turns on three things that Hextra has. The site’s root has llms.txt, which lists every section and its pages. Each page has a Markdown copy beside its HTML, such as /docs/using/clone.md beside /docs/using/clone/, and a section’s copy is index.md in its folder, such as /docs/using/index.md. Each docs page has a Copy page menu beside its title, which copies the page’s Markdown or opens it. hugo.yaml lists every output for the home page, the sections and the pages, so the home page and the sections keep their RSS feeds, and the search index is built apart from these outputs.
    • llms.txt comes from docs/layouts/llms.txt, which takes the place of Hextra’s. It has the site’s title and description, then each section under its title, with the section’s own page first and its pages after it. Each one is a link to its Markdown copy, as the llms.txt convention prefers, which docs/layouts/_partials/llms-link.txt writes, and a line of plain text, which docs/layouts/_partials/llms-summary.txt writes. A page without a copy gets a link to its HTML page instead. The line is the page’s description from its front matter when it has one, and otherwise its first paragraph, cut at 200 characters. A description is also the page’s description for search engines and link previews, so give a page one when its first paragraph doesn’t say what the page covers, as Coding agents has. llms.txt also lists the CLI reference, with each command’s one-line summary.
    • The Markdown copies come from docs/layouts/page.markdown.md and section.markdown.md, which take the place of Hextra’s and both use docs/layouts/_partials/markdown-copy.md. A copy is the page’s title as a heading, then its Markdown with its shortcodes rendered. In a copy, a link to a section’s _index.md points at index.md, the section copy’s name, so the links work between the copies, and a link written in code stays as it is.
    • Each shortcode that docs pages use has a Markdown version beside its HTML one in docs/layouts/_shortcodes/, named after it with .markdown.md, which renders it in the copies. install writes each system’s name in bold, then its commands, which come from docs/layouts/_partials/install-tabs.html, the same partial its tabs use. os-tabs writes its tabs the same way, through docs/layouts/_partials/os-sections.md. section-card makes a list item that links the section’s copy, and Hextra’s cards keeps only what’s inside it. yes and no become ✓ and ✗. A shortcode without a Markdown version puts its HTML into the copy.
    • The home page and the branding page are mostly shortcodes, and neither layout shows the menu. hugo.yaml gives the home page no Markdown copy, and its front matter sets contextMenu: false. The branding page’s front matter sets outputs: [html], contextMenu: false and llms: false, so it has no copy, no menu and no line in llms.txt. Any other page can set llms: false to stay out of llms.txt, or contextMenu: false to have no menu.
    • After you add a page or a shortcode, build the site and read the page’s line in llms.txt and its Markdown copy. The line should say what the page covers, and the copy should have no shortcode left in it, such as {{< os-tabs >}}, and no HTML from one. A shortcode that docs pages use gets its Markdown version. To try the menu, serve the site with mise run docs and open a docs page. custom.css gives the menu the site’s border and secondary text, lime on hover, and its list the surface color.
  • The 404 page is docs/layouts/404.html, which takes the place of Hextra’s and uses the site’s navbar and footer. It shows a VM’s terminal answering cd ./this-page with “no such page”, drawn like the home page’s art in docs/assets/home/not-found.svg with the blink cursor, then Hextra’s search box and links to Getting started and the docs. Build the site and open /404.html to see it.

mise run lint:docs, which mise run lint includes, checks docs/ with:

LinterChecksConfig
markdownlint-cli2Markdown.markdownlint-cli2.yaml
Valethe style rules.vale.ini, .vale/styles/devvm/
lychee (--offline)links to pages and files

It also builds the site with Hugo’s --panicOnWarning, into a temporary folder it removes, so any warning fails it. That includes a deprecation, such as a template that reads site.Data where Hugo wants hugo.Data, so a Hugo upgrade that deprecates something the templates use fails here before the feature is removed.

The CLI reference

The CLI reference in docs/content/docs/cli/ is generated from devvm’s commands with Cobra’s docs generator, and checked in. Don’t edit it: change the command’s Use, Short, Long, Example or flags, or its docs page in internal/msg/docs.go, then run:

mise run cli-docs

It runs internal/cli/docgen, which replaces every page in that folder: _index.md, listing the commands, and a page per command named after its path (devvm_build.md), with title and weight front matter and links to the other pages as files. Each page has the command’s examples, and a Docs section before See also that links to the command’s docs page on the site. Hidden commands (_msg, _qemu) and Cobra’s help command are left out. The pages carry no dates, so the same commands give the same files.

A command’s help

Every command’s help, which its reference page also shows, has the same shape. Short is an action in sentence case without a full stop, such as List the project VMs. Long is one to three short sentences on what the command does, and the rules and edge cases go on its docs page. Example comes from examples in internal/cli/help.go, which takes each command line with a short comment and lines the comments up, the most common invocation first. A flag’s description is short, and its long explanation goes on the page.

addDocs in internal/cli/help.go gives the root Cobra’s usage template with a Docs section at the end, which shows the address of the command’s page from commandDocs in internal/msg/docs.go, one user page per command path, such as devvm network allow. The root’s help also ends with the issue tracker, msg.IssuesURL. TestDocLinks checks that each page and heading in commandDocs exists and is a user page. commandGroups in the same file lists the root’s sections, in order, with the commands in each, and addGroups adds each group that has a command and sets its commands’ GroupID, so a group whose commands don’t exist yet, such as Links between VMs, stays out of the help. A group can also list a subcommand by its path, such as layer add in Change your VM, when it belongs in another group than its parent. addGroups then adds that group to the parent too, so the parent’s own help groups its subcommands the same way, and the usage template lists the subcommand by both names in the root’s group, after the group’s own commands. Cobra lists a group’s commands in alphabetical order, and help goes in Cobra’s own Additional Commands.

The tests fail when the checked-in pages are stale: TestReferenceIsCurrent in internal/cli/docgen generates the reference into a temporary folder and compares it with docs/content/docs/cli/. mise run docs regenerates it before serving the site.

Building the site

mise run docs serves the site at http://localhost:1313/ and reloads on every change. mise exec -- hugo -s docs --minify builds it into docs/public/, as the Pages workflow does on every push to main.

Last updated on