Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
6626158
test(tomlx): pin decode, unknown-key, and schema behaviour
NovusEdge Sep 4, 2026
5260e8a
feat(tomlx): one decoder with path, unknown keys, schema
NovusEdge Sep 4, 2026
a97d295
test(guest): pin bundled-load, validate, and parseFile behaviour
NovusEdge Sep 4, 2026
783ef5f
feat(guest): bundled guest.toml files and a loader
NovusEdge Sep 4, 2026
a5aa98a
test(guest): pin Load merge, missing field, and Capabilities
NovusEdge Sep 4, 2026
0c2ebdd
feat(guest): load guest.toml files, delete the Go literal
NovusEdge Sep 4, 2026
67d087b
test(recipes): pin guest-driven bootstrap, aliases, capabilities
NovusEdge Sep 4, 2026
a38eb3e
test(guest): pin sh/python3 prelude and shebang placement
NovusEdge Sep 4, 2026
34489e5
test(sshx,cloudinit): pin escalate argv and setup-first prelude
NovusEdge Sep 4, 2026
fbb8162
refactor(recipes): read package and capability facts from guest
NovusEdge Sep 4, 2026
571a37c
feat(guest): render the recipe prelude for sh and python3
NovusEdge Sep 4, 2026
b1cb12c
feat(sshx): prelude, escalate argv, and setup from the guest file
NovusEdge Sep 4, 2026
0659337
test(core): pin guests API and unknown-guest-is-broken
NovusEdge Sep 4, 2026
8b32bab
test(cli): pin wire.Guest golden and guest ls/show envelopes
NovusEdge Sep 4, 2026
830eff7
test(config,recipes,cli): pin tomlx decode and provision alias
NovusEdge Sep 4, 2026
c6a71f5
feat(core): guests API; unknown guest marks a VM broken
NovusEdge Sep 4, 2026
2540595
feat(cli): guest ls and guest show; load user guests at start
NovusEdge Sep 4, 2026
2062b05
refactor: vm.toml and recipe.toml decode through tomlx; provision ali…
NovusEdge Sep 4, 2026
7391683
feat(recipes): xfce installs through the guest verbs
NovusEdge Sep 4, 2026
309d078
test(cli): provision parses as apply
NovusEdge Sep 4, 2026
1ae999b
docs: keep the json command table intact; list guest
NovusEdge Sep 4, 2026
2fc8e3e
docs(cloudinit): reattach the mountsDoc comment
NovusEdge Sep 4, 2026
b199f63
fix(guest): reject a quote in a verb template
NovusEdge Sep 4, 2026
36c15ae
docs(guest): note the template quote rule
NovusEdge Sep 4, 2026
ce75a97
docs(core-api): state the fail vs failMsg rule
NovusEdge Sep 4, 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
1 change: 1 addition & 0 deletions docs/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
* [TUI](reference/tui.md)
* [CLI](reference/cli.md)
* [JSON output](reference/json.md)
* [Guest definitions](reference/guest.md)

## Recipes

Expand Down
6 changes: 6 additions & 0 deletions docs/design/core-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,12 @@ ErrBroken ErrDiskShrink

Each carries the specific subject (which field, which recipe, which dependency) rather than only a message.

The CLI reports these two ways, and the choice is not a style question. `a.fail` takes an
error `core` returned and maps its sentinel to the JSON error code. `a.failMsg` takes a
sentinel the CLI picked itself, for a condition `core` never saw: a bad flag combination, an
unknown subcommand. A CLI-detected condition sent through `a.fail` would carry whatever code
the wrapped error happened to hold.

---

## 10. Security model
Expand Down
5 changes: 5 additions & 0 deletions docs/design/guest-subsystem.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Guest Subsystem: Design

**Update, 2026-09-05:** the guest registry this document designs is now data,
not Go: `internal/guest/bundled/*.toml` plus `~/.stoat/guests/*.toml` merged
over it, loaded through `internal/tomlx`. See `docs/reference/guest.md` for
the file format.

**Status:** accepted design, not yet a plan. Written 2026-08-02. The operation surface built on this layer is specified in [`core-api.md`](core-api.md).

**Why this exists:** guest-OS knowledge is scattered across 25 sites as ad-hoc string comparisons, provisioning has no contract at all, and the logic that creates a VM lives inside a Bubbletea form. Adding Alpine cloud support missed three of those sites and the feature silently did not work. This document defines the subsystem that makes that class of failure structural rather than a matter of remembering, and the API layer that lets something other than a keyboard drive stoat.
Expand Down
12 changes: 2 additions & 10 deletions docs/recipe-spec-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,16 +63,8 @@ fedora = "install-fedora.sh"

### Capabilities

Capabilities are facts about a guest that a recipe can require:

| Capability | Meaning | OSes |
|------------|---------|------|
| `systemd` | Uses systemd init | ubuntu, debian, arch, fedora |
| `openrc` | Uses OpenRC init | alpine |
| `apt` | Has apt package manager | ubuntu, debian |
| `apk` | Has apk package manager | alpine |
| `dnf` | Has dnf package manager | fedora |
| `pacman` | Has pacman | arch |
Capabilities are facts about a guest that a recipe can require: every
`capabilities` entry and `init` of a loaded guest; see `stoat guest show`.

A recipe declaring `requires = ["systemd"]` is not offered to Alpine. Stoat resolves these against `guest.OS` at list/check time.

Expand Down
68 changes: 44 additions & 24 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,12 @@ usage: stoat <command> [flags]
| [`snapshot`](#stoat-snapshot-name-tag) | List, save, restore or delete a snapshot | 0, 1, 2 |
| [`prune`](#stoat-prune) | Report, or with `--apply` remove, stale files | 0, 1 |
| [`apply`](#stoat-apply-name) | Run the VM's recipes, streaming output | 0, 1 |
| [`provision`](#stoat-provision-name) | Run recipes, streaming output to stdout | 0, 1 |
| [`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 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 |
| [`logs`](#stoat-logs-name--n-n) | Tail a VM's log, or stoat's own | 0, 1 |
| [`doctor`](#stoat-doctor) | Check host prerequisites | 0, 1 |
| [`version`](#stoat-version) | Print the stoat version | 0 |
Expand Down Expand Up @@ -420,29 +421,7 @@ work: recipes applied

**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.

## `stoat provision <name>`

Runs the VM's recipes over ssh, streaming `last-provision.log` to stdout as it's written (polled every 150ms), the same log the TUI's detail screen tails, so there is no separate provisioning path to keep in sync.

```
$ stoat provision work
provisioning work...
=== recipe xfce ===
Unpacking libx11-data...
...
work provisioned
```

A **cloud-mode VM short-circuits**: cloud-init applies its recipes once, automatically, at first boot, baked into the seed when the VM's overlay was created, there is nothing left for ssh-based provisioning to do, and piping a cloud recipe (`#cloud-config` YAML, not a shell script) into `sh -s` would just fail. Instead it prints an explanatory line and exits 0 without touching ssh:

```
$ stoat provision cloudvm
cloudvm is a cloud VM: recipes are applied automatically via cloud-init at first boot; recreate the VM to change them.
```

`-q` suppresses the `provisioning <name>...` line only; the streamed log and the final line still print.

**Exit codes:** 0 on success (including the cloud short-circuit); 1 if the VM can't be loaded or the provision run itself fails.
`provision` is a hidden alias of `apply`: `stoat provision work` behaves exactly like `stoat apply work`, and reports `"cmd":"apply"` under `--json`.

## `stoat recipes`

Expand Down Expand Up @@ -505,6 +484,47 @@ edit it, then pick it in the new-vm form for a matching vm

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

## `stoat guest ls`

Lists every loaded guest OS: bundled definitions from `internal/guest/bundled/*.toml`, plus any `~/.stoat/guests/*.toml` merged over them.

```
$ stoat guest ls
NAME INIT PKG BACKEND SOURCE
alpine openrc apk apkovl bundled
arch systemd pacman cloudinit bundled
debian systemd apt-get cloudinit bundled
fedora systemd dnf cloudinit bundled
ubuntu systemd apt-get cloudinit bundled
```

**Exit codes:** always 0.

## `stoat guest show <name>`

Prints one guest's merged definition: init system, shell, default backend and ssh user, escalate argv, capabilities, aliases, and the `pkg`/`svc` tables. See `docs/reference/guest.md` for what each field means.

```
$ stoat guest show alpine
name: alpine (bundled)
init: openrc
shell: /bin/ash
default backend: apkovl
default ssh user: root
escalate: [sudo -n]
capabilities: [apk openrc]
aliases: []
pkg setup: apk update
pkg install: [apk --wait 60 add]
svc enable: rc-update add {name} default
svc start: rc-service {name} start
svc stop: rc-service {name} stop
svc restart: rc-service {name} restart
svc status: rc-service {name} status
```

**Exit codes:** 0 on success; 1 if no guest by that name is loaded.

## `stoat logs [name] [-n N]`

With a VM name, tails that VM's own log (`--which console` for the qemu console, the default, or `--which apply` for its apply log). With no name, tails stoat's own log file.
Expand Down
81 changes: 81 additions & 0 deletions docs/reference/guest.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Guest definitions

A guest OS's facts (its init system, package manager, service commands...)
live in one `guest.toml` file, not in Go. Five come bundled
(`internal/guest/bundled/*.toml`: alpine, ubuntu, debian, fedora, arch).
Add or override one by dropping a file in `~/.stoat/guests/<name>.toml`; see
`stoat guest ls` and `stoat guest show <name>`.

```toml
schema = 1
name = "freebsd"
init = "rc" # systemd | openrc | rc
shell = "/bin/sh"
installer = "" # the interactive install command; "" means "the installer"
default_backend = "cloudinit" # seeds the VM field at create time; the VM field always wins after
default_ssh_user = "freebsd" # same
escalate = ["sudo", "-n"] # argv; applied only when the VM's ssh user is not root
capabilities = ["pkg"] # feeds recipe.toml's `requires`; the loader appends init
aliases = ["bsd"] # extra keys a recipe's [scripts] map may use for this OS
filename_hints = ["FreeBSD-"] # recognise this OS in a BYO image filename
seed_packages = ["sudo"] # packages the cloud-init seed assumes but the image may lack

[pkg]
setup = "pkg update" # prelude's stoat_pkg_setup; empty means no refresh needed
install = ["pkg", "install", "-y"] # argv; prelude's stoat_pkg_install appends packages
scaffold_setup = "" # comment text `recipe new` writes into a scaffolded script
scaffold_install = "pkg install -y " # same, for the install line
runtime_packages = { python3 = "python3" } # maps a recipe runtime to the package that provides it

[svc]
enable = "sysrc {name}_enable=YES"
start = "service {name} start"
stop = "service {name} stop"
restart = "service {name} restart"
status = "service {name} status"

[backend.cloudinit] # opaque to the loader; the cloudinit package decodes it
skip_9p = true
```

## Field rules

- Required: every top-level scalar and list except `installer`, `aliases`,
`filename_hints`; every `[pkg]` and `[svc]` key except `scaffold_setup`. A
missing one is `guest.toml: <name>: missing <field>`.
- Unknown keys are an error: `<path>: unknown key "<key>"`.
- `schema` must be present and equal to 1.
- The loader appends `init` to `capabilities`. A file whose `capabilities`
names a different init system (`systemd`, `openrc`, `rc`) is rejected.
- `[backend.*]` loads as an opaque table per backend name. The backend
package that owns a name decodes its own keys; `internal/guest` never
reads inside.
- `default_backend` and `default_ssh_user` only seed a new VM's fields at
create time. Every code path reads the VM's own field afterward, never
these, so a catalog entry or a user's explicit choice always wins.
- `escalate` is an OS fact, not a VM fact: Alpine and OpenBSD ship `doas`,
not `sudo`. `sshx` applies it only when the VM's ssh user is not root.
- Every `[svc]` value is a template: `{name}` renders to `"$1"`; a template
without `{name}` gets `"$@"` appended instead. A template may not contain
a single quote: the python prelude wraps each one in a single-quoted
literal.
- `STOAT_PKGMGR` (available in the recipe prelude) is the basename of
`pkg.install[0]`.

## Merging a user file over a bundled one

A user file whose `name` matches a bundled guest merges over it: scalars and
lists in the file replace the bundled value, an absent field keeps the
bundled one, and each `[backend.x]` table replaces whole (not merged key by
key). A user file with a new `name` needs every required field; nothing is
inherited.

`stoat guest show <name>` reports `source`: `bundled`, `user`, or
`bundled+user` for a merged file.

## Unknown guest

A `vm.toml` whose `os` names no loaded guest makes that VM broken (`stoat ls`
shows `unknown guest "<name>"; run stoat guest ls`), the same as an
unparseable `vm.toml`. An empty `os` is not checked: it keeps the existing
fallbacks for a VM saved before the OS field existed.
25 changes: 23 additions & 2 deletions docs/reference/json.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,17 @@ RecipeIssue {"name":"docker","reason":"docker is not offered to debian/cloudinit

ApplyPlan {"name":"xfce","action":"run","reason":"never applied",
"version":"1.2"}

Guest {"name":"fedora","init":"systemd","shell":"/bin/bash",
"installer":"","default_backend":"cloudinit",
"default_ssh_user":"stoat","escalate":["sudo","-n"],
"capabilities":["dnf","systemd"],"aliases":["rpm-family"],
"filename_hints":["fedora"],"seed_packages":[],
"pkg":{"setup":"","install":["dnf","install","-y"],
"env":{},"runtime_packages":{"python3":"python3"}},
"svc":{"enable":"systemctl enable {name}", ...},
"cmd":{},"backend":{"cloudinit":{"skip_9p":false}},
"source":"bundled"}
```

`state` is one of `stopped`, `running`, `broken`. `error` appears only on a
Expand Down Expand Up @@ -261,6 +272,13 @@ agent cannot run a GUI viewer anyway. A consumer that needs to tell a human
where to look runs `stoat get <name>` without `--json`, which prints the
socket and an attach command for a viewer installed on that machine.

`Guest.source` is `"bundled"`, `"user"`, or `"bundled+user"` for a user file
merged over a bundled one. `Guest.svc` and `Guest.cmd` are template strings,
not commands to run directly: `{name}` renders to the service/argument, see
`docs/reference/guest.md`. `Guest.backend` passes each `[backend.<name>]`
table through opaque; only the backend package that owns `<name>` defines its
keys.

### What is deliberately absent

- **Host paths.** `core.VM` carries six absolute host paths (its disk, console
Expand Down Expand Up @@ -302,11 +320,14 @@ so a leak fails the build rather than shipping.
| `apply --dry-run` | `{"vm":"work","dry_run":true,"plan":[ApplyPlan,...]}` |
| `recipes` | `{"recipes":[Recipe,...]}` |
| `check-recipes` | `{"applicable":false,"issues":[RecipeIssue,...]}` |
| `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"}` |

Both `recipe` subcommands report `"cmd":"recipe"`, not `"cmd":"recipe list"`.
Distinguish them by which fields `data` carries.
Both `recipe` subcommands report `"cmd":"recipe"`, not `"cmd":"recipe list"`,
and both `guest` subcommands report `"cmd":"guest"`. Distinguish them by which
fields `data` carries.

`recipe list` is "every file in the recipes directory", which is not the same
as "every recipe you can use": it currently includes the `.bak` files the
Expand Down
71 changes: 51 additions & 20 deletions docs/writing-recipes.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,13 @@ depending on the guest and backend. This is the v2 format; see
xfce/
recipe.toml
install.sh
install-alpine.sh
install-debian.sh
install-arch.sh
docker/
recipe.toml
install.sh
install-alpine.sh
install-debian.sh
install-fedora.sh
install-arch.sh
```

One directory per recipe, named after the recipe. `recipe.toml` is required;
Expand All @@ -36,19 +37,15 @@ stage = "provision"
script = "install.sh"
```

`internal/recipes/bundled/xfce/recipe.toml`, with per-OS overrides:
`internal/recipes/bundled/xfce/recipe.toml`, one script covering every OS by
branching on the guest verbs (see Verbs, below):

```toml
name = "xfce"
description = "XFCE desktop with autologin startx on tty1"
os = ["alpine", "ubuntu", "debian", "arch"]
stage = "provision"
script = "install.sh"

[scripts]
alpine = "install-alpine.sh"
debian = "install-debian.sh"
arch = "install-arch.sh"
```

### Fields
Expand Down Expand Up @@ -157,29 +154,63 @@ table in `vm.toml`:
first time the VM becomes reachable, without waiting for an explicit
`stoat apply`. Default is `false`.

## Verbs

A recipe script does not need to know each guest's package manager or init
system by name: the prelude `stoat` renders in front of every script body
defines a small set of shell functions and variables over the guest's
`guest.toml` facts (`docs/reference/guest.md`), the same functions for every
OS.

| Name | What it does |
|------|--------------|
| `stoat_pkg_setup` | Refreshes the package index (a no-op where none is needed, e.g. dnf) |
| `stoat_pkg_install <pkgs...>` | Installs packages with the guest's package manager |
| `stoat_svc_enable <name>` | Enables a service to start at boot |
| `stoat_svc_start`/`stop`/`restart`/`status` `<name>` | Controls a running service |
| `STOAT_OS` | The guest name, e.g. `alpine` |
| `STOAT_INIT` | `systemd`, `openrc`, or `rc` |
| `STOAT_PKGMGR` | The package manager binary, e.g. `apk`, `apt-get`, `dnf`, `pacman` |

`internal/recipes/bundled/xfce/install.sh` is one script for four OSes,
branching on `STOAT_PKGMGR` for package names and `STOAT_INIT` for the
autologin mechanism (systemd's `getty@.service` drop-in vs. Alpine's busybox
`/etc/inittab`):

```sh
stoat_pkg_setup

case "$STOAT_PKGMGR" in
apk) stoat_pkg_install xfce4 xfce4-terminal dbus-x11 ;;
apt-get) stoat_pkg_install xfce4 xfce4-terminal dbus-x11 xinit xserver-xorg ;;
pacman) stoat_pkg_install xfce4 xfce4-terminal dbus xorg-xinit ;;
esac

stoat_svc_enable dbus
```

`[scripts]` per-OS overrides (below) still exist for a recipe whose install
differs by more than package names and a service call, `docker`'s GPG key
setup and repo URL being a different string per distro family.

## OS-specific script overrides

`script` is the fallback every OS not listed in `[scripts]` uses. `xfce`
needs different logic per init system and package manager, so it overrides
three of its four supported OSes and leaves `ubuntu` on the default
`install.sh`:
`script` is the fallback every OS not listed in `[scripts]` uses. `docker`
needs a different script per distro family (repo URL, GPG key path), so it
overrides every OS it supports:

```toml
os = ["alpine", "ubuntu", "debian", "arch"]
os = ["alpine", "ubuntu", "debian", "fedora", "arch"]
script = "install.sh"

[scripts]
alpine = "install-alpine.sh"
ubuntu = "install-debian.sh"
debian = "install-debian.sh"
fedora = "install-fedora.sh"
arch = "install-arch.sh"
```

Compare `install.sh` (ubuntu/debian, `apt-get` + systemd `getty@.service`
override) against `install-alpine.sh` (`apk` + busybox `/etc/inittab`
autologin, no systemd unit exists to override). The packages and init
mechanism differ enough per OS that a single script branching internally
would be harder to read than one script per OS family.

Two conventions worth copying from the bundled scripts:

- **`set -e` at the top**, so the script stops on the first real failure
Expand Down
Loading
Loading