Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
ce32fa0
test(config): pin vm encoder contract
NovusEdge Sep 5, 2026
87d697a
test(recipes): pin schema 3 manifest contract
NovusEdge Sep 5, 2026
26b7ef3
test(guest): pin command verb preludes
NovusEdge Sep 5, 2026
1cb8c0d
test(config): pin recipe state storage
NovusEdge Sep 5, 2026
d5b7168
test(recipes): pin manifest ordering
NovusEdge Sep 5, 2026
a4a0ff8
test(config): pin secrets file contract
NovusEdge Sep 5, 2026
abcc817
test(guest): forward python command args
NovusEdge Sep 5, 2026
91d0333
refactor(config): encode vm.toml with go-toml/v2
NovusEdge Sep 5, 2026
d36f663
feat(recipes): parse schema 3 params and health
NovusEdge Sep 5, 2026
9139e9a
feat(guest): add download and useradd verbs
NovusEdge Sep 5, 2026
568c43b
fix(config): mark toml encoder dependency direct
NovusEdge Sep 5, 2026
8aa8f8b
feat(config): store recipe params and applied state
NovusEdge Sep 5, 2026
5fc1398
feat(config): store recipe secrets securely
NovusEdge Sep 5, 2026
20d811c
test(recipes): pin v3 boundary validation
NovusEdge Sep 5, 2026
e42fe39
fix(recipes): validate schema and health bounds
NovusEdge Sep 5, 2026
3a3c53f
test(recipes): pin v3 chunk two contract
NovusEdge Sep 5, 2026
aa757c3
feat(recipes): resolve params and hash them
NovusEdge Sep 5, 2026
f9019d5
test(cli): isolate recipe parameter fixture
NovusEdge Sep 5, 2026
1cf0982
feat(cli): persist recipe parameter edits
NovusEdge Sep 5, 2026
bb12a79
feat(sshx): deliver recipe params and outputs
NovusEdge Sep 5, 2026
a4e46c1
feat(cloud): deliver recipe params and secrets
NovusEdge Sep 5, 2026
083d0a5
feat(core): run recipe health checks after apply
NovusEdge Sep 5, 2026
aa8007a
test(recipe): close chunk two review boundaries
NovusEdge Sep 5, 2026
1b44f4a
test(cloudinit): inherit xorriso umask
NovusEdge Sep 5, 2026
7967796
fix(recipe): close chunk two contract gaps
NovusEdge Sep 5, 2026
1d4bf76
test(recipe): cover contract v3 callers
NovusEdge Sep 5, 2026
cdb17c3
test(tui): exercise recipe parameter lifecycle
NovusEdge Sep 5, 2026
6ee9402
feat(cli): add wait healthy mode
NovusEdge Sep 5, 2026
15be713
test(cli): assert decoded secret redaction
NovusEdge Sep 5, 2026
97e3e48
feat(cli): expose recipe show contract
NovusEdge Sep 5, 2026
87b538a
feat(status): expose redacted recipe state
NovusEdge Sep 5, 2026
982982b
test(cli): cover apply stream redaction
NovusEdge Sep 5, 2026
db31fd5
test(cloudinit): cover namespace collision
NovusEdge Sep 5, 2026
b733f05
test(contract): correct defaults and e2e redaction
NovusEdge Sep 5, 2026
48acc70
fix(cli): close apply log redaction gaps
NovusEdge Sep 5, 2026
91ae9ff
feat(tui): add recipe parameter form
NovusEdge Sep 5, 2026
8dad8dd
feat(recipes): ship schema samples and bundled contracts
NovusEdge Sep 5, 2026
91e0460
test(contract): close chunk three review gaps
NovusEdge Sep 5, 2026
6f3bcdd
test(cli): correct source-boundary redaction case
NovusEdge Sep 5, 2026
c698919
fix(contract): close reviewed chunk gaps
NovusEdge Sep 5, 2026
8be0fea
test(contract): finish reviewed caller gaps
NovusEdge Sep 5, 2026
355ab9e
fix(contract): close health and cloudinit review gaps
NovusEdge Sep 5, 2026
9418a02
test(cloudinit): cover multiline Debian prelude
NovusEdge Sep 5, 2026
a2f083d
test(core): retain single health timeout detail
NovusEdge Sep 5, 2026
53a7df5
test(cloudinit): parse setup command YAML
NovusEdge Sep 5, 2026
a02cd93
fix(contract): preserve live prelude and health detail
NovusEdge Sep 5, 2026
0e05cb7
test(sshx): model ssh argv joining in the fake
NovusEdge Sep 5, 2026
26c52dc
fix(sshx): quote the output read-back for ssh
NovusEdge Sep 5, 2026
305a1dd
fix(recipes): restore the X server on Alpine xfce
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: 3 additions & 0 deletions docs/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@
* [CLI](reference/cli.md)
* [JSON output](reference/json.md)
* [Guest definitions](reference/guest.md)
* [Recipe sample](reference/samples/recipe.toml)
* [VM sample](reference/samples/vm.toml)
* [Guest sample](reference/samples/guest.toml)

## Recipes

Expand Down
5 changes: 5 additions & 0 deletions docs/concepts/modes-and-backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,11 @@ recipes already ran at first boot, and that changing them means recreating
the VM (the seed isn't rebuilt on later starts, since by then the overlay
holds real guest state you don't want thrown away).

The host seed artifacts remain in the VM directory for inspection and later
diagnosis. Stoat creates seed directories with mode `0700` and seed files with
mode `0600` before writing their bytes; it does not promise to delete or
detach those artifacts after boot.

## Comparison

| | `live` | `disk` | `cloud` |
Expand Down
47 changes: 40 additions & 7 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ usage: stoat <command> [flags]
| [`recipes`](#stoat-recipes) | List recipes, optionally only applicable ones | 0, 1 |
| [`check-recipes`](#stoat-check-recipes-names---osos) | Report why a recipe would not apply | 0, 1, 2 |
| [`recipe list`](#stoat-recipe-list) | List installed recipes and where they live | 0, 1 |
| [`recipe show`](#stoat-recipe-show-name) | Show one recipe's parameter and output contract | 0, 1 |
| [`recipe new`](#stoat-recipe-new-name) | Scaffold a recipe in the recipes directory | 0, 1 |
| [`guest ls`](#stoat-guest-ls) | List loaded guest OS definitions | 0 |
| [`guest show`](#stoat-guest-show-name) | Print one guest's merged definition | 0, 1 |
Expand Down Expand Up @@ -104,7 +105,7 @@ created work (alpine, live, ssh port 2222)
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), `--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).
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.

Expand All @@ -126,7 +127,7 @@ updated work: [share]

`work`'s share is now unset. Compare to `stoat update work` with no flags at all, which is a usage error (there is nothing to change), not a no-op.

Flags: `--ram`, `--cpus`, `--ssh-port`, `--disk` (grow-only), `--share` (empty clears it), `--recipes` (empty clears it; replaces the whole list, it does not add to it).
Flags: `--ram`, `--cpus`, `--ssh-port`, `--disk` (grow-only), `--share` (empty clears it), `--recipes` (empty clears it; replaces the whole list, it does not add to it), `--set recipe.param=value`, `--unset recipe.param` (remove a non-secret override and restore its manifest default), and `--secret recipe.param` (set a secret without writing its value to `vm.toml`). Use `recipe show` to inspect declared types, defaults, enum values, and required parameters.

Most fields are read by qemu only at start, so a change to a *running* VM is saved to `vm.toml` but doesn't take effect until the VM is next started; `update` says so:

Expand Down Expand Up @@ -211,7 +212,7 @@ $ stoat wait work --until reachable
work reached reachable (1240ms)
```

`--until` is one of `reachable` (sshd answering on the VM's forwarded port, default), `applied` (the most recent recipe run finished), or `stopped` (qemu no longer running). `--timeout` (default `2m`) is a Go duration (`30s`, `5m`).
`--until` is one of `reachable` (sshd answering on the VM's forwarded port, default), `applied` (the most recent recipe run finished), or `stopped` (qemu no longer running). `--healthy` waits for reachability and then every applied recipe's declared health check; it cannot be combined with an explicit `--until`. `--timeout` (default `2m`) is a Go duration (`30s`, `5m`).

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.

Expand Down Expand Up @@ -471,17 +472,49 @@ $ stoat recipe list

**Exit codes:** 0 on success; 1 if the directory can't be read.

## `stoat recipe show <name>`

Prints the recipe's schema, sorted named parameters and outputs, and its
declared health check without requiring a VM:

```
$ stoat recipe show docker
docker: Docker engine and the compose plugin
schema: 3
runtime: sh

params:
user string, default dev account to add to the docker group

outputs:
socket path of the docker socket

health: docker info (timeout 30s)
```

Under `--json`, the result is `data.recipe` with the `RecipeSchema` documented
in [json.md](json.md). Secret parameter values are never part of this output;
the schema only says that a parameter has type `secret`.

## `stoat recipe new <name>`

Scaffolds a new recipe file in the recipes directory and prints its path.
Scaffolds a new recipe directory (manifest plus scripts) and prints its path.

```
$ stoat recipe new mytool --os alpine
/home/user/.stoat/recipes/mytool.alpine.sh
edit it, then pick it in the new-vm form for a matching vm
/home/user/.stoat/recipes/mytool
edit its recipe.toml and scripts, then pick it in the new-vm form for a matching vm
```

`--backend cloudinit` scaffolds a cloud-init fragment instead of a shell script. `-q` suppresses the trailing hint line.
`--backend` is accepted for CLI compatibility but does not change the scaffold:
all recipes are directories with a manifest and shell scripts. `-q` suppresses
the trailing hint line.

`recipe new` copies the annotated [recipe sample](samples/recipe.toml), with
`name` and `os` filled for the new recipe. It creates the default script and
every script path declared by the sample's `[scripts]` overrides. The strict
VM and guest samples are [here](samples/vm.toml) and
[here](samples/guest.toml).

**Exit codes:** 0 on success; 1 if the recipe can't be created (e.g. the name is already taken).

Expand Down
46 changes: 36 additions & 10 deletions docs/reference/json.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,10 @@ VM {"name":"work","os":"alpine","mode":"cloud","backend":"cloudinit",
"allow_exec":true,"display":"vnc",
"error":"only on a broken VM"}

VMStatus {"name":"work",...VM fields...,"health":"ok","recipes_detail":[
{"name":"xfce","applied":true,"version":"1.2","at":"...",
"health":"unknown","params":{},"outputs":{}}]}

Image {"id":"alpine-virt","os":"alpine","variant":"virt",
"backend":"apkovl","file":"alpine-virt-3.24.1-x86_64.iso",
"downloaded":true,"bytes":62914560,"bytes_exact":true,"byo":false}
Expand All @@ -203,7 +207,17 @@ Check {"name":"qemu-img","ok":false,"detail":"not found",
PruneItem {"class":"orphaned_image","path":"/home/u/.stoat/isos/old.iso"}

Recipe {"name":"xfce","description":"XFCE desktop over SSH or at boot",
"reboot":false,"depends":[],"runtime":"sh"}
"schema":2,"runtime":"sh","reboot":false,"depends":[],
"params":[],"outputs":[],"health":null}

RecipeSchema {"name":"docker","description":"Docker engine and the compose plugin",
"schema":3,"runtime":"sh","reboot":false,"depends":[],
"params":[RecipeParam,...],"outputs":[RecipeOutput,...],
"health":{"check":"docker info","timeout":"30s"}}
RecipeParam {"name":"channel","type":"enum","required":false,
"default":"stable","values":["stable","test"],"help":"..."}
RecipeOutput {"name":"socket","help":"path of the socket"}
RecipeHealth {"check":"docker info","timeout":"30s"}

RecipeIssue {"name":"docker","reason":"docker is not offered to debian/cloudinit"}

Expand Down Expand Up @@ -312,7 +326,7 @@ so a leak fails the build rather than shipping.
| `cmd` | `data` |
|---|---|
| `ls` | `{"vms":[VM,...]}` |
| `get` | `{"vm":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) |
Expand All @@ -337,8 +351,15 @@ so a leak fails the build rather than shipping.
| `guest ls` | `{"guests":[Guest,...]}` |
| `guest show` | `{"guest":Guest}` |
| `recipe list` | `{"dir":"...","recipes":["xfce"]}`, see note below |
| `recipe new` | `{"path":"/home/u/.stoat/recipes/foo.alpine.sh"}` |
| `recipe show` | `{"recipe":RecipeSchema}` |
| `recipe new` | `{"path":"/home/u/.stoat/recipes/foo"}` |
| `screenshot` | `{"vm":"work","path":"/home/u/.stoat/work/screenshots/2026-09-05T140302Z.png","bytes":48213,"width":1280,"height":800}` |
| `logs` (no VM) | `{"lines":[...]}` (stoat's own log) |
| `logs <vm>` | `{"vm":"work","which":"console","lines":[...]}` |
| `doctor` | `{"healthy":false,"checks":[Check,...]}` |
| `version` | `{"version":"1.2.3","contract":2}` |
| `help` | `{"usage":"..."}` |
| `ssh` | **refused**, see below |

Both `recipe` subcommands report `"cmd":"recipe"`, not `"cmd":"recipe list"`,
and both `guest` subcommands report `"cmd":"guest"`. Distinguish them by which
Expand All @@ -348,13 +369,18 @@ fields `data` carries.
as "every recipe you can use": it currently includes the `.bak` files the
one-time manifest upgrade left behind, and those are not applicable to any VM.
Use `recipes` (which filters by OS and backend) to find something a VM can
actually run; use `recipe list` only to find a file to edit.
| `logs` (no VM) | `{"lines":[...]}` (stoat's own log) |
| `logs <vm>` | `{"vm":"work","which":"console","lines":[...]}` |
| `doctor` | `{"healthy":false,"checks":[Check,...]}` |
| `version` | `{"version":"1.2.3","contract":2}` |
| `help` | `{"usage":"..."}` |
| `ssh` | **refused**, see below |
actually run; use `recipe list` only to find a recipe directory to inspect.

`get` returns `{"vm":VMStatus}`: `VMStatus` embeds the VM fields directly;
only the outer get result has the `vm` member. `recipes` remains the compatible
string list, while `recipes_detail` adds stored per-recipe state. `health` is the stored aggregate
(`ok`, `failed`, or `unknown`); it is not a live SSH check. Every detail's
`params` and `outputs` is an object, even when empty. Secret parameters are
`<set>` or `<unset>` and are never emitted as their value.

`recipe show` and `recipes` use the same `RecipeSchema` projection. Parameters
and outputs are named arrays sorted by name. A recipe without a health check
has `health:null`; all list fields are `[]`, never `null`.

Fields worth knowing about:

Expand Down
36 changes: 36 additions & 0 deletions docs/reference/samples/guest.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Every field in a guest definition. A guest file describes the image and its
# package/service surface; recipes consume these facts through the prelude.
schema = 1 # int; required fixed value 1; guest author writes.
name = "alpine" # string; required; guest author writes.
shell = "/bin/ash" # string; required; guest author writes the login shell.
init = "openrc" # string; required; guest author writes: systemd, openrc, or rc.
installer = "setup-alpine" # string; default empty means "the installer"; guest author writes.
default_backend = "apkovl" # string; default none; guest author writes the create-time backend.
default_ssh_user = "root" # string; default none; guest author writes the create-time SSH user.
escalate = ["sudo", "-n"] # string[]; default []; guest author writes root escalation argv.
capabilities = ["apk"] # string[]; default []; guest author writes recipe capabilities; init is appended.
aliases = [] # string[]; default []; guest author writes alternate script keys.
filename_hints = ["alpine"] # string[]; default []; guest author writes BYO-image filename hints.
seed_packages = ["sudo"] # string[]; default []; guest author writes cloud-init seed packages.

[pkg]
setup = "apk update" # string; default empty; guest author writes the package-index prelude.
install = ["apk", "--wait", "60", "add"] # string[]; default []; guest author writes install argv.
env = {} # map[string]string; default {}; guest author writes prelude environment.
scaffold_setup = "setup-apkrepos -c -1" # string; default empty; guest author writes scaffold comment text.
scaffold_install = "apk add " # string; default empty; guest author writes scaffold install text.
runtime_packages = { python3 = "python3" } # map[string]string; default {}; guest author writes runtime packages.

[svc]
enable = "rc-update add {name} default" # string; required; guest author writes service-enable template.
start = "rc-service {name} start" # string; required; guest author writes service-start template.
stop = "rc-service {name} stop" # string; required; guest author writes service-stop template.
restart = "rc-service {name} restart" # string; required; guest author writes service-restart template.
status = "rc-service {name} status" # string; required; guest author writes service-status template.

[cmd]
download = "wget -O" # string; default empty; guest author writes the image download command.
useradd = "adduser -D {name}" # string; default empty; guest author writes the account command.

[backend.cloudinit]
skip_9p = false # bool; default false; cloud-init backend owner writes this opaque setting.
1 change: 1 addition & 0 deletions docs/reference/samples/recipe.toml
40 changes: 40 additions & 0 deletions docs/reference/samples/vm.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Every field a vm.toml can carry. stoat writes this file; a human edits the
# resource fields and recipe params. `stoat update` is safer for automation.

name = "work" # string; default none; user creates, stoat writes the directory identity.
mode = "disk" # string; default inferred from image/backend; stoat writes: live, disk, or cloud.
os = "alpine" # string; default inferred from image; stoat writes the guest definition name.
iso = "isos/x.iso" # string path; default none; stoat writes it relative to the data root.
ram = 2048 # int MB; default 4096; stoat writes at create/update.
cpus = 2 # int; default 4; stoat writes at create/update.
disk = "16G" # string; default 8G in disk mode; stoat writes, grow-only after creation.
installed = true # bool; default false; stoat writes for disk mode and flips boot order.
share = "~/src" # string path; default empty; user/TUI and stoat update write the host share.
sshport = 2200 # int; default an allocated free port; stoat writes the host forward to guest sshd.
recipes = ["docker"] # string[]; default []; user/TUI and stoat create/update write the selection.
display = "auto" # string; default "auto"; user/TUI writes: auto, window, or vnc.
backend = "cloudinit" # string; default inferred from image; stoat writes: apkovl, cloudinit, or ssh.
base = "" # string path; default empty; stoat writes the absolute shared base-image path.
sshuser = "stoat" # string; default guest-defined user (empty means root); stoat writes it.
console_password = "" # string; default "stoat" for cloud VMs, empty otherwise; stoat writes, never ssh.
allow_exec = true # bool; default true; user/TUI and stoat create write the MCP exec/copy opt-in.

[[forwards]] # table[]; default []; user/TUI and `stoat forward` write extra forwards.
hostport = 8080 # int; default none; user writes the host port.
guestport = 80 # int; default none; user writes the guest port.

[params.docker] # table; default {}; stoat writes parameter values; do not edit by hand.
user = "dev" # string; default recipe value "dev"; stoat writes the non-secret override.
channel = "stable" # string; default recipe value; stoat writes the non-secret override.

# Written by stoat; do not edit.
[applied.docker]
version = "1.2.0" # string; default empty; stoat writes the applied recipe version.
hash = "recipe-and-params-hash" # string; default empty; stoat writes the recipe/params hash.
script_hash = "script-hash" # string; default empty; stoat writes the applied script hash.
at = 2026-09-04T10:00:00Z # datetime; default zero; stoat writes the apply time.
health = "ok" # string; default unknown; stoat writes the stored health result.

# Written by stoat; do not edit.
[applied.docker.outputs]
socket = "/var/run/docker.sock" # string; default empty; stoat writes recipe output values.
7 changes: 7 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,35 @@ go 1.26
require (
charm.land/bubbles/v2 v2.1.1
charm.land/bubbletea/v2 v2.0.8
charm.land/huh/v2 v2.0.3
charm.land/lipgloss/v2 v2.0.5
charm.land/log/v2 v2.0.0
github.com/BurntSushi/toml v1.6.0
github.com/alecthomas/kong v1.16.0
github.com/charmbracelet/x/ansi v0.11.7
github.com/pelletier/go-toml/v2 v2.4.3
golang.org/x/sys v0.47.0
gopkg.in/yaml.v3 v3.0.1
)

require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/catppuccin/go v0.2.0 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 // indirect
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-logfmt/logfmt v0.6.1 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-runewidth v0.0.27 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sahilm/fuzzy v0.1.3 // indirect
Expand Down
Loading
Loading