Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions docs/reference/json.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ types can be added without a version bump.
| `message` | string | always, human-readable, not machine-parseable |
| `subject` | string | reserved, see below |
| `kind` | string | reserved, see below |
| `needed_mb`, `available_mb` | number | `limit_reached` for a RAM refusal only |

Branch on `code`. Never parse `message`.

Expand Down Expand Up @@ -149,6 +150,7 @@ bump the contract version. Do not write code that requires them.
| `confirmation_required` | a destructive command was run without `-y` |
| `access_denied` | MCP guest access was refused because the VM's `agent_access` level is too low |
| `rate_limited` | MCP refused a tool call because its per-tool or shared rate bucket was exhausted |
| `limit_reached` | a configured limit or the host's free memory refuses the operation; a RAM refusal adds `needed_mb` and `available_mb` |
| `lock_out_of_date` | a project declaration is not pinned in `stoat.lock` |
| `host_unsupported` | this host has no qualified native VM runtime yet |
| `capability_unavailable` | the VM's provider does not support this operation |
Expand Down
7 changes: 7 additions & 0 deletions docs/reference/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,13 @@ unavailable fork and continuation proposals. Discovery does not mutate a VM.
| `snapshot`, `restore`, `forward`, `wait`, `prune` | Manage disk snapshots, port forwards, state waits, and stale files. `prune` is dry-run unless `apply=true`. |
| `project_status`, `project_up`, `project_down`, `project_apply`, `project_wait` | Inspect or operate on every VM declared by the server working directory's `stoat.toml`, in declaration order. A failure stops the run and later VMs are marked skipped. |

`start` has no force option. When the VM needs more memory than the host has
free, or the RAM limit refuses it, the tool returns `limit_reached` with
`needed_mb` and `available_mb`. Lower `ram_mb` with `update`, or ask a person
to start the VM with `stoat up -y`. Inputs with a fixed set of values
(`until`, `mode`, `agent_access`, `stream`, `action`, `which`) list them as
`enum` in the tool schema.

`wait` accepts `reachable`, `applied`, or `stopped`; `healthy=true` waits for
the applied recipes' health checks. Its `timeout_seconds` is a count of
seconds capped at 600, not a duration string.
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/project-file.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,4 +143,4 @@ the stop request can return while QEMU is still exiting.
| 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` |
| unknown key in a bare argument | `no VM "db"; see stoat ls` |
10 changes: 6 additions & 4 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,15 +239,17 @@ deletes its directory and persistent disk. A project command without a VM
name uses declaration order; use the key or global name when repairing one
entry.

## `stoat up` says `no VM ... in stoat.toml or ~/.stoat/vms`
## `stoat up` says `no VM "<name>"; see stoat ls`

Project scope is active only when `stoat.toml` exists in the current
directory. Stoat does not walk up to a parent directory. A bare VM argument
must be a declaration key, a declared `name`, or a global VM that exists in
the data root.
the data root. When a similar name exists, the message adds
`(did you mean "<other>"?)`.

**Fix:** change to the directory containing the intended `stoat.toml`, use the
declaration key, or create a global VM with `stoat create --global ...`.
**Fix:** run `stoat ls` to see the VM names. Change to the directory
containing the intended `stoat.toml`, use the declaration key, or create a
global VM with `stoat create --global ...`.

## `stoat recipe lock` says the lock is out of date

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ require (
github.com/charmbracelet/x/ansi v0.11.8
github.com/charmbracelet/x/term v0.2.2
github.com/diskfs/go-diskfs v1.9.4
github.com/google/jsonschema-go v0.4.3
github.com/googleapis/gax-go/v2 v2.26.2
github.com/modelcontextprotocol/go-sdk v1.8.0
github.com/pelletier/go-toml/v2 v2.4.3
Expand Down Expand Up @@ -47,7 +48,6 @@ require (
github.com/go-logfmt/logfmt v0.6.1 // indirect
github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/jsonschema-go v0.4.3 // indirect
github.com/google/s2a-go v0.1.10 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.22 // indirect
Expand Down
2 changes: 1 addition & 1 deletion internal/capabilities/load.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ func LoadTarget(name string) (Target, error) {
path := filepath.Join(config.Root(), name, "vm.toml")
if _, err := os.Stat(path); err != nil {
if os.IsNotExist(err) {
return Target{}, fmt.Errorf("%w: %s", coreerr.ErrNotFound, name)
return Target{}, config.NoVM(name)
}
return Target{}, fmt.Errorf("%w: %s: %v", coreerr.ErrBroken, name, err)
}
Expand Down
51 changes: 50 additions & 1 deletion internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,11 @@ func commandPath(ctx *kong.Context) string {
// as a pure function: kong.Parse populates the grammar and returns, and no
// Run() method exists for it to call.
func Parse(args []string) (*Args, error) {
if len(args) > 1 && args[0] == "help" {
// `help <cmd>` is `<cmd> --help`, which is also the only spelling kong
// and parseExec both understand.
args = append(append([]string{}, args[1:]...), "--help")
}
if len(args) > 0 && args[0] == "exec" {
return parseExec(args[1:])
}
Expand Down Expand Up @@ -348,6 +353,9 @@ func preserveRecipeSearchTerm(args []string) []string {
// requires to arrive untouched. wire.SplitJSONFlag stops its own argv scan
// at exec's VM name for the same reason.
func parseExec(rest []string) (*Args, error) {
if len(rest) > 0 && (rest[0] == "-h" || rest[0] == "--help") {
return &Args{Cmd: "help", Help: kongHelp("exec", "--help")}, nil
}
if len(rest) == 0 {
return nil, usageError("exec: missing vm name")
}
Expand All @@ -363,6 +371,47 @@ func parseExec(rest []string) (*Args, error) {
return &Args{Cmd: "exec", VM: name, Command: cmd}, nil
}

// kongHelp returns the text kong prints for argv, which must end in --help.
func kongHelp(argv ...string) string {
var g grammar
var help bytes.Buffer
p, err := newParser(&g, &help)
if err != nil {
return "cli grammar: " + err.Error()
}
_, _ = p.Parse(argv)
return help.String()
}

// usageTail is what follows a usage error: the failing command's usage line
// and a pointer to its help. The whole root help is 60 lines and buries the
// error above it.
func usageTail(argv []string) string {
var g grammar
var buf bytes.Buffer
p, err := newParser(&g, &buf)
if err != nil {
return ""
}
if len(argv) > 0 && argv[0] == "exec" {
// parseExec bypasses kong, so trace only the command name.
argv = argv[:1]
}
ctx, err := kong.Trace(p, argv)
if err != nil {
return ""
}
_ = ctx.PrintUsage(true)
// The summary carries the description and its own hint after the usage
// line; only the line is wanted.
usage, _, _ := strings.Cut(buf.String(), "\n")
cmd := commandPath(ctx)
if cmd == "" {
return usage + "\nRun \"stoat --help\" for the command list.\n"
}
return usage + "\nRun \"stoat " + cmd + " --help\" for more information.\n"
}

// helpText renders the same text `stoat --help` prints, for the `help`
// subcommand and for --json help. It is generated from the grammar, so unlike
// the usage() string it replaces, it cannot drift from the actual flags.
Expand Down Expand Up @@ -460,7 +509,7 @@ func Main(args []string, version string, stdin io.Reader, stdout, stderr io.Writ
return ExitUsage
}
fmt.Fprintln(stderr, "stoat:", err)
fmt.Fprintln(stderr, helpText())
fmt.Fprint(stderr, usageTail(argv))
return ExitUsage
}
a.JSON = jsonMode
Expand Down
52 changes: 52 additions & 0 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -626,3 +626,55 @@ func TestParseCPResolvesLocalToAbsolutePath(t *testing.T) {
t.Errorf("flag form: Local = %q, want %q", got.Local, want)
}
}

func TestUsageErrorPrintsCommandUsageNotRootHelp(t *testing.T) {
var out, errOut bytes.Buffer
if code := Main([]string{"get"}, "test", nil, &out, &errOut); code != ExitUsage {
t.Fatalf("exit = %d, want %d", code, ExitUsage)
}
got := errOut.String()
for _, want := range []string{"Usage: stoat get <vm>", `Run "stoat get --help"`} {
if !strings.Contains(got, want) {
t.Errorf("stderr missing %q:\n%s", want, got)
}
}
if strings.Contains(got, "Run local QEMU VMs") || strings.Count(got, "\n") > 4 {
t.Errorf("stderr carries the root help:\n%s", got)
}
}

func TestHelpForExec(t *testing.T) {
for _, argv := range [][]string{{"exec", "--help"}, {"exec", "-h"}, {"help", "exec"}} {
var out, errOut bytes.Buffer
if code := Main(argv, "test", nil, &out, &errOut); code != ExitOK {
t.Fatalf("%v: exit = %d, stderr %q", argv, code, errOut.String())
}
if !strings.Contains(out.String(), "Usage: stoat exec <vm> <command>") {
t.Errorf("%v: stdout is not exec's help:\n%s", argv, out.String())
}
}
}

func TestHelpForNestedCommand(t *testing.T) {
var out, errOut bytes.Buffer
if code := Main([]string{"help", "recipe", "new"}, "test", nil, &out, &errOut); code != ExitOK {
t.Fatalf("exit = %d, stderr %q", code, errOut.String())
}
if !strings.Contains(out.String(), "Usage: stoat recipe new") {
t.Errorf("stdout is not recipe new's help:\n%s", out.String())
}
}

func TestMissingVMNamesTheFix(t *testing.T) {
cliRoot(t)
if err := (&config.VM{Name: "qol1", Mode: "live", RAM: 1024, CPUs: 1, SSHPort: 2200}).Save(); err != nil {
t.Fatal(err)
}
var out, errOut bytes.Buffer
if code := Main([]string{"ssh", "qol"}, "test", nil, &out, &errOut); code != ExitFail {
t.Fatalf("exit = %d, want %d", code, ExitFail)
}
if got, want := errOut.String(), "stoat: ssh: no VM \"qol\" (did you mean \"qol1\"?); see stoat ls\n"; got != want {
t.Errorf("stderr = %q, want %q", got, want)
}
}
15 changes: 11 additions & 4 deletions internal/cli/confirm.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,18 @@ func confirm(a *Args, stdin io.Reader, stdout, stderr io.Writer, prompt string)
if a.Yes {
return true, ExitOK
}
if a.JSON {
return false, a.failMsg(stdout, stderr, wire.ErrConfirmationRequired, prompt+"; pass -y to confirm")
why := ""
switch {
case a.JSON:
why = "--json never prompts"
case a.Quiet:
why = "--quiet never prompts"
case !terminal(stdin) || !terminal(stdout):
why = "no terminal"
}
if a.Quiet || !terminal(stdin) || !terminal(stdout) {
return false, a.failMsg(stdout, stderr, wire.ErrConfirmationRequired, prompt+"; pass -y to confirm")
if why != "" {
msg := "refusing to " + strings.TrimSuffix(prompt, "?") + " without confirmation (" + why + "); pass -y"
return false, a.failMsg(stdout, stderr, wire.ErrConfirmationRequired, msg)
}
fmt.Fprintf(stdout, "%s [y/N] ", prompt)
line, _ := bufio.NewReader(stdin).ReadString('\n')
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/grammar.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ type createCmd struct {
func (createCmd) Help() string {
return `Examples:
stoat create dev --image debian-13 --ram 2048 --cpus 2
stoat create lab --image alpine-3.21 --mode live --agent-access observe
stoat create lab --image alpine-standard --mode live --agent-access observe
stoat create web --image ubuntu-24.04 --recipes nginx --set nginx.port=8080`
}

Expand Down
4 changes: 1 addition & 3 deletions internal/cli/project.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
package cli

import (
"fmt"

"github.com/novusedge/stoat/internal/capabilities"
"github.com/novusedge/stoat/internal/project"
)
Expand Down Expand Up @@ -57,7 +55,7 @@ func resolveScope(a *Args) error {
return nil
}
if _, err := coreGet(a.VM); err != nil {
return fmt.Errorf("no VM %q in %s or ~/.stoat/vms", a.VM, project.FileName)
return err
}
return nil
}
9 changes: 4 additions & 5 deletions internal/cli/project_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package cli
import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/novusedge/stoat/internal/config"
Expand Down Expand Up @@ -51,17 +50,17 @@ func TestBareKeyResolvesToTheGlobalName(t *testing.T) {
}
}

// An argument that is neither a key nor an existing VM names both places it
// was looked for.
func TestUnknownBareArgumentNamesBothScopes(t *testing.T) {
// An argument that is neither a key nor an existing VM gets the same
// not-found error as everywhere else.
func TestUnknownBareArgumentIsNoVM(t *testing.T) {
projectRoot(t, twoVMs)
code, objs := runJSON(t, "get", "db")
if code != ExitFail {
t.Errorf("exit = %d, want %d", code, ExitFail)
}
errObj, _ := result(t, objs)["error"].(map[string]any)
msg, _ := errObj["message"].(string)
if !strings.Contains(msg, `no VM "db" in stoat.toml or ~/.stoat/vms`) {
if msg != `no VM "db"; see stoat ls` {
t.Errorf("message = %q", msg)
}
}
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/run_recipe_remote.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ func runRecipeAdd(a *Args, stdin io.Reader, stdout, stderr io.Writer) int {
source, gitRef, isURL := recipes.ParseRef(a.Ref)
if isURL && !a.Yes {
if a.JSON || !terminal(stdin) || !terminal(stdout) {
_, code := confirm(a, stdin, stdout, stderr, "install this recipe; pass -y to confirm")
_, code := confirm(a, stdin, stdout, stderr, "install this recipe")
return code
}
m, tmp, previewErr := recipes.Preview(source, gitRef)
Expand Down
3 changes: 3 additions & 0 deletions internal/cli/wire/envelope.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ type ErrorInfo struct {
// constants, so an agent branches on a field instead of parsing prose
// out of Message.
Reason string `json:"reason,omitempty"`
// NeededMB and AvailableMB are present on limit_reached for a RAM refusal.
NeededMB int `json:"needed_mb,omitempty"`
AvailableMB int `json:"available_mb,omitempty"`
}

// WithSubject sets Subject and Kind and returns e, for a one-line call at
Expand Down
11 changes: 9 additions & 2 deletions internal/cli/wire/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ const (
CodeConfirmationRequired Code = "confirmation_required"
CodeAccessDenied Code = "access_denied"
CodeRateLimited Code = "rate_limited"
CodeLimitReached Code = "limit_reached"
CodeInternal Code = "internal"

CodeQemuMissing Code = "qemu_missing"
Expand Down Expand Up @@ -83,7 +84,7 @@ func Codes() []Code {
CodeAlreadyRunning, CodeNoDisk, CodeImmutableField, CodeDiskShrink,
CodeCannotReach, CodeUnknownLog, CodeTimeout, CodeCanceled,
CodeUsage, CodeConfirmationRequired, CodeInternal,
CodeAccessDenied, CodeRateLimited,
CodeAccessDenied, CodeRateLimited, CodeLimitReached,
CodeQemuMissing, CodeKVMUnusable, CodeQemuStartFailed,
CodeMonitorUnreachable, CodeMonitorRejected, CodeNoConsolePassword,
CodeShareInvalid, CodeNoXattr,
Expand Down Expand Up @@ -173,6 +174,7 @@ var codeTable = []struct {
{CodeConfirmationRequired, ErrConfirmationRequired},
{CodeAccessDenied, ErrAccessDenied},
{CodeRateLimited, ErrRateLimited},
{CodeLimitReached, core.ErrLimit},
{CodeUsage, ErrBadInput},
{CodeQemuMissing, qemu.ErrBinaryMissing},
{CodeKVMUnusable, qemu.ErrKVMUnusable},
Expand Down Expand Up @@ -219,7 +221,12 @@ func MapError(err error) *ErrorInfo {
}
for _, e := range codeTable {
if errors.Is(err, e.err) {
return &ErrorInfo{Code: e.code, Message: err.Error()}
info := &ErrorInfo{Code: e.code, Message: err.Error()}
var limit *core.LimitError
if errors.As(err, &limit) {
info.NeededMB, info.AvailableMB = limit.NeededMB, limit.AvailableMB
}
return info
}
}
return &ErrorInfo{Code: CodeInternal, Message: err.Error()}
Expand Down
11 changes: 11 additions & 0 deletions internal/cli/wire/errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -300,3 +300,14 @@ func TestEveryISOSentinelHasOneRow(t *testing.T) {
}
}
}

func TestMapErrorLimitReachedCarriesTheNumbers(t *testing.T) {
err := fmt.Errorf("start: %w", &core.LimitError{NeededMB: 4096, AvailableMB: 1500})
got := MapError(err)
if got.Code != CodeLimitReached || got.NeededMB != 4096 || got.AvailableMB != 1500 {
t.Errorf("MapError = %+v, want limit_reached with 4096 needed and 1500 available", got)
}
if plain := MapError(fmt.Errorf("%w: 3 vms exist", core.ErrLimit)); plain.Code != CodeLimitReached || plain.NeededMB != 0 {
t.Errorf("count limit = %+v, want limit_reached without MB fields", plain)
}
}
3 changes: 3 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,9 @@ func (v *VM) Save() error {

// Load reads one VM by name.
func Load(name string) (*VM, error) {
if !Exists(name) {
return nil, NoVM(name)
}
dir := DirFor(name)
path := filepath.Join(dir, "vm.toml")
// Absent allow_exec means true; the seed survives the decode, a written
Expand Down
Loading
Loading