From 639ad5a3da1c401099c45db50e22176dfb9743e4 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Mon, 10 Aug 2026 17:24:35 +0300 Subject: [PATCH] refactor: rename the apply action off "provision" Every user-facing string now says "apply" instead of "provision": TUI key hints, toasts, the detail pane title, and CLI progress lines. Internal identifiers (sshx.Provision, ErrProvisionInProgress, the last-provision.log filename, the recipe stage schema) stay unchanged; only parsers and wire formats depend on those. The CLI drops runProvision and its divergent output strings. `provision` becomes a hidden command that shares applyCmd's fields and dispatches to runApply, so it gains --only and matches apply's JSON shape. `up`'s --no-provision flag is renamed to --no-apply, with --no-provision kept as a hidden alias for scripts that already pass it. TestToastOverlayKeepsScreenShape compared rendered lines byte for byte, which broke on the shorter "apply" footer label: lipgloss v2's compositor trims trailing whitespace when it draws the toast over a line, so a label-length change alone shifted trailing spaces without changing anything visible. The test now trims trailing spaces before comparing width, keeping the real invariant (no added row, no wrap). --- docs/troubleshooting.md | 15 ++++--- internal/cli/cli.go | 10 ++--- internal/cli/cli_test.go | 3 +- internal/cli/grammar.go | 19 ++++----- internal/cli/kong_test.go | 32 +++++++++++++- internal/cli/run_access.go | 80 ----------------------------------- internal/cli/run_apply.go | 12 +++--- internal/cli/run_vm.go | 18 ++++---- internal/cli/run_vm_test.go | 18 ++++---- internal/core/lock.go | 4 +- internal/tui/app.go | 4 +- internal/tui/detail.go | 2 +- internal/tui/keymap.go | 8 ++-- internal/tui/provision.go | 6 +-- internal/tui/provstep_test.go | 2 +- internal/tui/toast_test.go | 18 ++++++-- 16 files changed, 104 insertions(+), 147 deletions(-) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 34a0416..4fa6296 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -99,10 +99,10 @@ session exists but QEMU cannot draw on it. (`internal/sshx/sshx.go`'s `Wait`, with `WaitTimeout` set to 90 seconds.) -**If this happens while provisioning a disk VM:** the VM is still installing -itself off the ISO. Its guest OS is not on the disk yet, so there is nothing to -provision, and `sshd` answering means the *installer's* sshd is up, not the -system you are building. +**If this happens while applying recipes to a disk VM:** the VM is still +installing itself off the ISO. Its guest OS is not on the disk yet, so there +are no recipes to apply, and `sshd` answering means the *installer's* sshd is +up, not the system you are building. **Fix:** wait. The unattended `setup-alpine` finishes, reboots into the disk, and the next start notices the OS, marks the VM installed and drops the ISO from @@ -110,7 +110,7 @@ the boot order. Pressing `p` before that is refused outright, rather than making you wait out the full timeout to find out: ``` -: installing itself; wait for it to finish and reboot, then stoat notices the install and offers to provision +: installing itself; wait for it to finish and reboot, then stoat notices the install and offers to apply recipes ``` `i` on the detail screen still toggles `installed` by hand, for when that @@ -118,7 +118,7 @@ guess goes wrong in either direction: an install that died halfway leaves enough bytes on the disk to look finished (the threshold is `installedBytes` in `internal/qemu/run.go`), and `i` is how you get the ISO back. -## Provisioning a disk VM fails with `Permission denied (publickey,...)` +## Applying recipes to a disk VM fails with `Permission denied (publickey,...)` An Alpine disk VM gets the same apkovl a live one does (`internal/apkovl/apkovl.go`) while it is still uninstalled, precisely so @@ -208,7 +208,8 @@ machine) or a **cloud** VM (a prebuilt image where cloud-init's `packages:`/ A VM's directory exists under the data root but its `vm.toml` doesn't parse (`internal/config/config.go`'s `ListBroken`). stoat still shows it, rather than hiding a directory it can't fully understand, so you know it's there and -can act on it, but it can't be started, edited, or provisioned in that state. +can act on it, but it can't be started, edited, or have recipes applied in +that state. Its reserved ssh port stays held (`FreePort` checks broken VMs' raw `vm.toml` port fields too), so a broken VM won't silently let a new VM reuse its port out from under it. diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 47d58ce..773dc42 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -46,10 +46,10 @@ type Args struct { Yes bool N int // logs -n - // NoProvision belongs to "up": it skips the automatic post-boot - // provision, leaving `up` returning as soon as the VM starts, as it did - // before that behavior existed. - NoProvision bool + // NoApply belongs to "up": it skips the automatic post-boot apply, + // leaving `up` returning as soon as the VM starts, as it did before that + // behavior existed. + NoApply bool // JSON is set by Main from the pre-parse argv scan, never by Parse: the // flag has to be recognized before any parser exists so a usage error @@ -392,7 +392,7 @@ func Main(args []string, version string, stdin io.Reader, stdout, stderr io.Writ case "ssh": return runSSH(a, stdout, stderr) case "provision": - return runProvision(a, stdout, stderr) + return runApply(a, stdout, stderr) case "rm": return runRM(a, stdin, stdout, stderr) case "recipe": diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 8b1e315..3ea4662 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -73,7 +73,8 @@ func TestParse(t *testing.T) { {"up missing name", []string{"up"}, nil, true}, {"up too many args", []string{"up", "a", "b"}, nil, true}, {"up quiet", []string{"up", "-q", "alpine"}, &Args{Cmd: "up", VM: "alpine", Quiet: true}, false}, - {"up --no-provision", []string{"up", "--no-provision", "alpine"}, &Args{Cmd: "up", VM: "alpine", NoProvision: true}, false}, + {"up --no-apply", []string{"up", "--no-apply", "alpine"}, &Args{Cmd: "up", VM: "alpine", NoApply: true}, false}, + {"up --no-provision (hidden alias)", []string{"up", "--no-provision", "alpine"}, &Args{Cmd: "up", VM: "alpine", NoApply: true}, false}, {"down", []string{"down", "alpine"}, &Args{Cmd: "down", VM: "alpine"}, false}, {"down missing name", []string{"down"}, nil, true}, diff --git a/internal/cli/grammar.go b/internal/cli/grammar.go index 77fffc7..8a8743b 100644 --- a/internal/cli/grammar.go +++ b/internal/cli/grammar.go @@ -52,7 +52,7 @@ type grammar struct { Prune pruneCmd `cmd:"" help:"report, or with --apply remove, stale files"` Apply applyCmd `cmd:"" help:"run the VM's recipes, streaming output"` - Provision provisionCmd `cmd:"" help:"run recipes, streaming output to stdout"` + Provision applyCmd `cmd:"" hidden:"" name:"provision" help:"alias of apply"` Recipes recipesCmd `cmd:"" help:"list recipes, optionally only applicable ones"` CheckRecipes checkRecipesCmd `cmd:"" help:"report why a recipe would not apply"` Recipe recipeCmd `cmd:"" help:"author recipes"` @@ -75,8 +75,8 @@ type getCmd struct { } type upCmd struct { - VM string `arg:"" help:"vm name"` - NoProvision bool `name:"no-provision" help:"start only; skip the automatic post-boot provision"` + VM string `arg:"" help:"vm name"` + NoApply bool `name:"no-apply" aliases:"no-provision" help:"start only; skip the automatic post-boot apply"` } type downCmd struct { @@ -91,10 +91,6 @@ type sshCmdCmd struct { VM string `arg:"" help:"vm name"` } -type provisionCmd struct { - VM string `arg:"" help:"vm name"` -} - type rmCmd struct { VM string `arg:"" help:"vm name"` Yes bool `short:"y" help:"skip the delete confirmation"` @@ -249,12 +245,12 @@ func (g *grammar) toArgs(path string) (*Args, error) { // FLAG path gets it from kong's own buffer. a.Help = helpText() - case "get", "down", "ssh", "ssh-command", "provision": + case "get", "down", "ssh", "ssh-command": a.VM = g.vmFor(path) case "up": a.VM = g.Up.VM - a.NoProvision = g.Up.NoProvision + a.NoApply = g.Up.NoApply case "rm": a.VM, a.Yes = g.RM.VM, g.RM.Yes @@ -389,6 +385,9 @@ func (g *grammar) toArgs(path string) (*Args, error) { case "apply": a.VM, a.Only = g.Apply.VM, trimList(g.Apply.Only) + case "provision": + a.VM, a.Only = g.Provision.VM, trimList(g.Provision.Only) + case "recipes": a.OS, a.Backend = g.Recipes.OS, g.Recipes.Backend @@ -480,8 +479,6 @@ func (g *grammar) vmFor(path string) string { return g.SSH.VM case "ssh-command": return g.SSHCmd.VM - case "provision": - return g.Provision.VM } return "" } diff --git a/internal/cli/kong_test.go b/internal/cli/kong_test.go index 77d0b24..ffa7e84 100644 --- a/internal/cli/kong_test.go +++ b/internal/cli/kong_test.go @@ -103,6 +103,15 @@ func TestTrimListAcrossCommands(t *testing.T) { t.Errorf("apply --only with trailing comma = %v, want %v", a.Only, want) } + // provision is a hidden alias of apply and takes the same --only flag. + a, err = Parse([]string{"provision", "work", "--only", "a.sh, b.sh"}) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(a.Only, want) { + t.Errorf("provision --only = %v, want %v", a.Only, want) + } + // check-recipes' Names is a positional []string (arg:""). Kong's Sep tag // only splits a FLAG's value, never a positional, so kong alone hands // "a.sh,b.sh" back as one element containing a comma. trimList splits @@ -262,12 +271,14 @@ func TestSnapshotXor(t *testing.T) { // small spot-check to the full command surface: every leaf command kong // generates a line for must be named here, so adding a subcommand to // grammar.go without it appearing in the generated help fails this build -// rather than silently shipping an undocumented command. +// rather than silently shipping an undocumented command. "provision" is +// deliberately absent: it is a hidden alias of "apply", so kong omits it +// from the generated help by design (see TestProvisionIsHiddenFromHelp). func TestHelpListsEverySubcommand(t *testing.T) { want := []string{ "ls", "get", "create", "update", "up", "down", "wait", "rm", "clone", "exec", "ssh", "ssh-command", "cp", "forward", "images", "pull", - "snapshot", "prune", "apply", "provision", "recipes", "check-recipes", + "snapshot", "prune", "apply", "recipes", "check-recipes", "recipe list", "recipe new", "logs", "doctor", "version", "help", } for _, args := range [][]string{{"help"}, {"--help"}, {"-h"}} { @@ -282,3 +293,20 @@ func TestHelpListsEverySubcommand(t *testing.T) { } } } + +// TestProvisionIsHiddenFromHelp pins that the "provision" alias still parses +// (TestParse's "provision" cases) while staying out of the command list a +// user reads with --help, so the help surface names only the one spelling +// ("apply") a new user should reach for. +func TestProvisionIsHiddenFromHelp(t *testing.T) { + a, err := Parse([]string{"--help"}) + if err != nil { + t.Fatal(err) + } + for _, line := range strings.Split(a.Help, "\n") { + fields := strings.Fields(line) + if len(fields) > 0 && fields[0] == "provision" { + t.Errorf("help lists the hidden provision alias: %q", line) + } + } +} diff --git a/internal/cli/run_access.go b/internal/cli/run_access.go index 3070298..b563976 100644 --- a/internal/cli/run_access.go +++ b/internal/cli/run_access.go @@ -3,12 +3,10 @@ package cli import ( "bytes" "context" - "errors" "fmt" "io" "os" "os/exec" - "path/filepath" "strings" "syscall" "time" @@ -121,84 +119,6 @@ func runSSH(a *Args, stdout, stderr io.Writer) int { return ExitOK // unreachable on success: the process image is gone } -// runProvision runs sshx.Provision (which does the actual work and writes -// last-provision.log) in the background while polling that same file and -// copying new bytes to stdout, so the CLI shows live output without any -// duplicated provisioning logic. -func runProvision(a *Args, stdout, stderr io.Writer) int { - v, err := config.Load(a.VM) - if err != nil { - return a.fail(stdout, stderr, err) - } - if v.Mode == "cloud" { - // cloud-init's packages: list runs only at first boot, baked into - // the seed when the overlay was created. There is nothing left for - // ssh-based provisioning to do, and a cloud recipe is #cloud-config - // YAML, not a shell script, so piping it into `sh -s` would fail. - const reason = "cloud VM: recipes are applied by cloud-init at first boot; recreate the VM to change them" - if a.JSON { - // A consumer has to be able to tell "recipes ran" from "there was - // nothing to run" without reading English prose. - return a.ok(stdout, map[string]any{ - "vm": a.VM, "provisioned": false, "skipped_reason": reason, - }) - } - fmt.Fprintf(stdout, "%s is a cloud VM: recipes are applied automatically via cloud-init at first boot; recreate the VM to change them.\n", a.VM) - return ExitOK - } - if !a.Quiet { - fmt.Fprintf(stdout, "provisioning %s...\n", a.VM) - } - - // No cancellation source reaches here yet: runProvision has no signal - // handling of its own, so this is a call site noted for the caller to - // decide whether Ctrl-C should cancel an in-flight provision, not a - // design decision made here. - logPath := filepath.Join(v.Dir, "last-provision.log") - done := make(chan error, 1) - // core.WithProvisionLock, not core.Apply: this path drives sshx.Provision - // directly (its cloud-VM handling and JSON shape differ from Apply's), so - // it takes the same per-VM lock itself instead of going through Apply. - go func() { - done <- core.WithProvisionLock(v.Dir, func() error { - return sshx.Provision(context.Background(), v) - }) - }() - - // Under --json the recipe's raw bytes must not reach stdout: they would sit - // in the middle of the JSON Lines stream and every consumer's json.loads - // would fail on them. Each appended line becomes a "log" event instead. - out := stdout - var lw *jsonLogWriter - if a.JSON { - lw = &jsonLogWriter{em: wire.NewEmitter(stdout), cmd: a.Cmd} - out = lw - } - perr := streamFile(logPath, out, done) - if lw != nil { - lw.Flush() - } - if errors.Is(perr, core.ErrProvisionInProgress) { - // Another run already holds the VM's provision lock; that run owns - // the error, so this one exits clean instead of reporting it too. - if a.JSON { - return a.ok(stdout, map[string]any{"vm": a.VM, "provisioned": false, "skipped_reason": "provision already running"}) - } - fmt.Fprintf(stdout, "%s: provision already running\n", a.VM) - return ExitOK - } - if perr != nil { - return a.fail(stdout, stderr, perr) - } - if a.JSON { - return a.ok(stdout, map[string]any{ - "vm": a.VM, "provisioned": true, "skipped_reason": "", - }) - } - fmt.Fprintf(stdout, "%s provisioned\n", a.VM) - return ExitOK -} - // jsonLogWriter wraps appended log bytes as one "log" event per line. type jsonLogWriter struct { em *wire.Emitter diff --git a/internal/cli/run_apply.go b/internal/cli/run_apply.go index 3a80269..408383a 100644 --- a/internal/cli/run_apply.go +++ b/internal/cli/run_apply.go @@ -10,9 +10,9 @@ import ( "github.com/novusedge/stoat/internal/core" ) -// runApply runs a VM's recipes and streams their output live, the same -// pattern as runProvision (run_access.go): the work happens in a goroutine -// while the apply log file is tailed and copied out as it grows. +// runApply runs a VM's recipes and streams their output live: the work +// happens in a goroutine while the apply log file is tailed and copied out +// as it grows. It also serves the "provision" alias (cli.go's dispatch). // // core.Get, not config.Load: the load is only here for the log path to tail // and the VM's recipe list, but config.Load returns an untyped error, so a @@ -38,7 +38,7 @@ func runApply(a *Args, stdout, stderr io.Writer) int { // Under --json, raw log bytes must not reach stdout: they would sit // inside the JSON Lines stream and break every consumer's parse. Each - // appended line becomes a "log" event instead, same as runProvision. + // appended line becomes a "log" event instead. out := stdout var lw *jsonLogWriter if a.JSON { @@ -54,9 +54,9 @@ func runApply(a *Args, stdout, stderr io.Writer) int { // caller owns the error; this one exits clean rather than reporting // somebody else's concurrent apply as its own failure. if a.JSON { - return a.ok(stdout, map[string]any{"vm": a.VM, "applied": false, "skipped_reason": "provision already running"}) + return a.ok(stdout, map[string]any{"vm": a.VM, "applied": false, "skipped_reason": "an apply is already running"}) } - fmt.Fprintf(stdout, "%s: provision already running\n", a.VM) + fmt.Fprintf(stdout, "%s: an apply is already running\n", a.VM) return ExitOK } if aerr != nil { diff --git a/internal/cli/run_vm.go b/internal/cli/run_vm.go index 931b3da..70c0e9a 100644 --- a/internal/cli/run_vm.go +++ b/internal/cli/run_vm.go @@ -96,11 +96,11 @@ func runUp(a *Args, stdout, stderr io.Writer) int { } // afterStart runs v's pending recipes once it answers ssh, the same -// core.Apply path `stoat provision` uses (run_access.go's runProvision). -// --no-provision, or a VM with nothing pending, returns immediately: `up` +// core.Apply path `stoat apply` uses (run_apply.go's runApply). +// --no-apply, or a VM with nothing pending, returns immediately: `up` // does not block a VM that has no work waiting. func afterStart(a *Args, v core.VM, stdout, stderr io.Writer) int { - if a.NoProvision { + if a.NoApply { return ExitOK } cfg, err := config.Load(v.Name) @@ -125,21 +125,21 @@ func afterStart(a *Args, v core.VM, stdout, stderr io.Writer) int { } if !a.Quiet { - fmt.Fprintf(stdout, "provisioning %s...\n", a.VM) + fmt.Fprintf(stdout, "applying recipes to %s...\n", a.VM) } done := make(chan error, 1) go func() { done <- core.Apply(context.Background(), a.VM, core.ApplyOpts{}) }() if err := streamFile(v.Paths.ApplyLog, stdout, done); err != nil { if errors.Is(err, core.ErrProvisionInProgress) { - // A concurrent `apply` or `provision` already holds the lock; that - // run owns the error. `up` still started the VM, so this is not a - // failure of the up command. - fmt.Fprintf(stdout, "%s: provision already running\n", a.VM) + // A concurrent `apply` already holds the lock; that run owns the + // error. `up` still started the VM, so this is not a failure of + // the up command. + fmt.Fprintf(stdout, "%s: an apply is already running\n", a.VM) return ExitOK } return a.fail(stdout, stderr, err) } - fmt.Fprintf(stdout, "%s provisioned\n", a.VM) + fmt.Fprintf(stdout, "%s: recipes applied\n", a.VM) return ExitOK } diff --git a/internal/cli/run_vm_test.go b/internal/cli/run_vm_test.go index df96c5b..d9fec44 100644 --- a/internal/cli/run_vm_test.go +++ b/internal/cli/run_vm_test.go @@ -12,8 +12,8 @@ import ( // afterStart is exercised directly rather than through runUp, which would // need core.Start to actually launch qemu. fakeRunning marks the VM as // running the same way apply_test.go does, so afterStart's own decision -// (core.NeedsProvision, --no-provision) is tested without a real boot or a -// real ssh wait. +// (core.NeedsProvision, --no-apply) is tested without a real boot or a real +// ssh wait. func startedVM(t *testing.T, name string, patch func(*config.VM)) core.VM { t.Helper() dir := cliRoot(t) @@ -45,26 +45,26 @@ func TestAfterStartSkipsWhenNothingPending(t *testing.T) { if code != ExitOK { t.Fatalf("code = %d, want ExitOK: %s", code, errOut.String()) } - if strings.Contains(out.String(), "provisioning") || strings.Contains(out.String(), "waiting for ssh") { + if strings.Contains(out.String(), "applying recipes") || strings.Contains(out.String(), "waiting for ssh") { t.Errorf("waited on a VM with nothing pending: %q", out.String()) } } -// TestAfterStartNoProvisionSkipsEvenWithRecipesPending: --no-provision must -// win over core.NeedsProvision, not just apply when there is nothing to do. -func TestAfterStartNoProvisionSkipsEvenWithRecipesPending(t *testing.T) { +// TestAfterStartNoApplySkipsEvenWithRecipesPending: --no-apply must win over +// core.NeedsProvision, not just apply when there is nothing to do. +func TestAfterStartNoApplySkipsEvenWithRecipesPending(t *testing.T) { v := startedVM(t, "work", func(v *config.VM) { v.Recipes = []string{"xfce.alpine.sh"} }) var out, errOut strings.Builder - a := &Args{Cmd: "up", VM: "work", NoProvision: true} + a := &Args{Cmd: "up", VM: "work", NoApply: true} code := afterStart(a, v, &out, &errOut) if code != ExitOK { t.Fatalf("code = %d, want ExitOK: %s", code, errOut.String()) } - if strings.Contains(out.String(), "provisioning") || strings.Contains(out.String(), "waiting for ssh") { - t.Errorf("--no-provision did not skip provisioning: %q", out.String()) + if strings.Contains(out.String(), "applying recipes") || strings.Contains(out.String(), "waiting for ssh") { + t.Errorf("--no-apply did not skip the apply: %q", out.String()) } } diff --git a/internal/core/lock.go b/internal/core/lock.go index 4d20dcb..df62676 100644 --- a/internal/core/lock.go +++ b/internal/core/lock.go @@ -24,9 +24,7 @@ var ErrProvisionInProgress = errors.New("provision already in progress") // when the kernel closes its file descriptors, so a lock file left on disk // after a crash never blocks the next run. The file itself is never removed. // -// Apply is one caller. internal/cli's runProvision is the other: it drives -// sshx.Provision directly rather than through Apply, so it takes this lock -// itself around that call. +// Apply is the only caller: it takes this lock itself around sshx.Provision. func WithProvisionLock(dir string, fn func() error) error { path := filepath.Join(dir, "provision.lock") f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o644) diff --git a/internal/tui/app.go b/internal/tui/app.go index 8dd5188..58663a3 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -358,14 +358,14 @@ func (m model) updateApp(msg tea.Msg) (tea.Model, tea.Cmd) { // process. This is the cross-process case: another stoat process (a // second TUI, or the CLI) holds the VM's lock. Not an error toast, // since the user did nothing wrong. - cmd := m.showToast("provision already running for "+msg.name, false) + cmd := m.showToast(msg.name+": recipes already applying", false) return m, cmd } if msg.err != nil { cmd := m.showToast(msg.name+": "+msg.err.Error(), true) return m, cmd } - cmd := m.showToast(msg.name+" provisioned", false) + cmd := m.showToast(msg.name+": recipes applied", false) return m, cmd case vmSavedMsg: // Adopt the saved VM only now. saveEdit returns a statusMsg on diff --git a/internal/tui/detail.go b/internal/tui/detail.go index d81ff8f..a827577 100644 --- a/internal/tui/detail.go +++ b/internal/tui/detail.go @@ -470,7 +470,7 @@ func (m model) viewDetail() string { if m.detail.log != "" { // lipgloss re-applies the style on every line of a multi-line string, // so the log needs no per-line loop of its own. - parts = append(parts, "", paneAt("last provision", dimStyle.Render(m.detail.log), appContentWidth, m.width)) + parts = append(parts, "", paneAt("last apply", dimStyle.Render(m.detail.log), appContentWidth, m.width)) } if panel := progressPanel(m); panel != "" { diff --git a/internal/tui/keymap.go b/internal/tui/keymap.go index a3fc65c..9e2beb3 100644 --- a/internal/tui/keymap.go +++ b/internal/tui/keymap.go @@ -95,7 +95,7 @@ func (h listHelp) ShortHelp() []key.Binding { plainKey([]string{"enter"}, "↵", "start/stop"), plainKey([]string{"right", "l"}, "→/l", "details"), h.ssh(), - plainKey([]string{"p"}, "p", "provision"), + plainKey([]string{"p"}, "p", "apply"), plainKey([]string{"/"}, "/", "search"), plainKey([]string{"n"}, "n", "new"), plainKey([]string{"r"}, "r", "recipes"), @@ -109,7 +109,7 @@ func (h listHelp) FullHelp() [][]key.Binding { return [][]key.Binding{ {plainKey([]string{"k", "up"}, "k/↑", "up"), plainKey([]string{"j", "down"}, "j/↓", "down")}, {plainKey([]string{"enter"}, "↵", "start/stop"), plainKey([]string{"right", "l"}, "→/l", "details")}, - {h.ssh(), plainKey([]string{"p"}, "p", "provision")}, + {h.ssh(), plainKey([]string{"p"}, "p", "apply")}, {plainKey([]string{"/"}, "/", "search by name"), plainKey([]string{"esc"}, "esc", "clear search")}, {plainKey([]string{"n"}, "n", "new"), plainKey([]string{"d"}, "d", "delete")}, {plainKey([]string{"r"}, "r", "edit recipes in $EDITOR")}, @@ -138,7 +138,7 @@ func (h detailHelp) ShortHelp() []key.Binding { plainKey([]string{"i"}, "i", "installed"), plainKey([]string{"d"}, "d", "display"), h.ssh(), - plainKey([]string{"p"}, "p", "provision"), + plainKey([]string{"p"}, "p", "apply"), plainKey([]string{"S"}, "S", "snapshots"), } if h.consolePassword { @@ -158,7 +158,7 @@ func (h detailHelp) FullHelp() [][]key.Binding { {plainKey([]string{"e"}, "e", "edit form"), plainKey([]string{"E"}, "E", "raw vm.toml in $EDITOR")}, {plainKey([]string{"i"}, "i", "installed"), plainKey([]string{"d"}, "d", "cycle display: auto/window/vnc")}, {h.ssh()}, - {plainKey([]string{"p"}, "p", "provision"), plainKey([]string{"L"}, "L", "console log")}, + {plainKey([]string{"p"}, "p", "apply"), plainKey([]string{"L"}, "L", "console log")}, {plainKey([]string{"S"}, "S", "snapshots")}, } if h.consolePassword { diff --git a/internal/tui/provision.go b/internal/tui/provision.go index c751ba9..6ddf041 100644 --- a/internal/tui/provision.go +++ b/internal/tui/provision.go @@ -78,7 +78,7 @@ func (m *model) startProvision(v core.VM) tea.Cmd { // core.Apply refuses the same state with ErrAppliedAtBoot. This check // shows the user the refusal before anything starts, instead of // after a failed attempt. - return m.showToast(v.Name+": cloud VMs provision at first boot via cloud-init. Recipes are applied automatically; recreate the VM to change them", true) + return m.showToast(v.Name+": cloud VMs apply recipes at first boot via cloud-init. Recreate the VM to change them", true) } // A disk VM still boots its installer ISO until its OS is on disk. sshd // there belongs to the installer, not the system being built, so @@ -88,13 +88,13 @@ func (m *model) startProvision(v core.VM) tea.Cmd { if v.Mode == "disk" && !v.Installed { if v.Backend == "apkovl" { return m.showToast(v.Name+": installing itself; wait for it to finish and reboot, "+ - "then stoat notices the install and offers to provision", true) + "then stoat notices the install and offers to apply recipes", true) } return m.showToast(v.Name+": not installed yet, run "+installerName(v.OS)+ " at the console, then stop and start it", true) } if len(v.Recipes) == 0 { - return m.showToast(v.Name+": no recipes selected, nothing to provision", true) + return m.showToast(v.Name+": no recipes selected, nothing to apply", true) } if _, running := m.provisioning[v.Name]; running { return nil diff --git a/internal/tui/provstep_test.go b/internal/tui/provstep_test.go index 8f8a985..97c30a6 100644 --- a/internal/tui/provstep_test.go +++ b/internal/tui/provstep_test.go @@ -134,7 +134,7 @@ func TestProvisionRefusedUntilInstalled(t *testing.T) { if len(m.provisioning) != 0 { t.Error("the VM was marked as provisioning anyway") } - for _, want := range []string{"installing itself", "reboot", "provision"} { + for _, want := range []string{"installing itself", "reboot", "apply"} { if !strings.Contains(m.toast.text, want) { t.Errorf("toast = %q, missing %q", m.toast.text, want) } diff --git a/internal/tui/toast_test.go b/internal/tui/toast_test.go index d24ec41..83d028d 100644 --- a/internal/tui/toast_test.go +++ b/internal/tui/toast_test.go @@ -23,16 +23,28 @@ func TestToastOverlayKeepsScreenShape(t *testing.T) { withToast := m.View().Content if got, want := lipgloss.Height(withToast), lipgloss.Height(plain); got != want { - t.Errorf("height %d, want %d", got, want) + t.Errorf("height %d, want %d: the toast added or removed a row", got, want) } - if got, want := lipgloss.Width(withToast), lipgloss.Width(plain); got != want { - t.Errorf("width %d, want %d", got, want) + // lipgloss v2's compositor trims trailing whitespace off a line it draws + // over, so a shorter footer label changes a line's trailing spaces + // without changing what a terminal shows. Trim both sides before + // comparing width, so the real invariant (no wrap) survives that. + if got, want := lipgloss.Width(trimTrailingSpaces(withToast)), lipgloss.Width(trimTrailingSpaces(plain)); got != want { + t.Errorf("width %d, want %d: the toast changed the visible column count", got, want) } if !strings.Contains(withToast, "vm1 stopped") { t.Error("toast text missing from the rendered screen") } } +func trimTrailingSpaces(s string) string { + lines := strings.Split(s, "\n") + for i, l := range lines { + lines[i] = strings.TrimRight(l, " ") + } + return strings.Join(lines, "\n") +} + // A toast replaced by a newer one must not be retired by the older one's // timer, or the second toast vanishes early. func TestStaleToastTimerDoesNotClearTheCurrentToast(t *testing.T) {