Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
0932675
test(project): pin stoat.toml load and name resolution
NovusEdge Sep 5, 2026
08db9c4
test(project): pin shares resolution and secrets reading
NovusEdge Sep 5, 2026
e25b107
test(config): pin vm.toml project and shares plumbing
NovusEdge Sep 5, 2026
7436977
feat(project): load and resolve stoat.toml
NovusEdge Sep 5, 2026
5c7d632
feat(project): resolve shares and read secrets
NovusEdge Sep 5, 2026
8a7ce73
feat(config): record a vm's project and its 9p shares
NovusEdge Sep 5, 2026
eb45f11
test(recipes): use a schema-valid table in stoat.toml fixtures
NovusEdge Sep 5, 2026
cfba440
test(tomlx): pin reject mode over dynamic and typed maps
NovusEdge Sep 5, 2026
5af4d85
test(core): pin a declaration's spec against stoat new's defaults
NovusEdge Sep 5, 2026
12f826f
test(core): pin drift and restart flags against vm.toml
NovusEdge Sep 5, 2026
5e4c867
test(core): pin reconcile create-or-update semantics
NovusEdge Sep 5, 2026
63b8c3d
test(cli): pin bare vm resolution at project scope
NovusEdge Sep 5, 2026
7e14d0d
test(cli): pin stoat init's output and refusal to overwrite
NovusEdge Sep 5, 2026
09ddbc9
feat(core): build a Spec from a stoat.toml declaration
NovusEdge Sep 5, 2026
8cd2b32
feat(core): diff a declaration against vm.toml
NovusEdge Sep 5, 2026
3c44dff
feat(core): reconcile a declared vm
NovusEdge Sep 5, 2026
344748f
feat(cli): resolve a bare vm argument at project scope
NovusEdge Sep 5, 2026
4812d2e
feat(cli): add stoat init
NovusEdge Sep 5, 2026
4d46600
fix(core): diff a declared image against its resolved spelling
NovusEdge Sep 5, 2026
318d3d1
fix(core): diff a disk change even on one unset side
NovusEdge Sep 5, 2026
e494a86
test(core): seed STOAT_HOME and recipes in projectDir
NovusEdge Sep 5, 2026
ec6a765
test(core): fix image-change assertion to unwrap the error
NovusEdge Sep 5, 2026
ff4f0f9
test(core): pin disk immutability and omitted defaults
NovusEdge Sep 5, 2026
357b8ae
test(cli): move missing-vm-name expectations to Main
NovusEdge Sep 5, 2026
b1b1b66
fix(core): default omitted declared disk before diffing
NovusEdge Sep 5, 2026
7449011
docs(core): state the image-comparison fact, not the history
NovusEdge Sep 5, 2026
e852f67
test(cli): pin up reconcile and project fan-out
NovusEdge Sep 5, 2026
e62d671
test(cli): pin rm and apply project fan-out
NovusEdge Sep 5, 2026
ea2adfb
test(cli): pin stoat status
NovusEdge Sep 5, 2026
40fc9bf
test(cli): pin ls project column and --project
NovusEdge Sep 5, 2026
d14577e
test(cli): pin new refusal at project scope
NovusEdge Sep 5, 2026
52054a1
feat(cli): reconcile and fan out stoat up
NovusEdge Sep 5, 2026
8d52bca
feat(cli): fan out down, apply, wait and rm
NovusEdge Sep 5, 2026
61cbdc4
feat(cli): add stoat status
NovusEdge Sep 5, 2026
8d009bd
feat(cli): add the project column and ls --project
NovusEdge Sep 5, 2026
7312d3a
feat(cli): refuse stoat new inside a project
NovusEdge Sep 5, 2026
21faf54
test(cli): pin status drift on the resolved image spelling
NovusEdge Sep 5, 2026
e409af5
test(wire): pin VM golden's project and key fields
NovusEdge Sep 5, 2026
70c7592
test(cli): pin the ls PROJECT column and SSH width
NovusEdge Sep 5, 2026
86a78b3
fix(cli): let fanOut drive reconcile for stoat up
NovusEdge Sep 5, 2026
4ae1aa4
fix(cli): surface the immutable-diff error in status
NovusEdge Sep 5, 2026
1f1edf7
fix(cli): render unknown health as a dash in status
NovusEdge Sep 5, 2026
23344d0
test(cli): explain the leak slice's v.Dir exception
NovusEdge Sep 5, 2026
fccf4c9
test(cli): make status JSON drift fixture exercise real drift
NovusEdge Sep 5, 2026
3727709
fix(cli): reconcile every declaration before any up start
NovusEdge Sep 5, 2026
f437683
docs(cli): note ls --project also uses Args.Clear
NovusEdge Sep 5, 2026
46dfa83
test(mcp): pin project tools
NovusEdge Sep 5, 2026
ae845bb
feat(mcp): add project scope and project_status
NovusEdge Sep 5, 2026
6a2c0ef
docs: document the project file
NovusEdge Sep 5, 2026
ac9480f
fix(core): unset params a declaration drops on reconcile
NovusEdge Sep 5, 2026
98f8a76
fix(core): reconcile a secrets-only change with no other drift
NovusEdge Sep 5, 2026
bf3600d
fix(cli): init template uses a real catalog image id
NovusEdge Sep 5, 2026
d985d5e
docs: use a real catalog image id in the project sample
NovusEdge Sep 5, 2026
e7a675e
test(cli): pin the docs sample against project's structs
NovusEdge Sep 5, 2026
79fbfd1
fix(project): compare the lexical share check against p.Dir
NovusEdge Sep 5, 2026
12bfe92
test(core): pin reconcile over dropped params and recipes
NovusEdge Sep 5, 2026
01cf84f
fix(core): drop a recipe's params and secrets with it
NovusEdge Sep 5, 2026
3c50519
docs(mcp): note project_apply's access level source
NovusEdge Sep 5, 2026
bb19b90
test(core): pin secrets on a dropped recipe and a no-op reconcile
NovusEdge Sep 5, 2026
e1fdf97
fix(core): filter reconcile secrets to declared recipes
NovusEdge Sep 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ just setup # builds, installs to ~/.local/bin, reports missing host deps
just dev # runs the TUI against a scratch STOAT_HOME
```

`just setup` also installs the git hooks.
`just setup` also installs the git hooks. A repository with a `stoat.toml`
declares its own VMs; run `stoat up` in it to build them.

Go 1.26 (pinned in `go.mod`), `just`, and for anything that boots a VM:
KVM, `qemu-system-x86_64`, `qemu-img`, `ssh`. `stoat doctor` lists what is
Expand Down
2 changes: 2 additions & 0 deletions docs/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,11 @@
* [CLI](reference/cli.md)
* [JSON output](reference/json.md)
* [Guest definitions](reference/guest.md)
* [The project file](reference/project-file.md)
* [Recipe sample](reference/samples/recipe.toml)
* [VM sample](reference/samples/vm.toml)
* [Guest sample](reference/samples/guest.toml)
* [Project sample](reference/samples/stoat.toml)

## Recipes

Expand Down
58 changes: 56 additions & 2 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,20 @@ usage: stoat <command> [flags]
- **`-h`, `--help`** prints the command's usage and flags and exits 0. `stoat help` (the subcommand) prints the same top-level text `stoat --help` does.
- **`-v`, `--version`** prints `stoat <version>` and exits 0. It is matched as the **first argument only**, before any parsing (`cmd/stoat/main.go`), which has two consequences worth knowing: `stoat -v --json` prints plain text and ignores `--json`, and `stoat --json --version` is a usage error because `-v` is no longer first. **Scripts and machine consumers should use the `version` subcommand**, which behaves normally under `--json`.

## Project scope

A `stoat.toml` in the current directory activates project scope. `up`,
`down`, `apply`, `wait` and `rm` act on every declared VM, in declaration
order, when given no VM argument. A bare VM argument resolves against
`stoat.toml` first, then against a global VM name. See
[project-file.md](project-file.md).

## Subcommands

| Command | Synopsis | Exit codes |
|---|---|---|
| [`init`](#stoat-init---name-n) | Write a `stoat.toml` for this directory | 0, 1 |
| [`status`](#stoat-status) | One line per declared VM: state, health, drift | 0, 1 |
| [`ls`](#stoat-ls) | List VMs, one line per VM | 0, 1 |
| [`get`](#stoat-get-name) | Show one VM's details | 0, 1 |
| [`create`](#stoat-create-name---imageimage) | Create a VM without starting it | 0, 1, 2 |
Expand Down Expand Up @@ -67,7 +77,39 @@ oldvm - broken - - - unexpected token near line 4

The `STATE` column is colored (green `running`, red `broken`) when [color is enabled](#scripting). `-q`/`--quiet` is accepted but has no effect on `ls`'s output.

**Exit codes:** 0 on success; 1 if the data root can't be read.
`--project` filters the list to VMs the `stoat.toml` in the current directory declares. It refuses outside a project.

**Exit codes:** 0 on success; 1 if the data root can't be read, or `--project` is given outside a project.

## `stoat init [--name n]`

Writes `stoat.toml` for this directory: one `[vms.dev]` declaration, annotated with every field's type and default. Refuses if `stoat.toml` already exists; there is no safe automatic merge into a file you already wrote.

```
$ stoat init
wrote stoat.toml
added .stoat/ to .gitignore
edit it, then run: stoat up
```

`--name` sets `project.name`, the prefix for a VM's global name; it defaults to the current directory's name, lowercased. In a git checkout, `init` also appends `.stoat/` to `.gitignore` if it is not already there.

**Exit codes:** 0 on success; 1 if `stoat.toml` already exists or the file can't be written.

## `stoat status`

Prints one line per VM `stoat.toml` declares: declaration key, global name, state, health, and every field where the declaration and the VM disagree.

```
$ stoat status
KEY NAME STATE HEALTH DRIFT
dev myrepo-dev running ok cpus 2 → 4 (restart)
ci myrepo-ci missing - -
```

A VM `stoat.toml` declares but that does not exist yet shows state `missing`. An immutable-field mismatch (`image` or `disk`) prints in place of the drift column, naming `stoat rm <key>` as the fix.

**Exit codes:** 0 on success; 1 outside a project, or if a VM's status can't be read.

## `stoat get <name>`

Expand Down Expand Up @@ -107,7 +149,9 @@ start it with: stoat up work

Flags: `--image` (required; catalog id or a path to your own image), `--os`, `--backend` (override what a bring-your-own image's filename would otherwise infer), `--mode` (`live` or `disk`; only meaningful for the alpine iso, every other image has one mode), `--ram` (MB), `--cpus`, `--disk` (absolute size, e.g. `8G`), `--share` (host directory to expose), `--console-password` (`random` generates one), `--recipes` (comma-separated or repeated), `--set recipe.param=value` (set a non-secret recipe parameter), `--secret recipe.param` (read a secret from the environment or prompt), `--allow-exec` (default true; `--allow-exec=false` opts this VM out of `exec`/`copy_to`/`copy_from`, enforced by the MCP server rather than stoat itself).

**Exit codes:** 0 on success; 1 if creation fails (e.g. the image isn't downloaded yet: run `stoat pull` or download it from the TUI's image picker first); 2 if `--image` is missing.
`create` (alias `new`) refuses at project scope: `a stoat.toml is present; declare the VM there and run stoat up, or pass --global`. `--global` creates the VM outside the project.

**Exit codes:** 0 on success; 1 if creation fails (e.g. the image isn't downloaded yet: run `stoat pull` or download it from the TUI's image picker first) or a `stoat.toml` refuses it without `--global`; 2 if `--image` is missing.

## `stoat update <name>`

Expand Down Expand Up @@ -153,6 +197,8 @@ display: no qemu window; the screen is on /home/user/.stoat/work/vnc.sock

`-q`/`--quiet`/`--no-interactive` suppresses the `starting <name>...` line; the final result line always prints.

At project scope, `<name>` is optional. A named VM is reconciled against its `stoat.toml` declaration before it starts, the same change `stoat update` would make. With no name, every declared VM is reconciled, then started, in declaration order; a VM that fails to reconcile or start stops the run, and every later VM is reported skipped.

### Where the screen is

A VM gets a real QEMU window by default, on a host with a graphical session. Set `display = "vnc"` in `vm.toml` (or cycle it with the `d` key in the TUI) to keep a VM headless instead. QEMU then starts with `-display none` and a VNC server bound to a unix socket in the VM's directory; `-display none` cannot be undone on a running QEMU, so binding VNC at launch keeps a misbehaving guest recoverable.
Expand Down Expand Up @@ -201,6 +247,8 @@ work stopped

`-q` suppresses the `stopping <name>...` line only.

At project scope, `<name>` is optional: with no name, every declared VM is stopped in declaration order, and a failure stops the run and reports every later VM as skipped.

**Exit codes:** 0 on success; 1 if the VM can't be loaded, is broken, isn't running, or fails to stop.

## `stoat wait <name>`
Expand All @@ -216,6 +264,8 @@ work reached reachable (1240ms)

A request that cannot ever be satisfied fails immediately rather than waiting out the timeout: `--until applied` on a VM with no recipes configured, or `--until reachable` on a VM that isn't running.

At project scope, `<name>` is optional: with no name, `wait` blocks on every declared VM in turn, in declaration order, and a VM that does not reach the state stops the run.

**Exit codes:** 0 if the state was reached; 1 if the timeout expires or the state can't be reached at all; 2 if `--timeout` is zero or negative.

## `stoat rm <name> [-y]`
Expand All @@ -230,6 +280,8 @@ scratch deleted

Without `-y`, confirmation is required: interactively it prompts on stdout and reads a line from stdin (anything other than a `y`/`Y` aborts); in `-q`/`--quiet`/`--no-interactive` mode there is no prompt to answer, so it refuses outright instead of guessing. Under `--json` the same rule applies for the same reason: nothing reads stdin, so `-y` is required or the command fails with `confirmation_required`. `-y` skips the prompt in every mode.

At project scope, `<name>` is optional: with no name, every declared VM is asked for (or refused without `-y`) and deleted in declaration order.

**Exit codes:** 0 if deleted; 1 if the VM can't be loaded, is running, the confirmation is declined or aborted, `-y` was needed but not given, or the delete itself fails. Note that declining the confirmation prompt is exit 1, not 0: a script checking `$?` sees "delete didn't happen" as a failure either way, whether the VM was running or the user just said no.

## `stoat clone <source> <name>`
Expand Down Expand Up @@ -421,6 +473,8 @@ work: recipes applied

`--only` restricts the run to a subset of the VM's own recipe list (comma-separated or repeated), instead of applying all of them.

At project scope, `<name>` is optional: with no name, every declared VM's recipes run in turn, in declaration order, and a failure stops the run.

**Exit codes:** 0 on success; 1 if the VM can't be loaded, the run fails, or (for a cloud-mode VM) recipes were already applied at boot rather than by this command.

`provision` is a hidden alias of `apply`: `stoat provision work` behaves exactly like `stoat apply work`, and reports `"cmd":"apply"` under `--json`.
Expand Down
52 changes: 49 additions & 3 deletions docs/reference/json.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,8 @@ VM {"name":"work","os":"alpine","mode":"cloud","backend":"cloudinit",
"ssh_port":2200,"ssh_user":"stoat","installed":false,
"forwards":[{"host_port":8080,"guest_port":80}],
"allow_exec":true,"agent_access":"manage","display":"vnc",
"error":"only on a broken VM"}
"error":"only on a broken VM",
"project":"/home/u/myrepo","key":"dev","project_missing":false}

VMStatus {"name":"work",...VM fields...,"health":"ok","recipes_detail":[
{"name":"xfce","applied":true,"version":"1.2","at":"...",
Expand Down Expand Up @@ -261,11 +262,41 @@ Guest {"name":"fedora","init":"systemd","shell":"/bin/bash",
MCPClient {"client":"cursor","path":"/home/u/.cursor/mcp.json",
"installed":true,"command":"/home/u/.local/bin/stoat",
"current":true}

InitResult {"path":"/home/u/myrepo/stoat.toml","project":"myrepo",
"gitignore_updated":true}

Drift {"field":"cpus","from":"2","to":"4","needs_restart":true}

ProjectStatusVM {"key":"dev","name":"myrepo-dev","state":"running",
"health":"ok","drift":[Drift,...],
"error":"only on an immutable-field mismatch"}
ProjectStatus {"project":"myrepo","dir":"/home/u/myrepo",
"vms":[ProjectStatusVM,...]}

ProjectRunVM {"key":"dev","name":"myrepo-dev","status":"ok",
"error":"only when status is error"}
ProjectRun {"project":"myrepo","vms":[ProjectRunVM,...]}
```

`MCPClient.current` is false when the client's entry names a different
binary than the running one, which is the stale entry `mcp doctor` reports.

`VM.project` is the absolute directory of the `stoat.toml` that declared this
VM, and `VM.key` is the declaration key, both empty for a VM `stoat create`
made outside a project. `VM.project_missing` is true when that directory no
longer exists; the VM still lists and runs.

`ProjectStatusVM.state` is `missing` for a declared VM that does not exist
yet, otherwise a `VM.state` value. `drift` is empty when `error` is set: an
immutable-field mismatch (`image` or `disk`) stops the comparison before the
rest of the fields are checked.

`ProjectRunVM.status` is `ok`, `error` or `skipped`. `skipped` means a VM
earlier in declaration order failed and this one was never attempted;
`ProjectRun.vms` always lists every declared VM, in declaration order, so a
caller can see what did not run as plainly as what did.

`RecipeEntry` has `name`, `description`, `scope`, `source`, `ref`, and
`commit`. `scope` is one of `bundled`, `local`, `global`, or `project`; only
`global` and `project` entries carry `source`, `ref`, and the seven-character
Expand Down Expand Up @@ -369,12 +400,15 @@ so a leak fails the build rather than shipping.

| `cmd` | `data` |
|---|---|
| `init` | `InitResult` |
| `status` | `ProjectStatus` |
| `ls` | `{"vms":[VM,...]}` |
| `get` | `{"vm":VMStatus}` |
| `create` | `{"vm":VM}` |
| `update` | `{"vm":VM,"changed":["ram"],"applies_at":"now"}` |
| `up` | `{"vm":VM}` (re-read after start, so `state` is authoritative) |
| `down` | `{"vm":VM}` |
| `up` (one VM) | `{"vm":VM}` (re-read after start, so `state` is authoritative) |
| `up`, `down`, `apply`, `wait`, `rm` (no VM, project scope) | `ProjectRun` |
| `down` (one VM) | `{"vm":VM}` |
| `wait` | `{"vm":"work","until":"reachable","reached":true,"waited_ms":4210}` |
| `rm` | `{"name":"scratch","deleted":true}` |
| `clone` | `{"vm":VM,"source":"work","forwards_copied":false}` |
Expand Down Expand Up @@ -433,6 +467,11 @@ scope (`bundled`, `local`, `global`, or `project`); only remote `global` and
`project` rows carry source, ref, and the seven-character commit prefix. The
`roots` list gives the search order and the scope label for each root.

`up`, `down`, `apply`, `wait` and `rm` report `ProjectRun` only when they run
at project scope with no VM argument; given a VM, each keeps its one-VM shape
from the row above. `stoat.toml`'s `[project]` fan-out is the only thing that
changes `data`'s shape; the command's own `cmd` name does not.

Fields worth knowing about:

- **`update.changed`** names the fields that actually changed, in wire naming
Expand Down Expand Up @@ -552,3 +591,10 @@ The same version also adds `agent_access` to `VM`, additive alongside
`stoat mcp` in this binary. Neither change removes or repurposes a field, so
neither bumped the version on its own; they are noted here only because they
landed in the same branch as the `recipe list` change.

The project-file plan adds `init` and `status` commands, `--project` on `ls`,
and a no-argument fan-out on `up`, `down`, `apply`, `wait` and `rm` at project
scope, plus `project`, `key` and `project_missing` on `VM`, and five new MCP
tools (`project_status`, `project_up`, `project_down`, `project_apply`,
`project_wait`) alongside `start`, `stop`, `apply_recipes` and `wait`, which
keep their existing inputs and outputs. All additions; the contract stays 3.
107 changes: 107 additions & 0 deletions docs/reference/project-file.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# The project file

`stoat.toml` in a repository declares that repository's VMs. `git clone`
the repository, then run `stoat up`, to build them.

## Scope

`stoat.toml` in the current directory activates project scope. There is
no walk-up: a parent directory's `stoat.toml` has no effect.

## The file

```toml
# stoat.toml declares this repository's VMs. Commit it, and stoat.lock with it.
# Every field carries its type, its default, and who writes it.
schema = 1 # int, required. The file format version.

[project]
name = "myrepo" # string, default: the directory name.
# The prefix for a VM's global name.

# Remote recipes. A value is a ref string, or a table naming a source.
# stoat recipe lock pins each one to a commit in stoat.lock.
[recipes]
tailscale = "v1.2"

[vms.dev] # the key "dev" is the name you type
image = "ubuntu-24.04" # string, required. A catalog id, or a path
# to your own image, relative to this file.
name = "shared-dev" # string, default "<project>-<key>". The VM's
# global name under ~/.stoat.
cpus = 4 # int, default 4
ram = 4096 # int, MB, default 4096
disk = "20G" # string, default 8G. Disk-mode images only.
recipes = ["docker", "tailscale"] # applied in dependency order
shares = [".", "src"] # directories from this project, mounted under
# /work. "." mounts at /work, "src" at
# /work/src. Every entry stays inside the
# project.
agent_access = "manage" # none | observe | manage | exec, default manage

[vms.dev.params.docker] # non-secret recipe params
user = "dev" # secrets go in .stoat/secrets.toml, 0600

[vms.docs]
image = "alpine-virt"
shares = ["docs"]
```

See the [sample file](samples/stoat.toml) on its own.

## Names

A VM's global name is its declaration's `name` field, if set, otherwise
`<project>-<key>`. `project.name` defaults to the repository directory
name.

A bare command argument resolves to the declaration key first, then to a
global name. `stoat ssh dev` reaches `shared-dev`.

Two declarations that resolve to one global name are an error.

## Shares

Each `shares` entry mounts read-write under `/work` in the guest. `.`
mounts at `/work`. Every other entry mounts at `/work/<basename>`.

Every entry must resolve inside the project directory. A relative path
that escapes it, directly or through a symlink, is refused.

## Reconcile

`stoat up` reconciles a declared VM before it starts it:

- A missing VM is created from its declaration.
- An existing VM takes `cpus`, `ram`, `recipes`, `params`, `shares` and
`agent_access` from the declaration, through the same path as `stoat
update`. `cpus`, `ram` and `shares` take effect at the VM's next `down`
and `up`.
- `image` and `disk` are immutable. A declaration that changes either is
an error naming `stoat rm <key>` as the fix.

## Secrets

Secrets live in `.stoat/secrets.toml`, mode 0600, keyed
`<key>.<recipe>.<param>`. `stoat init` adds `.stoat/` to `.gitignore` in
a git checkout. Every reader renders a secret as `<set>` or `<unset>`,
never as its value.

## Commands

| Command | Effect |
|---|---|
| `stoat init [--name n]` | writes `stoat.toml` from the annotated sample, with one VM |
| `stoat status` | one line per declared VM: global name, state, health, drift |
| `stoat ls --project` | filters the VM list to the current project |
| `stoat up`, `down`, `apply`, `wait`, `rm` with no VM argument | act on every declared VM, in declaration order |

## Errors

| Condition | Message |
|---|---|
| duplicate global name | `stoat.toml: vms.dev and vms.ci both resolve to "myrepo-dev"` |
| share outside project | `stoat.toml: vms.dev.shares: "../secrets" is outside the project` |
| immutable change | `dev: image changed (ubuntu-24 → debian-12); run stoat rm dev and stoat up` |
| new at project scope | `a stoat.toml is present; declare the VM there and run stoat up, or pass --global` |
| unknown key in a bare argument | `no VM "db" in stoat.toml or ~/.stoat/vms` |
Loading