From b9e0e81929d4f409564b5db4c57c070864ea8e86 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Fri, 4 Sep 2026 23:49:30 +0300 Subject: [PATCH 1/9] ci: add golangci-lint and shellcheck jobs Action majors move to v6 so the runner stops warning about Node 20. Every errcheck and staticcheck finding from the lint run is fixed at the call site, not suppressed through config. Signed-off-by: NovusEdge --- .github/workflows/ci.yml | 30 ++++++++++++-- .golangci.yml | 8 ++++ cmd/installer/main_linux.go | 2 +- internal/apkovl/apkovl.go | 6 +-- internal/apkovl/apkovl_test.go | 4 +- internal/backend/cloudinit.go | 2 +- internal/cli/cli.go | 20 +++++----- internal/cli/run_access.go | 20 +++++----- internal/cli/run_apply.go | 8 ++-- internal/cli/run_get.go | 30 +++++++------- internal/cli/run_image.go | 12 +++--- internal/cli/run_misc.go | 16 ++++---- internal/cli/run_recipes.go | 10 ++--- internal/cli/run_state.go | 34 ++++++++-------- internal/cli/run_update.go | 4 +- internal/cli/run_vm.go | 60 ++++++++++++++-------------- internal/cli/run_wait.go | 2 +- internal/cloudinit/cloudinit.go | 8 ++-- internal/cloudinit/cloudinit_test.go | 2 +- internal/cloudinit/scripts.go | 4 +- internal/config/config.go | 4 +- internal/config/config_test.go | 2 +- internal/config/lock.go | 6 +-- internal/core/access_test.go | 10 ++--- internal/core/apply.go | 4 +- internal/core/apply_test.go | 14 +++---- internal/core/clone.go | 2 +- internal/core/core.go | 2 +- internal/core/lock.go | 4 +- internal/core/vm_test.go | 2 +- internal/core/wait.go | 4 +- internal/core/wait_test.go | 8 ++-- internal/hostcheck/kvm_linux.go | 2 +- internal/installer/build.go | 6 +-- internal/installer/tui.go | 4 +- internal/iso/iso.go | 22 +++++----- internal/iso/iso_test.go | 16 ++++---- internal/keys/keys.go | 4 +- internal/logx/logx_test.go | 2 +- internal/qemu/qmp.go | 12 +++--- internal/qemu/run.go | 12 +++--- internal/qemu/sendkey.go | 4 +- internal/sshx/sharemount.go | 4 +- internal/sshx/sshx.go | 34 ++++++++-------- internal/sshx/sshx_test.go | 12 +++--- internal/testutil/fakevm.go | 12 +++--- internal/tui/app.go | 2 +- internal/tui/detail.go | 6 +-- internal/tui/detail_test.go | 2 +- internal/tui/edit.go | 6 --- internal/tui/imagemodal.go | 8 ++-- internal/tui/imagemodal_test.go | 2 +- internal/tui/list.go | 4 +- internal/tui/logpager.go | 2 +- internal/tui/provstep.go | 2 +- internal/tui/snapshots.go | 2 +- internal/tui/theme.go | 5 --- internal/tui/vmlist.go | 4 +- justfile | 6 +++ 59 files changed, 282 insertions(+), 259 deletions(-) create mode 100644 .golangci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 39bdd623..a76482b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,8 +12,8 @@ jobs: name: check runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version: '1.26' @@ -35,6 +35,30 @@ jobs: - name: test run: go test ./... + lint: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version: '1.26' + - uses: golangci/golangci-lint-action@v9 + with: + version: v2.13.2 + + shellcheck: + name: shellcheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: install + run: sudo apt-get update -q && sudo apt-get install -y -q shellcheck + # Warning level: an info-level note about a quoted variable in a + # bundled installer is not worth a red PR. + - name: shellcheck + run: shellcheck -S warning $(git ls-files 'internal/recipes/bundled/*.sh' 'internal/recipes/bundled/*/*.sh' 'scripts/*.sh' '.githooks/*') + # A separate job because it needs Python rather than Go, and because a # failure here should read as "the MCP server broke", not "the Go suite # broke". Until this existed, mcp/'s tests ran only on a contributor's @@ -48,7 +72,7 @@ jobs: run: working-directory: mcp steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: actions/setup-python@v5 with: python-version: '3.12' diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 00000000..34ab4506 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,8 @@ +version: "2" +linters: + enable: + - errcheck + - staticcheck + - govet + - unused + - misspell diff --git a/cmd/installer/main_linux.go b/cmd/installer/main_linux.go index e12bcbf8..7a80afdd 100644 --- a/cmd/installer/main_linux.go +++ b/cmd/installer/main_linux.go @@ -63,7 +63,7 @@ func runHeadless(repoDir, home string) int { fmt.Fprintln(os.Stderr, "error:", err) return 1 } - defer os.RemoveAll(tmp) + defer func() { _ = os.RemoveAll(tmp) }() outPath := filepath.Join(tmp, "stoat") if err := installer.Build(repoDir, version, outPath); err != nil { diff --git a/internal/apkovl/apkovl.go b/internal/apkovl/apkovl.go index cc508956..15ade204 100644 --- a/internal/apkovl/apkovl.go +++ b/internal/apkovl/apkovl.go @@ -157,14 +157,14 @@ func Build(v *config.VM) error { tmp := filepath.Join(v.OvlDir(), tmpName) // Remove any temp file orphaned by a crash mid-Build before tmpName // existed: unlike tmpName, it matches the initramfs's glob. - os.Remove(filepath.Join(v.OvlDir(), legacyTmpName)) + _ = os.Remove(filepath.Join(v.OvlDir(), legacyTmpName)) f, err := os.Create(tmp) if err != nil { return err } defer func() { - f.Close() - os.Remove(tmp) + _ = f.Close() + _ = os.Remove(tmp) }() gz := gzip.NewWriter(f) tw := tar.NewWriter(gz) diff --git a/internal/apkovl/apkovl_test.go b/internal/apkovl/apkovl_test.go index 322e7db4..64cbd564 100644 --- a/internal/apkovl/apkovl_test.go +++ b/internal/apkovl/apkovl_test.go @@ -19,7 +19,7 @@ func entries(t *testing.T, path string) (map[string]string, map[string]*tar.Head if err != nil { t.Fatal(err) } - defer f.Close() + defer func() { _ = f.Close() }() gz, err := gzip.NewReader(f) if err != nil { t.Fatal(err) @@ -219,7 +219,7 @@ func TestBuildFailureLeavesGoodOverlayIntact(t *testing.T) { if err := os.Chmod(v.OvlDir(), 0o555); err != nil { t.Fatalf("chmod ovl dir: %v", err) } - defer os.Chmod(v.OvlDir(), 0o755) + defer func() { _ = os.Chmod(v.OvlDir(), 0o755) }() if err := Build(v); err == nil { t.Fatal("expected Build to fail when the ovl dir is read-only") diff --git a/internal/backend/cloudinit.go b/internal/backend/cloudinit.go index cb5b18d8..129ff54a 100644 --- a/internal/backend/cloudinit.go +++ b/internal/backend/cloudinit.go @@ -57,7 +57,7 @@ func (cloudinitBackend) Prepare(v *config.VM) error { if v.Disk != "" { out, err := exec.Command("qemu-img", "resize", v.DiskPath(), v.Disk).CombinedOutput() if err != nil { - os.Remove(v.DiskPath()) + _ = os.Remove(v.DiskPath()) return fmt.Errorf("qemu-img resize to %s: %s", v.Disk, strings.TrimSpace(string(out))) } } diff --git a/internal/cli/cli.go b/internal/cli/cli.go index cf9f342c..92ab393e 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -266,7 +266,7 @@ func (a *Args) fail(stdout, stderr io.Writer, err error) int { _ = wire.NewEmitter(stdout).ResultErr(a.Cmd, wire.MapError(err)) return ExitFail } - fmt.Fprintf(stderr, "stoat: %s: %v\n", a.Cmd, err) + _, _ = fmt.Fprintf(stderr, "stoat: %s: %v\n", a.Cmd, err) return ExitFail } @@ -278,7 +278,7 @@ func (a *Args) failMsg(stdout, stderr io.Writer, sentinel error, msg string) int _ = wire.NewEmitter(stdout).ResultErr(a.Cmd, wire.MapError(fmt.Errorf("%w: %s", sentinel, msg))) return ExitFail } - fmt.Fprintf(stderr, "stoat: %s: %s\n", a.Cmd, msg) + _, _ = fmt.Fprintf(stderr, "stoat: %s: %s\n", a.Cmd, msg) return ExitFail } @@ -298,7 +298,7 @@ func Main(args []string, version string, stdin io.Reader, stdout, stderr io.Writ // or a dead process, never a silent exit with neither. defer func() { if r := recover(); r != nil { - fmt.Fprintf(stderr, "panic: %v\n", r) + _, _ = fmt.Fprintf(stderr, "panic: %v\n", r) _ = wire.NewEmitter(stdout).ResultErr(cmd, wire.InternalError(fmt.Sprintf("panic: %v", r))) code = ExitFail } @@ -311,8 +311,8 @@ func Main(args []string, version string, stdin io.Reader, stdout, stderr io.Writ _ = wire.NewEmitter(stdout).ResultErr(cmd, wire.UsageError(err.Error())) return ExitUsage } - fmt.Fprintln(stderr, "stoat:", err) - fmt.Fprintln(stderr, helpText()) + _, _ = fmt.Fprintln(stderr, "stoat:", err) + _, _ = fmt.Fprintln(stderr, helpText()) return ExitUsage } a.JSON = jsonMode @@ -328,13 +328,13 @@ func Main(args []string, version string, stdin io.Reader, stdout, stderr io.Writ if a.JSON { return a.ok(stdout, map[string]any{"usage": a.Help}) } - fmt.Fprintln(stdout, a.Help) + _, _ = fmt.Fprintln(stdout, a.Help) return ExitOK case "version": if a.JSON { return a.ok(stdout, map[string]any{"version": version, "contract": wire.ContractVersion}) } - fmt.Fprintln(stdout, "stoat", version) + _, _ = fmt.Fprintln(stdout, "stoat", version) return ExitOK } @@ -347,7 +347,7 @@ func Main(args []string, version string, stdin io.Reader, stdout, stderr io.Writ _ = wire.NewEmitter(stdout).ResultErr(a.Cmd, wire.MapError(err)) return ExitFail } - fmt.Fprintln(stderr, "stoat:", err) + _, _ = fmt.Fprintln(stderr, "stoat:", err) return ExitFail } } @@ -355,7 +355,7 @@ func Main(args []string, version string, stdin io.Reader, stdout, stderr io.Writ // rather than failing the command the user actually asked for. `logs` // re-Inits and reports its own error, since there the log IS the command. _ = logx.Init() - defer logx.Close() + defer func() { _ = logx.Close() }() logx.L().Debug("cli", "cmd", a.Cmd, "vm", a.VM) switch a.Cmd { @@ -411,7 +411,7 @@ func Main(args []string, version string, stdin io.Reader, stdout, stderr io.Writ return runDoctor(a, stdout, stderr) default: // Unreachable: Parse already rejected anything not handled above. - fmt.Fprintln(stderr, "stoat: unknown subcommand", a.Cmd) + _, _ = fmt.Fprintln(stderr, "stoat: unknown subcommand", a.Cmd) return ExitUsage } } diff --git a/internal/cli/run_access.go b/internal/cli/run_access.go index b5639766..efd325ee 100644 --- a/internal/cli/run_access.go +++ b/internal/cli/run_access.go @@ -40,9 +40,9 @@ func runCopy(a *Args, stdout, stderr io.Writer) int { } if !a.Quiet { if a.ToRemote { - fmt.Fprintf(stdout, "copied %s to %s:%s\n", a.Local, a.VM, a.Remote) + _, _ = fmt.Fprintf(stdout, "copied %s to %s:%s\n", a.Local, a.VM, a.Remote) } else { - fmt.Fprintf(stdout, "copied %s:%s to %s\n", a.VM, a.Remote, a.Local) + _, _ = fmt.Fprintf(stdout, "copied %s:%s to %s\n", a.VM, a.Remote, a.Local) } } return ExitOK @@ -83,8 +83,8 @@ func runExec(a *Args, stdout, stderr io.Writer) int { } // Streamed through verbatim, on the matching stream, so a caller can pipe // stdout without stderr contaminating it. - fmt.Fprint(stdout, res.Stdout) - fmt.Fprint(stderr, res.Stderr) + _, _ = fmt.Fprint(stdout, res.Stdout) + _, _ = fmt.Fprint(stderr, res.Stderr) return res.ExitCode } @@ -103,17 +103,17 @@ func runSSH(a *Args, stdout, stderr io.Writer) int { } v, err := config.Load(a.VM) if err != nil { - fmt.Fprintln(stderr, "stoat: ssh:", err) + _, _ = fmt.Fprintln(stderr, "stoat: ssh:", err) return ExitFail } path, err := exec.LookPath("ssh") if err != nil { - fmt.Fprintln(stderr, "stoat: ssh:", err) + _, _ = fmt.Fprintln(stderr, "stoat: ssh:", err) return ExitFail } argv := append([]string{"ssh"}, sshx.Args(v)...) if err := syscall.Exec(path, argv, os.Environ()); err != nil { - fmt.Fprintln(stderr, "stoat: ssh:", err) + _, _ = fmt.Fprintln(stderr, "stoat: ssh:", err) return ExitFail } return ExitOK // unreachable on success: the process image is gone @@ -168,7 +168,7 @@ func streamFile(path string, out io.Writer, done <-chan error) error { for { select { case err := <-done: - offset = copyNew(path, out, offset) + copyNew(path, out, offset) return err case <-ticker.C: offset = copyNew(path, out, offset) @@ -181,7 +181,7 @@ func copyNew(path string, out io.Writer, offset int64) int64 { if err != nil { return offset } - defer f.Close() + defer func() { _ = f.Close() }() fi, err := f.Stat() if err != nil || fi.Size() <= offset { return offset @@ -189,6 +189,6 @@ func copyNew(path string, out io.Writer, offset int64) int64 { if _, err := f.Seek(offset, io.SeekStart); err != nil { return offset } - io.Copy(out, f) + _, _ = io.Copy(out, f) return fi.Size() } diff --git a/internal/cli/run_apply.go b/internal/cli/run_apply.go index 26c66f89..46958173 100644 --- a/internal/cli/run_apply.go +++ b/internal/cli/run_apply.go @@ -34,7 +34,7 @@ func runApply(a *Args, stdout, stderr io.Writer) int { } if !a.Quiet { - fmt.Fprintf(stdout, "applying recipes to %s...\n", a.VM) + _, _ = fmt.Fprintf(stdout, "applying recipes to %s...\n", a.VM) } done := make(chan error, 1) @@ -63,7 +63,7 @@ func runApply(a *Args, stdout, stderr io.Writer) int { // bool. skipped_reason is what distinguishes the two. return a.ok(stdout, map[string]any{"vm": a.VM, "applied": []string{}, "skipped_reason": "an apply is already running"}) } - fmt.Fprintf(stdout, "%s: an apply is already running\n", a.VM) + _, _ = fmt.Fprintf(stdout, "%s: an apply is already running\n", a.VM) return ExitOK } if aerr != nil { @@ -76,7 +76,7 @@ func runApply(a *Args, stdout, stderr io.Writer) int { } return a.ok(stdout, map[string]any{"vm": a.VM, "applied": applied, "skipped_reason": ""}) } - fmt.Fprintf(stdout, "%s: recipes applied\n", a.VM) + _, _ = fmt.Fprintf(stdout, "%s: recipes applied\n", a.VM) return ExitOK } @@ -103,7 +103,7 @@ func runApplyDryRun(a *Args, stdout, stderr io.Writer) int { if p.Version != "" { reason = fmt.Sprintf("%s at %s", p.Reason, p.Version) } - fmt.Fprintf(stdout, "%s (%s, %s)\n", p.Name, p.Action, reason) + _, _ = fmt.Fprintf(stdout, "%s (%s, %s)\n", p.Name, p.Action, reason) } return ExitOK } diff --git a/internal/cli/run_get.go b/internal/cli/run_get.go index c325b260..5b649b9f 100644 --- a/internal/cli/run_get.go +++ b/internal/cli/run_get.go @@ -17,29 +17,29 @@ func runGet(a *Args, stdout, stderr io.Writer) int { if a.JSON { return a.ok(stdout, map[string]any{"vm": wire.FromVM(v, core.GraphicalSession())}) } - fmt.Fprintf(stdout, "name: %s\n", v.Name) - fmt.Fprintf(stdout, "os: %s\n", v.OS) - fmt.Fprintf(stdout, "mode: %s\n", v.Mode) - fmt.Fprintf(stdout, "backend: %s\n", v.Backend) - fmt.Fprintf(stdout, "state: %s\n", v.State) - fmt.Fprintf(stdout, "cpus: %d\n", v.CPUs) - fmt.Fprintf(stdout, "ram: %d\n", v.RAM) - fmt.Fprintf(stdout, "disk: %s\n", v.Disk) - fmt.Fprintf(stdout, "share: %s\n", v.Share) - fmt.Fprintf(stdout, "ssh port: %d\n", v.SSHPort) - fmt.Fprintf(stdout, "ssh user: %s\n", v.SSHUser) - fmt.Fprintf(stdout, "recipes: %s\n", strings.Join(v.Recipes, ", ")) + _, _ = fmt.Fprintf(stdout, "name: %s\n", v.Name) + _, _ = fmt.Fprintf(stdout, "os: %s\n", v.OS) + _, _ = fmt.Fprintf(stdout, "mode: %s\n", v.Mode) + _, _ = fmt.Fprintf(stdout, "backend: %s\n", v.Backend) + _, _ = fmt.Fprintf(stdout, "state: %s\n", v.State) + _, _ = fmt.Fprintf(stdout, "cpus: %d\n", v.CPUs) + _, _ = fmt.Fprintf(stdout, "ram: %d\n", v.RAM) + _, _ = fmt.Fprintf(stdout, "disk: %s\n", v.Disk) + _, _ = fmt.Fprintf(stdout, "share: %s\n", v.Share) + _, _ = fmt.Fprintf(stdout, "ssh port: %d\n", v.SSHPort) + _, _ = fmt.Fprintf(stdout, "ssh user: %s\n", v.SSHUser) + _, _ = fmt.Fprintf(stdout, "recipes: %s\n", strings.Join(v.Recipes, ", ")) forwards := make([]string, len(v.Forwards)) for i, f := range v.Forwards { forwards[i] = fmt.Sprintf("%d:%d", f.HostPort, f.GuestPort) } - fmt.Fprintf(stdout, "forwards: %s\n", strings.Join(forwards, ", ")) + _, _ = fmt.Fprintf(stdout, "forwards: %s\n", strings.Join(forwards, ", ")) // The one line here that is not a vm.toml field: where the screen is. A // user who has lost their window asks `get` before they ask anything // else, and until now it was the one place that did not answer. printDisplay(stdout, core.DisplayFor(v, core.GraphicalSession())) if v.State == core.StateBroken { - fmt.Fprintf(stdout, "error: %s\n", v.Error) + _, _ = fmt.Fprintf(stdout, "error: %s\n", v.Error) } return ExitOK } @@ -52,6 +52,6 @@ func runSSHCommand(a *Args, stdout, stderr io.Writer) int { if a.JSON { return a.ok(stdout, map[string]any{"argv": argv}) } - fmt.Fprintln(stdout, strings.Join(argv, " ")) + _, _ = fmt.Fprintln(stdout, strings.Join(argv, " ")) return ExitOK } diff --git a/internal/cli/run_image.go b/internal/cli/run_image.go index dc2d09b2..c1ddecfd 100644 --- a/internal/cli/run_image.go +++ b/internal/cli/run_image.go @@ -22,7 +22,7 @@ func runImages(a *Args, stdout, stderr io.Writer) int { if a.JSON { return a.ok(stdout, map[string]any{"images": wire.FromCatalogImages(imgs)}) } - fmt.Fprintf(stdout, "%-16s %-9s %-11s %-10s %s\n", "ID", "OS", "VARIANT", "SIZE", "STATE") + _, _ = fmt.Fprintf(stdout, "%-16s %-9s %-11s %-10s %s\n", "ID", "OS", "VARIANT", "SIZE", "STATE") for _, i := range imgs { size := humanSize(i.Bytes) if !i.Exact { @@ -37,7 +37,7 @@ func runImages(a *Args, stdout, stderr io.Writer) int { id = i.File // a byo file has no catalog id state = "byo" } - fmt.Fprintf(stdout, "%-16s %-9s %-11s %-10s %s\n", id, i.OS, i.Variant, size, state) + _, _ = fmt.Fprintf(stdout, "%-16s %-9s %-11s %-10s %s\n", id, i.OS, i.Variant, size, state) } return ExitOK } @@ -50,7 +50,7 @@ func runPull(a *Args, stdout, stderr io.Writer) int { if a.JSON { em = wire.NewEmitter(stdout) } - var lastPct int = -1 + var lastPct = -1 // Both renderers fire only on a percentage CHANGE: a per-read event is // thousands of lines for one image, and a consumer gains nothing from them. progress := func(done, total int64) { @@ -68,7 +68,7 @@ func runPull(a *Args, stdout, stderr io.Writer) int { "id": a.VM, "done": done, "total": total, "percent": pct, }) case !a.Quiet: - fmt.Fprintf(stdout, "\r%s %3d%% %s / %s", a.VM, pct, humanSize(done), humanSize(total)) + _, _ = fmt.Fprintf(stdout, "\r%s %3d%% %s / %s", a.VM, pct, humanSize(done), humanSize(total)) } } res, err := core.DownloadImage(context.Background(), a.VM, progress) @@ -76,7 +76,7 @@ func runPull(a *Args, stdout, stderr io.Writer) int { if a.JSON { return a.fail(stdout, stderr, err) } - fmt.Fprintln(stderr, "\nstoat: pull:", err) + _, _ = fmt.Fprintln(stderr, "\nstoat: pull:", err) return ExitFail } if a.JSON { @@ -96,7 +96,7 @@ func runPull(a *Args, stdout, stderr io.Writer) int { if !res.ChecksumAvailable { note = ": UNVERIFIED (no published checksum)" } - fmt.Fprintf(stdout, "\r%s downloaded%s%s\n", a.VM, note, strings.Repeat(" ", 30)) + _, _ = fmt.Fprintf(stdout, "\r%s downloaded%s%s\n", a.VM, note, strings.Repeat(" ", 30)) } return ExitOK } diff --git a/internal/cli/run_misc.go b/internal/cli/run_misc.go index b351e9a7..7b6d6c99 100644 --- a/internal/cli/run_misc.go +++ b/internal/cli/run_misc.go @@ -72,7 +72,7 @@ func runLogs(a *Args, stdout, stderr io.Writer) int { return a.ok(stdout, map[string]any{"vm": a.VM, "which": string(a.Which), "lines": lines}) } for _, l := range lines { - fmt.Fprintln(stdout, l) + _, _ = fmt.Fprintln(stdout, l) } return ExitOK } @@ -91,7 +91,7 @@ func runLogs(a *Args, stdout, stderr io.Writer) int { return a.ok(stdout, map[string]any{"lines": lines}) } for _, l := range lines { - fmt.Fprintln(stdout, l) + _, _ = fmt.Fprintln(stdout, l) } return ExitOK } @@ -141,13 +141,13 @@ func runRecipe(a *Args, stdout, stderr io.Writer) int { } return a.ok(stdout, map[string]any{"dir": recipes.Dir(), "recipes": names}) } - fmt.Fprintln(stdout, recipes.Dir()) + _, _ = fmt.Fprintln(stdout, recipes.Dir()) if len(names) == 0 { - fmt.Fprintln(stdout, " (none)") + _, _ = fmt.Fprintln(stdout, " (none)") return ExitOK } for _, n := range names { - fmt.Fprintln(stdout, " "+n) + _, _ = fmt.Fprintln(stdout, " "+n) } return ExitOK @@ -159,9 +159,9 @@ func runRecipe(a *Args, stdout, stderr io.Writer) int { if a.JSON { return a.ok(stdout, map[string]any{"path": path}) } - fmt.Fprintln(stdout, path) + _, _ = fmt.Fprintln(stdout, path) if !a.Quiet { - fmt.Fprintln(stdout, "edit it, then pick it in the new-vm form for a matching vm") + _, _ = fmt.Fprintln(stdout, "edit it, then pick it in the new-vm form for a matching vm") } return ExitOK } @@ -170,6 +170,6 @@ func runRecipe(a *Args, stdout, stderr io.Writer) int { _ = wire.NewEmitter(stdout).ResultErr(a.Cmd, wire.UsageError("recipe: unknown action "+a.Sub)) return ExitUsage } - fmt.Fprintln(stderr, "stoat: recipe: unknown action", a.Sub) + _, _ = fmt.Fprintln(stderr, "stoat: recipe: unknown action", a.Sub) return ExitUsage } diff --git a/internal/cli/run_recipes.go b/internal/cli/run_recipes.go index 3da2d1f1..0dd8d25c 100644 --- a/internal/cli/run_recipes.go +++ b/internal/cli/run_recipes.go @@ -79,12 +79,12 @@ func runRecipes(a *Args, stdout, stderr io.Writer) int { return a.ok(stdout, map[string]any{"recipes": wire.FromRecipes(rs)}) } if len(rs) == 0 { - fmt.Fprintln(stdout, "no recipes") + _, _ = fmt.Fprintln(stdout, "no recipes") return ExitOK } - fmt.Fprintf(stdout, "%-30s %s\n", "NAME", "DESCRIPTION") + _, _ = fmt.Fprintf(stdout, "%-30s %s\n", "NAME", "DESCRIPTION") for _, r := range rs { - fmt.Fprintf(stdout, "%-30s %s\n", r.Name, r.Description) + _, _ = fmt.Fprintf(stdout, "%-30s %s\n", r.Name, r.Description) } return ExitOK } @@ -108,11 +108,11 @@ func runCheckRecipes(a *Args, stdout, stderr io.Writer) int { }) } if len(issues) == 0 { - fmt.Fprintln(stdout, "all applicable") + _, _ = fmt.Fprintln(stdout, "all applicable") return ExitOK } for _, i := range issues { - fmt.Fprintf(stdout, "%s: %s\n", i.Name, i.Reason) + _, _ = fmt.Fprintf(stdout, "%s: %s\n", i.Name, i.Reason) } return ExitOK } diff --git a/internal/cli/run_state.go b/internal/cli/run_state.go index bb5f3434..ce10acb4 100644 --- a/internal/cli/run_state.go +++ b/internal/cli/run_state.go @@ -55,11 +55,11 @@ func runForward(a *Args, stdout, stderr io.Writer) int { }) } if len(v.Forwards) == 0 { - fmt.Fprintf(stdout, "%s has no port forwards\n", a.VM) + _, _ = fmt.Fprintf(stdout, "%s has no port forwards\n", a.VM) return ExitOK } for _, f := range v.Forwards { - fmt.Fprintf(stdout, "%d:%d\n", f.HostPort, f.GuestPort) + _, _ = fmt.Fprintf(stdout, "%d:%d\n", f.HostPort, f.GuestPort) } return ExitOK } @@ -88,14 +88,14 @@ func runForward(a *Args, stdout, stderr io.Writer) int { } switch { case a.Clear: - fmt.Fprintf(stdout, "cleared %s's port forwards\n", a.VM) + _, _ = fmt.Fprintf(stdout, "cleared %s's port forwards\n", a.VM) default: for _, f := range a.Forwards { - fmt.Fprintf(stdout, "%d:%d\n", f.HostPort, f.GuestPort) + _, _ = fmt.Fprintf(stdout, "%d:%d\n", f.HostPort, f.GuestPort) } } if !active { - fmt.Fprintf(stdout, "%s is running; this takes effect at next start\n", a.VM) + _, _ = fmt.Fprintf(stdout, "%s is running; this takes effect at next start\n", a.VM) } return ExitOK } @@ -115,14 +115,14 @@ func runPrune(a *Args, stdout, stderr io.Writer) int { }) } if len(removed) == 0 { - fmt.Fprintln(stdout, "nothing to prune") + _, _ = fmt.Fprintln(stdout, "nothing to prune") return ExitOK } for _, r := range removed { - fmt.Fprintln(stdout, prunePrefix(r.Class)+r.Path) + _, _ = fmt.Fprintln(stdout, prunePrefix(r.Class)+r.Path) } if a.Prune.DryRun { - fmt.Fprintln(stdout, "\n(dry run: nothing was deleted; re-run with --apply)") + _, _ = fmt.Fprintln(stdout, "\n(dry run: nothing was deleted; re-run with --apply)") } return ExitOK } @@ -170,11 +170,11 @@ func runSnapshot(a *Args, stdout, stderr io.Writer) int { if !a.Quiet { switch action { case "restore": - fmt.Fprintf(stdout, "%s restored to %s\n", a.VM, a.Tag) + _, _ = fmt.Fprintf(stdout, "%s restored to %s\n", a.VM, a.Tag) case "delete": - fmt.Fprintf(stdout, "deleted %s\n", a.Tag) + _, _ = fmt.Fprintf(stdout, "deleted %s\n", a.Tag) case "save": - fmt.Fprintf(stdout, "saved %s\n", a.Tag) + _, _ = fmt.Fprintf(stdout, "saved %s\n", a.Tag) } } return ExitOK @@ -188,16 +188,16 @@ func runSnapshot(a *Args, stdout, stderr io.Writer) int { return a.ok(stdout, map[string]any{"vm": a.VM, "snapshots": wire.FromSnapshots(snaps)}) } if len(snaps) == 0 { - fmt.Fprintf(stdout, "%s has no snapshots\n", a.VM) + _, _ = fmt.Fprintf(stdout, "%s has no snapshots\n", a.VM) return ExitOK } - fmt.Fprintf(stdout, "%-24s %-10s %-20s %s\n", "TAG", "SIZE", "CREATED", "RAM") + _, _ = fmt.Fprintf(stdout, "%-24s %-10s %-20s %s\n", "TAG", "SIZE", "CREATED", "RAM") for _, s := range snaps { ram := "no" if s.VMState { ram = "yes" } - fmt.Fprintf(stdout, "%-24s %-10s %-20s %s\n", s.Tag, s.Size, s.Created, ram) + _, _ = fmt.Fprintf(stdout, "%-24s %-10s %-20s %s\n", s.Tag, s.Size, s.Created, ram) } return ExitOK } @@ -227,13 +227,13 @@ func runDoctor(a *Args, stdout, stderr io.Writer) int { }) } if len(failed) == 0 { - fmt.Fprintln(stdout, "ok") + _, _ = fmt.Fprintln(stdout, "ok") return ExitOK } for _, c := range failed { - fmt.Fprintf(stdout, "FAIL: %s: %s\n", c.Name, c.Detail) + _, _ = fmt.Fprintf(stdout, "FAIL: %s: %s\n", c.Name, c.Detail) if len(c.Fix) > 0 { - fmt.Fprintf(stdout, " try: %s\n", strings.Join(c.Fix, " ")) + _, _ = fmt.Fprintf(stdout, " try: %s\n", strings.Join(c.Fix, " ")) } } return ExitFail diff --git a/internal/cli/run_update.go b/internal/cli/run_update.go index a652322e..ab9164a6 100644 --- a/internal/cli/run_update.go +++ b/internal/cli/run_update.go @@ -28,9 +28,9 @@ func runUpdate(a *Args, stdout, stderr io.Writer) int { }) } if !a.Quiet { - fmt.Fprintf(stdout, "updated %s: %v\n", v.Name, a.Changed) + _, _ = fmt.Fprintf(stdout, "updated %s: %v\n", v.Name, a.Changed) if appliesAt(v) == "next_start" { - fmt.Fprintf(stdout, "%s is running; this takes effect at next start\n", v.Name) + _, _ = fmt.Fprintf(stdout, "%s is running; this takes effect at next start\n", v.Name) } } return ExitOK diff --git a/internal/cli/run_vm.go b/internal/cli/run_vm.go index b4d76061..982775f1 100644 --- a/internal/cli/run_vm.go +++ b/internal/cli/run_vm.go @@ -23,7 +23,7 @@ func runLS(a *Args, stdout, stderr io.Writer) int { return a.ok(stdout, map[string]any{"vms": wire.FromVMs(vms, core.GraphicalSession())}) } - fmt.Fprintf(stdout, "%-15s %-5s %-8s %-5s %-6s %s\n", "NAME", "MODE", "STATE", "CPUS", "RAM", "SSH") + _, _ = fmt.Fprintf(stdout, "%-15s %-5s %-8s %-5s %-6s %s\n", "NAME", "MODE", "STATE", "CPUS", "RAM", "SSH") // core.List() sorts every VM, broken ones included, together by name, // so a broken VM can interleave alphabetically with good ones. The // original two calls (config.List then config.ListBroken) printed every @@ -39,7 +39,7 @@ func runLS(a *Args, stdout, stderr io.Writer) int { if v.State == core.StateRunning { state = "running" } - fmt.Fprintf(stdout, "%-15s %-5s %s %-5d %-6d %d\n", + _, _ = fmt.Fprintf(stdout, "%-15s %-5s %s %-5d %-6d %d\n", v.Name, v.Mode, colorState(state, 8), v.CPUs, v.RAM, v.SSHPort) } // Broken VMs are real entries: hiding them is the bug that was already @@ -49,7 +49,7 @@ func runLS(a *Args, stdout, stderr io.Writer) int { if v.State != core.StateBroken { continue } - fmt.Fprintf(stdout, "%-15s %-5s %s %-5s %-6s %-4s %s\n", + _, _ = fmt.Fprintf(stdout, "%-15s %-5s %s %-5s %-6s %-4s %s\n", v.Name, "-", colorState("broken", 8), "-", "-", "-", oneLine(v.Error)) } return ExitOK @@ -71,7 +71,7 @@ func runUp(a *Args, stdout, stderr io.Writer) int { return a.failMsg(stdout, stderr, core.ErrBroken, v.Error) } if !a.Quiet { - fmt.Fprintf(stdout, "starting %s...\n", a.VM) + _, _ = fmt.Fprintf(stdout, "starting %s...\n", a.VM) } if err := core.Start(a.VM); err != nil { return a.fail(stdout, stderr, err) @@ -84,7 +84,7 @@ func runUp(a *Args, stdout, stderr io.Writer) int { } return a.ok(stdout, map[string]any{"vm": wire.FromVM(v, core.GraphicalSession())}) } - fmt.Fprintf(stdout, "%s started (ssh :%d)\n", a.VM, v.SSHPort) + _, _ = fmt.Fprintf(stdout, "%s started (ssh :%d)\n", a.VM, v.SSHPort) // Re-read for the display line too: Start is what flips a disk VM to // installed, and that flip is exactly what moves the screen off the qemu // window. The pre-Start copy would announce a window that is not there. @@ -100,11 +100,11 @@ func runUp(a *Args, stdout, stderr io.Writer) int { // boot the installed system either way. if v.Mode == "disk" && !v.Installed { if !a.Quiet { - fmt.Fprintf(stdout, "installing %s (a few minutes)...\n", a.VM) + _, _ = fmt.Fprintf(stdout, "installing %s (a few minutes)...\n", a.VM) } restarted, err := core.AutoRestartAfterInstall(context.Background(), a.VM) if err != nil || !restarted { - fmt.Fprintf(stdout, "install did not finish; inspect: stoat logs %s\n", a.VM) + _, _ = fmt.Fprintf(stdout, "install did not finish; inspect: stoat logs %s\n", a.VM) return ExitOK } if started, err := core.Get(a.VM); err == nil { @@ -135,7 +135,7 @@ func afterStart(a *Args, v core.VM, stdout, stderr io.Writer) int { } if !a.Quiet { - fmt.Fprintf(stdout, "waiting for ssh on %s...\n", a.VM) + _, _ = fmt.Fprintf(stdout, "waiting for ssh on %s...\n", a.VM) } ctx, cancel := context.WithTimeout(context.Background(), sshx.WaitTimeout) defer cancel() @@ -144,7 +144,7 @@ func afterStart(a *Args, v core.VM, stdout, stderr io.Writer) int { } if !a.Quiet { - fmt.Fprintf(stdout, "applying recipes to %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{}) }() @@ -153,12 +153,12 @@ func afterStart(a *Args, v core.VM, stdout, stderr io.Writer) int { // 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) + _, _ = fmt.Fprintf(stdout, "%s: an apply is already running\n", a.VM) return ExitOK } return a.fail(stdout, stderr, err) } - fmt.Fprintf(stdout, "%s: recipes applied\n", a.VM) + _, _ = fmt.Fprintf(stdout, "%s: recipes applied\n", a.VM) return ExitOK } @@ -172,7 +172,7 @@ func afterStart(a *Args, v core.VM, stdout, stderr io.Writer) int { func printDisplay(w io.Writer, d core.Display) { switch d.Kind { case core.DisplayWindow: - fmt.Fprintln(w, "display: a qemu window") + _, _ = fmt.Fprintln(w, "display: a qemu window") case core.DisplayVNC: if d.NoSession { // Said before the socket line, because without it "no qemu window" @@ -180,17 +180,17 @@ func printDisplay(w io.Writer, d core.Display) { // "no usable session" rather than "no session": the same line // prints when the user set STOAT_GRAPHICAL=0 on a host that plainly // has one, because its GTK cannot draw on it. - fmt.Fprintln(w, "display: no usable graphical session on this host, so the screen") - fmt.Fprintln(w, " is on VNC instead; attach to watch it") + _, _ = fmt.Fprintln(w, "display: no usable graphical session on this host, so the screen") + _, _ = fmt.Fprintln(w, " is on VNC instead; attach to watch it") } - fmt.Fprintf(w, "display: no qemu window; the screen is on %s\n", d.Socket) + _, _ = fmt.Fprintf(w, "display: no qemu window; the screen is on %s\n", d.Socket) if d.Attach.Command == "" { - fmt.Fprintf(w, " no VNC viewer found; install one of: %s\n", strings.Join(d.Attach.Missing, ", ")) + _, _ = fmt.Fprintf(w, " no VNC viewer found; install one of: %s\n", strings.Join(d.Attach.Missing, ", ")) return } - fmt.Fprintf(w, " attach with: %s\n", d.Attach.Command) + _, _ = fmt.Fprintf(w, " attach with: %s\n", d.Attach.Command) if d.Attach.Then != "" { - fmt.Fprintf(w, " %s\n", d.Attach.Then) + _, _ = fmt.Fprintf(w, " %s\n", d.Attach.Then) } } } @@ -209,7 +209,7 @@ func runDown(a *Args, stdout, stderr io.Writer) int { return a.failMsg(stdout, stderr, core.ErrNotRunning, a.VM+" is not running") } if !a.Quiet { - fmt.Fprintf(stdout, "stopping %s...\n", a.VM) + _, _ = fmt.Fprintf(stdout, "stopping %s...\n", a.VM) } if err := core.Stop(a.VM); err != nil { // The State check above catches the common case before any output @@ -226,7 +226,7 @@ func runDown(a *Args, stdout, stderr io.Writer) int { } return a.ok(stdout, map[string]any{"vm": wire.FromVM(v, core.GraphicalSession())}) } - fmt.Fprintf(stdout, "%s stopped\n", a.VM) + _, _ = fmt.Fprintf(stdout, "%s stopped\n", a.VM) return ExitOK } @@ -245,13 +245,13 @@ func runRM(a *Args, stdin io.Reader, stdout, stderr io.Writer) int { return a.fail(stdout, stderr, fmt.Errorf("%w: %s", wire.ErrConfirmationRequired, a.VM)) } if a.Quiet { - fmt.Fprintln(stderr, "stoat: rm: refusing to delete without -y in non-interactive mode") + _, _ = fmt.Fprintln(stderr, "stoat: rm: refusing to delete without -y in non-interactive mode") return ExitFail } - fmt.Fprintf(stdout, "delete VM %s? [y/N] ", a.VM) + _, _ = fmt.Fprintf(stdout, "delete VM %s? [y/N] ", a.VM) line, _ := bufio.NewReader(stdin).ReadString('\n') if strings.ToLower(strings.TrimSpace(line)) != "y" { - fmt.Fprintln(stdout, "aborted") + _, _ = fmt.Fprintln(stdout, "aborted") return ExitFail } } @@ -267,7 +267,7 @@ func runRM(a *Args, stdin io.Reader, stdout, stderr io.Writer) int { if a.JSON { return a.ok(stdout, map[string]any{"name": a.VM, "deleted": true}) } - fmt.Fprintf(stdout, "%s deleted\n", a.VM) + _, _ = fmt.Fprintf(stdout, "%s deleted\n", a.VM) return ExitOK } @@ -280,9 +280,9 @@ func runCreate(a *Args, stdout, stderr io.Writer) int { if a.JSON { return a.fail(stdout, stderr, err) } - fmt.Fprintln(stderr, "stoat: create:", err) + _, _ = fmt.Fprintln(stderr, "stoat: create:", err) if errors.Is(err, core.ErrImageNotDownloaded) { - fmt.Fprintln(stderr, "stoat: download it from the TUI's image picker first") + _, _ = fmt.Fprintln(stderr, "stoat: download it from the TUI's image picker first") } return ExitFail } @@ -290,8 +290,8 @@ func runCreate(a *Args, stdout, stderr io.Writer) int { return a.ok(stdout, map[string]any{"vm": wire.FromVM(v, core.GraphicalSession())}) } if !a.Quiet { - fmt.Fprintf(stdout, "created %s (%s, %s, ssh port %d)\n", v.Name, v.OS, v.Mode, v.SSHPort) - fmt.Fprintf(stdout, "start it with: stoat up %s\n", v.Name) + _, _ = fmt.Fprintf(stdout, "created %s (%s, %s, ssh port %d)\n", v.Name, v.OS, v.Mode, v.SSHPort) + _, _ = fmt.Fprintf(stdout, "start it with: stoat up %s\n", v.Name) } return ExitOK } @@ -314,8 +314,8 @@ func runClone(a *Args, stdout, stderr io.Writer) int { }) } if !a.Quiet { - fmt.Fprintf(stdout, "cloned %s to %s (ssh :%d)\n", a.VM, v.Name, v.SSHPort) - fmt.Fprintf(stdout, "port forwards were not copied; set them with: stoat forward %s ...\n", v.Name) + _, _ = fmt.Fprintf(stdout, "cloned %s to %s (ssh :%d)\n", a.VM, v.Name, v.SSHPort) + _, _ = fmt.Fprintf(stdout, "port forwards were not copied; set them with: stoat forward %s ...\n", v.Name) } return ExitOK } diff --git a/internal/cli/run_wait.go b/internal/cli/run_wait.go index 58234492..ab7a4c7c 100644 --- a/internal/cli/run_wait.go +++ b/internal/cli/run_wait.go @@ -33,7 +33,7 @@ func runWait(a *Args, stdout, stderr io.Writer) int { }) } if !a.Quiet { - fmt.Fprintf(stdout, "%s reached %s (%dms)\n", a.VM, a.Until, waited.Milliseconds()) + _, _ = fmt.Fprintf(stdout, "%s reached %s (%dms)\n", a.VM, a.Until, waited.Milliseconds()) } return ExitOK } diff --git a/internal/cloudinit/cloudinit.go b/internal/cloudinit/cloudinit.go index 5a0bd36f..f3d91157 100644 --- a/internal/cloudinit/cloudinit.go +++ b/internal/cloudinit/cloudinit.go @@ -136,9 +136,9 @@ func mountsDoc(v *config.VM) string { const opts = "trans=virtio,version=9p2000.L,%s,_netdev,nofail" var b strings.Builder b.WriteString("#cloud-config\nmounts:\n") - b.WriteString(fmt.Sprintf(" - [ work, /mnt/work, 9p, %q, \"0\", \"0\" ]\n", fmt.Sprintf(opts, "rw"))) + fmt.Fprintf(&b, " - [ work, /mnt/work, 9p, %q, \"0\", \"0\" ]\n", fmt.Sprintf(opts, "rw")) if v.Share != "" { - b.WriteString(fmt.Sprintf(" - [ host, /mnt/host, 9p, %q, \"0\", \"0\" ]\n", fmt.Sprintf(opts, "ro"))) + fmt.Fprintf(&b, " - [ host, /mnt/host, 9p, %q, \"0\", \"0\" ]\n", fmt.Sprintf(opts, "ro")) } return b.String() } @@ -197,10 +197,10 @@ func ValidateFragment(body string) (annotated string, err error) { if err != nil { return "", fmt.Errorf("creating schema-check temp file: %w", err) } - defer os.Remove(f.Name()) + defer func() { _ = os.Remove(f.Name()) }() if _, err := f.WriteString(body); err != nil { - f.Close() + _ = f.Close() return "", fmt.Errorf("writing schema-check temp file: %w", err) } if err := f.Close(); err != nil { diff --git a/internal/cloudinit/cloudinit_test.go b/internal/cloudinit/cloudinit_test.go index 11503855..aa479c3a 100644 --- a/internal/cloudinit/cloudinit_test.go +++ b/internal/cloudinit/cloudinit_test.go @@ -132,7 +132,7 @@ func TestSeedWritesUserDataAndMetaData(t *testing.T) { if err != nil { t.Fatalf("seed.iso not written: %v", err) } - defer f.Close() + defer func() { _ = f.Close() }() label := make([]byte, 32) if _, err := f.ReadAt(label, 0x8028); err != nil { diff --git a/internal/cloudinit/scripts.go b/internal/cloudinit/scripts.go index 7615e725..aa01f224 100644 --- a/internal/cloudinit/scripts.go +++ b/internal/cloudinit/scripts.go @@ -43,7 +43,7 @@ func WrapScripts(scripts []Script) string { rc.WriteString("runcmd:\n") for _, s := range scripts { path := fmt.Sprintf("%s/%s.sh", scriptDir, s.Name) - wf.WriteString(fmt.Sprintf(" - path: %s\n", path)) + fmt.Fprintf(&wf, " - path: %s\n", path) wf.WriteString(" permissions: '0755'\n") wf.WriteString(" content: |\n") wf.WriteString(indentBlock(s.Content)) @@ -52,7 +52,7 @@ func WrapScripts(scripts []Script) string { // leaves no marker for a script that failed, so a failed recipe stays // pending instead of being recorded as applied. marker := fmt.Sprintf("%s/%s", MarkerDir, s.Name) - rc.WriteString(fmt.Sprintf(" - %s && mkdir -p %s && touch %s\n", path, MarkerDir, marker)) + fmt.Fprintf(&rc, " - %s && mkdir -p %s && touch %s\n", path, MarkerDir, marker) } return "#cloud-config\n" + wf.String() + rc.String() diff --git a/internal/config/config.go b/internal/config/config.go index 53272870..9b9cf8fe 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -194,7 +194,7 @@ func (v *VM) Save() error { if err != nil { return err } - defer f.Close() + defer func() { _ = f.Close() }() return toml.NewEncoder(f).Encode(v) } @@ -341,7 +341,7 @@ func FreePort() (int, error) { if err != nil { continue } - l.Close() + _ = l.Close() return p, nil } return 0, fmt.Errorf("no free port in 2200-2299") diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 20f134fc..260bdc07 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -394,7 +394,7 @@ func TestFreePortFreshInstallNoVMs(t *testing.T) { if err != nil { t.Errorf("FreePort returned %d, which is not actually free: %v", p, err) } else { - l.Close() + _ = l.Close() } } diff --git a/internal/config/lock.go b/internal/config/lock.go index a17c7fb7..ed23ba3c 100644 --- a/internal/config/lock.go +++ b/internal/config/lock.go @@ -71,14 +71,14 @@ func lockFile(name string) (func(), error) { return nil, err } if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil { - f.Close() + _ = f.Close() return nil, err } return func() { // Unlock explicitly rather than relying on Close: the ordering is // then visible to a reader, and Close alone would still be correct // only because this fd is not shared. - syscall.Flock(int(f.Fd()), syscall.LOCK_UN) - f.Close() + _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) + _ = f.Close() }, nil } diff --git a/internal/core/access_test.go b/internal/core/access_test.go index 9b6aeb8d..5386e3b2 100644 --- a/internal/core/access_test.go +++ b/internal/core/access_test.go @@ -95,7 +95,7 @@ func TestLogsReturnsWrittenBytes(t *testing.T) { if err != nil { t.Fatal(err) } - defer r.Close() + defer func() { _ = r.Close() }() b, err := io.ReadAll(r) if err != nil { t.Fatal(err) @@ -108,7 +108,7 @@ func TestLogsReturnsWrittenBytes(t *testing.T) { if err != nil { t.Fatal(err) } - defer r.Close() + defer func() { _ = r.Close() }() b, err = io.ReadAll(r) if err != nil { t.Fatal(err) @@ -128,7 +128,7 @@ func TestLogsMissingFileIsEmptyNotError(t *testing.T) { if err != nil { t.Fatal(err) } - defer r.Close() + defer func() { _ = r.Close() }() b, err := io.ReadAll(r) if err != nil { t.Fatal(err) @@ -169,7 +169,7 @@ func TestLogsBrokenVMStillServesConsoleLog(t *testing.T) { if err != nil { t.Fatalf("Logs on a broken VM should still serve the console log, got err = %v", err) } - defer r.Close() + defer func() { _ = r.Close() }() b, err := io.ReadAll(r) if err != nil { t.Fatal(err) @@ -190,7 +190,7 @@ func TestLogsBrokenVMMissingFileIsEmptyNotError(t *testing.T) { if err != nil { t.Fatal(err) } - defer r.Close() + defer func() { _ = r.Close() }() b, err := io.ReadAll(r) if err != nil { t.Fatal(err) diff --git a/internal/core/apply.go b/internal/core/apply.go index 2ec6b2b6..2a0b465a 100644 --- a/internal/core/apply.go +++ b/internal/core/apply.go @@ -291,8 +291,8 @@ func appendProvisionLog(v *config.VM, s string) { if err != nil { return } - defer f.Close() - f.WriteString(s) + defer func() { _ = f.Close() }() + _, _ = f.WriteString(s) } // discoverCloudInitApplied rebuilds v.Applied for a cloudinit VM from the diff --git a/internal/core/apply_test.go b/internal/core/apply_test.go index 89b7249a..e40811a7 100644 --- a/internal/core/apply_test.go +++ b/internal/core/apply_test.go @@ -47,7 +47,7 @@ func TestApplyRefusesWhileLockIsHeld(t *testing.T) { release := make(chan struct{}) held := make(chan struct{}) go func() { - WithProvisionLock(v.Dir, func() error { + _ = WithProvisionLock(v.Dir, func() error { close(held) <-release return nil @@ -141,15 +141,15 @@ func TestApplyOnlyAcceptsAValidSubset(t *testing.T) { if err != nil { t.Fatal(err) } - defer ln.Close() + defer func() { _ = ln.Close() }() go func() { for { c, err := ln.Accept() if err != nil { return } - c.Write([]byte("SSH-2.0-fake\r\n")) - c.Close() + _, _ = c.Write([]byte("SSH-2.0-fake\r\n")) + _ = c.Close() } }() port := ln.Addr().(*net.TCPAddr).Port @@ -600,15 +600,15 @@ func TestApplyRecordsHashAndSkipsOnRerun(t *testing.T) { if err != nil { t.Fatal(err) } - defer ln.Close() + defer func() { _ = ln.Close() }() go func() { for { c, err := ln.Accept() if err != nil { return } - c.Write([]byte("SSH-2.0-fake\r\n")) - c.Close() + _, _ = c.Write([]byte("SSH-2.0-fake\r\n")) + _ = c.Close() } }() port := ln.Addr().(*net.TCPAddr).Port diff --git a/internal/core/clone.go b/internal/core/clone.go index 0114d12b..ac9b0be8 100644 --- a/internal/core/clone.go +++ b/internal/core/clone.go @@ -102,7 +102,7 @@ func Clone(name, newName string) (VM, error) { // Leave no trace of a failed clone, matching Create's own rule for a // failed qemu-img call: otherwise List shows a VM with no disk that // can never boot. - os.RemoveAll(clone.Dir) + _ = os.RemoveAll(clone.Dir) return VM{}, err } diff --git a/internal/core/core.go b/internal/core/core.go index f2aa64bf..ef344fb3 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -126,7 +126,7 @@ func Create(s Spec) (VM, error) { if err != nil { // Leave no trace of a failed creation: otherwise the list shows a // VM with no disk.qcow2 that can never boot. - os.RemoveAll(v.Dir) + _ = os.RemoveAll(v.Dir) return VM{}, fmt.Errorf("qemu-img: %s", strings.TrimSpace(string(out))) } } diff --git a/internal/core/lock.go b/internal/core/lock.go index df626769..5f581fb4 100644 --- a/internal/core/lock.go +++ b/internal/core/lock.go @@ -31,7 +31,7 @@ func WithProvisionLock(dir string, fn func() error) error { if err != nil { return fmt.Errorf("open provision lock: %w", err) } - defer f.Close() + defer func() { _ = f.Close() }() if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { if errors.Is(err, syscall.EWOULDBLOCK) { @@ -39,7 +39,7 @@ func WithProvisionLock(dir string, fn func() error) error { } return fmt.Errorf("lock %s: %w", path, err) } - defer syscall.Flock(int(f.Fd()), syscall.LOCK_UN) + defer func() { _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) }() return fn() } diff --git a/internal/core/vm_test.go b/internal/core/vm_test.go index 82571b5a..85b26061 100644 --- a/internal/core/vm_test.go +++ b/internal/core/vm_test.go @@ -174,7 +174,7 @@ func TestDestroyRefusesWhileRunning(t *testing.T) { t.Fatalf("VM directory should still exist after a refused destroy: %v", err) } stop() - os.Remove(v.PidPath()) + _ = os.Remove(v.PidPath()) if err := Destroy("work"); err != nil { t.Fatalf("Destroy after stopping: %v", err) diff --git a/internal/core/wait.go b/internal/core/wait.go index 8f1ad041..b16b2205 100644 --- a/internal/core/wait.go +++ b/internal/core/wait.go @@ -106,8 +106,8 @@ func sshBannerUp(ctx context.Context, v *config.VM) bool { if err != nil { return false } - defer c.Close() - c.SetReadDeadline(time.Now().Add(2 * time.Second)) + defer func() { _ = c.Close() }() + _ = c.SetReadDeadline(time.Now().Add(2 * time.Second)) buf := make([]byte, 4) _, err = io.ReadFull(c, buf) return err == nil && string(buf) == "SSH-" diff --git a/internal/core/wait_test.go b/internal/core/wait_test.go index b75c199e..1ab71022 100644 --- a/internal/core/wait_test.go +++ b/internal/core/wait_test.go @@ -30,15 +30,15 @@ func fakeSSHD(t *testing.T, port int) (int, func()) { return } go func() { - c.Write([]byte("SSH-2.0-fake\r\n")) + _, _ = c.Write([]byte("SSH-2.0-fake\r\n")) <-done - c.Close() + _ = c.Close() }() } }() return l.Addr().(*net.TCPAddr).Port, func() { close(done) - l.Close() + _ = l.Close() } } @@ -94,7 +94,7 @@ func TestWaitReachablePollsUntilUp(t *testing.T) { t.Fatal(err) } port := l.Addr().(*net.TCPAddr).Port - l.Close() + _ = l.Close() v := &config.VM{Name: "work", Mode: "live", RAM: 1024, CPUs: 1, SSHPort: port} if err := v.Save(); err != nil { diff --git a/internal/hostcheck/kvm_linux.go b/internal/hostcheck/kvm_linux.go index ee621b0f..8991db18 100644 --- a/internal/hostcheck/kvm_linux.go +++ b/internal/hostcheck/kvm_linux.go @@ -18,7 +18,7 @@ func KVMCheck() Check { return kvmCheckAt("/dev/kvm") } func kvmCheckAt(path string) Check { f, err := os.OpenFile(path, os.O_RDWR, 0) if err == nil { - f.Close() + _ = f.Close() return Check{Name: "/dev/kvm", OK: true, Detail: "read/write"} } diff --git a/internal/installer/build.go b/internal/installer/build.go index 69c9c1f3..561da83f 100644 --- a/internal/installer/build.go +++ b/internal/installer/build.go @@ -102,7 +102,7 @@ func copyFile(src, dst string) error { if err != nil { return err } - defer in.Close() + defer func() { _ = in.Close() }() out, err := os.Create(dst) if err != nil { return err @@ -126,14 +126,14 @@ func Install(srcPath, destDir string) (string, error) { if err != nil { return "", err } - defer src.Close() + defer func() { _ = src.Close() }() tmp, err := os.CreateTemp(destDir, ".stoat-*") if err != nil { return "", err } tmpName := tmp.Name() - defer os.Remove(tmpName) // no-op once the rename succeeds + defer func() { _ = os.Remove(tmpName) }() // no-op once the rename succeeds if _, err := io.Copy(tmp, src); err != nil { _ = tmp.Close() diff --git a/internal/installer/tui.go b/internal/installer/tui.go index 63d01d01..1f481b61 100644 --- a/internal/installer/tui.go +++ b/internal/installer/tui.go @@ -161,7 +161,7 @@ func buildCmd(repoDir, version string) tea.Cmd { // Nothing will call installCmd to clean this up. The build // produced no binary to hand it, so this path // removes the temp dir itself. - os.RemoveAll(tmp) + _ = os.RemoveAll(tmp) return errMsg{err: err} } return builtMsg{tmpPath: out} @@ -173,7 +173,7 @@ func installCmd(src, destDir, repoDir, home string) tea.Cmd { // buildCmd's temp dir has done its job once the binary is copied out, // whether the copy succeeds or fails. It is removed here // unconditionally, so a run never leaks ~10MB. - defer os.RemoveAll(filepath.Dir(src)) + defer func() { _ = os.RemoveAll(filepath.Dir(src)) }() path, err := Install(src, destDir) if err != nil { return errMsg{err: err} diff --git a/internal/iso/iso.go b/internal/iso/iso.go index f778aedd..70216915 100644 --- a/internal/iso/iso.go +++ b/internal/iso/iso.go @@ -346,7 +346,7 @@ func Latest(flavor string) (*Release, error) { if err != nil { return nil, err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("index: %s", resp.Status) } @@ -410,7 +410,7 @@ func fetchChecksum(checksumURL, filename string) (string, error) { if err != nil { return "", err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("checksum: %s", resp.Status) } @@ -469,7 +469,7 @@ func fileDigest(path string, h hash.Hash) (string, error) { if err != nil { return "", err } - defer f.Close() + defer func() { _ = f.Close() }() if _, err := io.Copy(h, f); err != nil { return "", err } @@ -528,7 +528,7 @@ func Download(ctx context.Context, r *Release, progress func(done, total int64)) if err != nil { return "", err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("download: %s", resp.Status) } @@ -575,8 +575,8 @@ func Download(ctx context.Context, r *Release, progress func(done, total int64)) if n > 0 { stall.Reset(stallTimeout) if _, werr := f.Write(buf[:n]); werr != nil { - f.Close() - os.Remove(part) + _ = f.Close() + _ = os.Remove(part) return "", werr } h.Write(buf[:n]) @@ -589,8 +589,8 @@ func Download(ctx context.Context, r *Release, progress func(done, total int64)) break } if rerr != nil { - f.Close() - os.Remove(part) + _ = f.Close() + _ = os.Remove(part) if stalled.Load() { return "", fmt.Errorf("download stalled: no data for %s", stallTimeout) } @@ -602,19 +602,19 @@ func Download(ctx context.Context, r *Release, progress func(done, total int64)) // file would still pass verification and get renamed into place as // "verified". if err := f.Close(); err != nil { - os.Remove(part) + _ = os.Remove(part) return "", err } if r.SHA256 != "" { if got := hex.EncodeToString(h.Sum(nil)); got != r.SHA256 { - os.Remove(part) + _ = os.Remove(part) return "", fmt.Errorf("checksum mismatch: got %s, want %s", got, r.SHA256) } r.Verified = true } if err := os.Rename(part, final); err != nil { - os.Remove(part) + _ = os.Remove(part) return "", err } return rel, nil diff --git a/internal/iso/iso_test.go b/internal/iso/iso_test.go index bf013191..bfbb46d3 100644 --- a/internal/iso/iso_test.go +++ b/internal/iso/iso_test.go @@ -36,7 +36,7 @@ func TestDownload_RenameFailureCleansUpPart(t *testing.T) { sum := sha256.Sum256(body) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - w.Write(body) + _, _ = w.Write(body) })) defer srv.Close() @@ -248,12 +248,12 @@ func TestResolveAndDownload_Entry(t *testing.T) { const filename = "example-cloudimg-amd64.qcow2" fileSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - w.Write(body) + _, _ = w.Write(body) })) defer fileSrv.Close() sumsSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - fmt.Fprintf(w, "%s %s\nfeedface some-other-file.qcow2\n", hex.EncodeToString(sum[:]), filename) + _, _ = fmt.Fprintf(w, "%s %s\nfeedface some-other-file.qcow2\n", hex.EncodeToString(sum[:]), filename) })) defer sumsSrv.Close() @@ -323,7 +323,7 @@ func TestDownload_SHA512(t *testing.T) { const filename = "example-genericcloud-amd64.qcow2" srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - w.Write(body) + _, _ = w.Write(body) })) defer srv.Close() @@ -355,7 +355,7 @@ func TestDownload_UnverifiedWhenNoChecksum(t *testing.T) { const filename = "unverified-cloudimg-amd64.qcow2" srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - w.Write(body) + _, _ = w.Write(body) })) defer srv.Close() @@ -456,7 +456,7 @@ func TestCatalog_FedoraURLNotArchived(t *testing.T) { if err != nil { t.Skipf("network unreachable, skipping live fedora-cloud check: %v", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() switch resp.StatusCode { case http.StatusOK, http.StatusPartialContent: @@ -497,7 +497,7 @@ func TestDownloadOutlastsMetadataTimeout(t *testing.T) { w.Header().Set("Content-Length", fmt.Sprint(len(body))) flusher, _ := w.(http.Flusher) for i := 0; i < len(body); i += 64 { - w.Write(body[i:min(i+64, len(body))]) + _, _ = w.Write(body[i:min(i+64, len(body))]) if flusher != nil { flusher.Flush() } @@ -544,7 +544,7 @@ func TestResolve_AlpineDirectURLEntry(t *testing.T) { const filename = "alpine-cloud.qcow2" fileSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - w.Write(body) + _, _ = w.Write(body) })) defer fileSrv.Close() diff --git a/internal/keys/keys.go b/internal/keys/keys.go index 1e616946..b806c1f7 100644 --- a/internal/keys/keys.go +++ b/internal/keys/keys.go @@ -70,8 +70,8 @@ func generate(path, comment string) error { } // ssh-keygen refuses to overwrite. A stale half of the pair, either the // private key or its .pub, would wedge this, so clear both first. - os.Remove(path) - os.Remove(path + ".pub") + _ = os.Remove(path) + _ = os.Remove(path + ".pub") cmd := exec.Command("ssh-keygen", "-t", "ed25519", "-N", "", "-C", comment, "-f", path) // Extra safety alongside the lock above. ssh-keygen prompts on stdin // when it finds a file it did not expect, and a background or diff --git a/internal/logx/logx_test.go b/internal/logx/logx_test.go index bfd69248..f23ebbc2 100644 --- a/internal/logx/logx_test.go +++ b/internal/logx/logx_test.go @@ -35,7 +35,7 @@ func TestInitCreatesLogAndAppends(t *testing.T) { t.Fatal(err) } L().Info("second message") - Close() + _ = Close() b, _ = os.ReadFile(Path()) if !strings.Contains(string(b), "first message") { t.Error("Init truncated an existing log") diff --git a/internal/qemu/qmp.go b/internal/qemu/qmp.go index f8dcb184..fff54f73 100644 --- a/internal/qemu/qmp.go +++ b/internal/qemu/qmp.go @@ -38,7 +38,7 @@ func dialQMP(v *config.VM) (*qmp, error) { return nil, fmt.Errorf("qmp: %w", err) } if err := c.SetDeadline(time.Now().Add(qmpTimeout)); err != nil { - c.Close() + _ = c.Close() return nil, err } q := &qmp{c: c, dec: json.NewDecoder(c)} @@ -49,15 +49,15 @@ func dialQMP(v *config.VM) (*qmp, error) { QMP json.RawMessage `json:"QMP"` } if err := q.dec.Decode(&greeting); err != nil { - c.Close() + _ = c.Close() return nil, fmt.Errorf("qmp greeting: %w", err) } if greeting.QMP == nil { - c.Close() + _ = c.Close() return nil, fmt.Errorf("qmp: not a QMP socket (no greeting)") } if _, err := q.command("qmp_capabilities", nil); err != nil { - c.Close() + _ = c.Close() return nil, err } return q, nil @@ -152,7 +152,7 @@ func snapshotCmd(v *config.VM, hmp string) error { if err != nil { return err } - defer q.Close() + defer func() { _ = q.Close() }() return q.hmpChecked(hmp) } @@ -183,6 +183,6 @@ func SnapshotInfo(v *config.VM) (string, error) { if err != nil { return "", err } - defer q.Close() + defer func() { _ = q.Close() }() return q.hmp("info snapshots") } diff --git a/internal/qemu/run.go b/internal/qemu/run.go index 2f8543f9..561453e7 100644 --- a/internal/qemu/run.go +++ b/internal/qemu/run.go @@ -24,7 +24,7 @@ func Preflight() error { if err != nil { return fmt.Errorf("/dev/kvm not usable: %w (are you in the kvm group?)", err) } - f.Close() + _ = f.Close() return nil } @@ -59,11 +59,11 @@ func Running(v *config.VM) bool { } cmdline, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", p)) if err != nil { - os.Remove(v.PidPath()) + _ = os.Remove(v.PidPath()) return false } if !cmdlineMatches(cmdline, v.Dir) { - os.Remove(v.PidPath()) + _ = os.Remove(v.PidPath()) return false } return true @@ -106,7 +106,7 @@ func Start(v *config.VM) error { if Running(v) { return fmt.Errorf("%s is already running", v.Name) } - os.Remove(v.MonitorPath()) + _ = os.Remove(v.MonitorPath()) // The interactive install happens inside the guest, where stoat can't // watch it finish. Checked here, at the next start, since that's the // only moment the boot order matters. @@ -202,8 +202,8 @@ func Stop(v *config.VM) error { return nil } if c, err := dialMonitor(v); err == nil { - fmt.Fprintln(c, "system_powerdown") - c.Close() + _, _ = fmt.Fprintln(c, "system_powerdown") + _ = c.Close() for i := 0; i < 100; i++ { if !Running(v) { logx.L().Info("stopped", "vm", v.Name) diff --git a/internal/qemu/sendkey.go b/internal/qemu/sendkey.go index d4bdb054..0b9307fe 100644 --- a/internal/qemu/sendkey.go +++ b/internal/qemu/sendkey.go @@ -36,7 +36,7 @@ const consoleKeyDelay = 50 * time.Millisecond func passwordKeys(password string) ([]string, error) { keys := make([]string, 0, len(password)) for _, r := range password { - if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'z')) { + if (r < '0' || r > '9') && (r < 'a' || r > 'z') { return nil, fmt.Errorf( "console password contains %q, which can't be typed reliably over the qemu monitor: enter it manually at the console", string(r)) @@ -64,7 +64,7 @@ func TypeConsolePassword(v *config.VM) error { if err != nil { return fmt.Errorf("qemu monitor: %w", err) } - defer c.Close() + defer func() { _ = c.Close() }() for _, k := range keys { if _, err := fmt.Fprintln(c, "sendkey "+k); err != nil { return fmt.Errorf("qemu monitor: %w", err) diff --git a/internal/sshx/sharemount.go b/internal/sshx/sharemount.go index 6c783048..1f17b062 100644 --- a/internal/sshx/sharemount.go +++ b/internal/sshx/sharemount.go @@ -105,7 +105,7 @@ func mountShares(ctx context.Context, v *config.VM, log io.Writer) { if len(tags) == 0 { return } - fmt.Fprintln(log, "\n=== mounting 9p shares ===") + _, _ = fmt.Fprintln(log, "\n=== mounting 9p shares ===") cmd := exec.CommandContext(ctx, "ssh", Args(v, sudoWrap(v, []string{"sh", "-s"})...)...) cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGTERM) } cmd.WaitDelay = recipeShutdownGrace @@ -113,6 +113,6 @@ func mountShares(ctx context.Context, v *config.VM, log io.Writer) { cmd.Stdout = log cmd.Stderr = log if err := cmd.Run(); err != nil { - fmt.Fprintf(log, "stoat: share mount step failed, continuing: %v\n", err) + _, _ = fmt.Fprintf(log, "stoat: share mount step failed, continuing: %v\n", err) } } diff --git a/internal/sshx/sshx.go b/internal/sshx/sshx.go index d890cf9c..2751bd93 100644 --- a/internal/sshx/sshx.go +++ b/internal/sshx/sshx.go @@ -140,7 +140,7 @@ func Wait(ctx context.Context, v *config.VM, timeout time.Duration) error { if bannerReady(c, time.Until(deadline)) { return nil } - c.Close() + _ = c.Close() } else if ctx.Err() != nil { return ctx.Err() } @@ -196,7 +196,7 @@ func bannerReady(c net.Conn, budget time.Duration) bool { if budget < d { d = budget } - c.SetReadDeadline(time.Now().Add(d)) + _ = c.SetReadDeadline(time.Now().Add(d)) buf := make([]byte, 4) _, err := io.ReadFull(c, buf) return err == nil && string(buf) == "SSH-" @@ -231,14 +231,14 @@ func Provision(ctx context.Context, v *config.VM) (err error) { if err != nil { return err } - defer log.Close() + defer func() { _ = log.Close() }() - fmt.Fprintf(log, "waiting for ssh on port %d…\n", v.SSHPort) + _, _ = fmt.Fprintf(log, "waiting for ssh on port %d…\n", v.SSHPort) if err := Wait(ctx, v, WaitTimeout); err != nil { if ctx.Err() != nil { - fmt.Fprintf(log, "CANCELLED: %v\n", err) + _, _ = fmt.Fprintf(log, "CANCELLED: %v\n", err) } else { - fmt.Fprintf(log, "FAILED: %v\n", err) + _, _ = fmt.Fprintf(log, "FAILED: %v\n", err) } return err } @@ -251,7 +251,7 @@ func Provision(ctx context.Context, v *config.VM) (err error) { // cloud-init failure surfaces when the recipe itself runs. apkovl and BYO // images ship no cloud-init and skip this. if v.Backend == "cloudinit" { - fmt.Fprintln(log, "waiting for cloud-init to finish…") + _, _ = fmt.Fprintln(log, "waiting for cloud-init to finish…") ci := exec.CommandContext(ctx, "ssh", Args(v, sudoWrap(v, []string{"cloud-init", "status", "--wait"})...)...) ci.Cancel = func() error { return ci.Process.Signal(syscall.SIGTERM) } ci.WaitDelay = recipeShutdownGrace @@ -267,24 +267,24 @@ func Provision(ctx context.Context, v *config.VM) (err error) { // cancelled between recipes must stop here rather than start one more // ssh process it will only have to kill. if err := ctx.Err(); err != nil { - fmt.Fprintf(log, "CANCELLED: %v\n", err) + _, _ = fmt.Fprintf(log, "CANCELLED: %v\n", err) return err } body, err := recipes.ScriptBody(name, v.OS) if err != nil { - fmt.Fprintf(log, "FAILED: recipe %s: %v\n", name, err) + _, _ = fmt.Fprintf(log, "FAILED: recipe %s: %v\n", name, err) return err } runtime, err := recipes.RuntimeFor(name, v.OS) if err != nil { - fmt.Fprintf(log, "FAILED: recipe %s: %v\n", name, err) + _, _ = fmt.Fprintf(log, "FAILED: recipe %s: %v\n", name, err) return err } - fmt.Fprintf(log, "\n%s\n", RecipeMarker(name)) + _, _ = fmt.Fprintf(log, "\n%s\n", RecipeMarker(name)) if bootstrap := recipes.BootstrapScript(runtime, v.OS); bootstrap != "" { - fmt.Fprintf(log, "ensuring %s is installed...\n", runtime) + _, _ = fmt.Fprintf(log, "ensuring %s is installed...\n", runtime) bs := exec.CommandContext(ctx, "ssh", Args(v, sudoWrap(v, []string{"sh", "-s"})...)...) bs.Cancel = func() error { return bs.Process.Signal(syscall.SIGTERM) } bs.WaitDelay = recipeShutdownGrace @@ -293,10 +293,10 @@ func Provision(ctx context.Context, v *config.VM) (err error) { bs.Stderr = log if err := bs.Run(); err != nil { if ctxErr := ctx.Err(); ctxErr != nil { - fmt.Fprintf(log, "CANCELLED: recipe %s: %v\n", name, ctxErr) + _, _ = fmt.Fprintf(log, "CANCELLED: recipe %s: %v\n", name, ctxErr) return ctxErr } - fmt.Fprintf(log, "FAILED: recipe %s: installing %s: %v\n", name, runtime, err) + _, _ = fmt.Fprintf(log, "FAILED: recipe %s: installing %s: %v\n", name, runtime, err) return fmt.Errorf("recipe %s: installing %s: %w", name, runtime, err) } } @@ -315,13 +315,13 @@ func Provision(ctx context.Context, v *config.VM) (err error) { // caller sniffing Logs' text, must not read a cancellation as if // the recipe itself had failed. if ctxErr := ctx.Err(); ctxErr != nil { - fmt.Fprintf(log, "CANCELLED: recipe %s: %v\n", name, ctxErr) + _, _ = fmt.Fprintf(log, "CANCELLED: recipe %s: %v\n", name, ctxErr) return ctxErr } - fmt.Fprintf(log, "FAILED: recipe %s: %v\n", name, err) + _, _ = fmt.Fprintf(log, "FAILED: recipe %s: %v\n", name, err) return fmt.Errorf("recipe %s: %w", name, err) } } - fmt.Fprintln(log, "\ndone") + _, _ = fmt.Fprintln(log, "\ndone") return nil } diff --git a/internal/sshx/sshx_test.go b/internal/sshx/sshx_test.go index 390317af..e9d04be2 100644 --- a/internal/sshx/sshx_test.go +++ b/internal/sshx/sshx_test.go @@ -214,7 +214,7 @@ func acceptOnly(t *testing.T, body string) int { if err != nil { t.Fatal(err) } - t.Cleanup(func() { l.Close() }) + t.Cleanup(func() { _ = l.Close() }) go func() { for { c, err := l.Accept() @@ -222,7 +222,7 @@ func acceptOnly(t *testing.T, body string) int { return } if body != "" { - c.Write([]byte(body)) + _, _ = c.Write([]byte(body)) } } }() @@ -274,14 +274,14 @@ func TestWaitSucceedsOnSlowBanner(t *testing.T) { if err != nil { t.Fatal(err) } - t.Cleanup(func() { l.Close() }) + t.Cleanup(func() { _ = l.Close() }) go func() { c, err := l.Accept() if err != nil { return } time.Sleep(500 * time.Millisecond) - c.Write([]byte("SSH-2.0-OpenSSH_9.6\r\n")) + _, _ = c.Write([]byte("SSH-2.0-OpenSSH_9.6\r\n")) }() port := l.Addr().(*net.TCPAddr).Port @@ -323,14 +323,14 @@ func acceptAndClose(t *testing.T) int { if err != nil { t.Fatal(err) } - t.Cleanup(func() { l.Close() }) + t.Cleanup(func() { _ = l.Close() }) go func() { for { c, err := l.Accept() if err != nil { return } - c.Close() + _ = c.Close() } }() return l.Addr().(*net.TCPAddr).Port diff --git a/internal/testutil/fakevm.go b/internal/testutil/fakevm.go index bd31caf5..f47c2077 100644 --- a/internal/testutil/fakevm.go +++ b/internal/testutil/fakevm.go @@ -67,8 +67,8 @@ func FakeRunning(t *testing.T, dir string) func() { break } if time.Now().After(deadline) { - cmd.Process.Kill() - cmd.Wait() + _ = cmd.Process.Kill() + _ = cmd.Wait() t.Fatalf("fake VM process never showed %q in its cmdline; qemu.Running would not match it", dir+"/") } time.Sleep(2 * time.Millisecond) @@ -76,12 +76,12 @@ func FakeRunning(t *testing.T, dir string) func() { pidFile := filepath.Join(dir, "qemu.pid") if err := os.WriteFile(pidFile, []byte(strconv.Itoa(cmd.Process.Pid)), 0o644); err != nil { - cmd.Process.Kill() - cmd.Wait() + _ = cmd.Process.Kill() + _ = cmd.Wait() t.Fatal(err) } return func() { - cmd.Process.Kill() - cmd.Wait() + _ = cmd.Process.Kill() + _ = cmd.Wait() } } diff --git a/internal/tui/app.go b/internal/tui/app.go index a29bcec6..c296f91d 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -137,7 +137,7 @@ func Run() error { // ponytail: a log we can't open is not worth refusing to start over. // logx.L() falls back to io.Discard, so the TUI just runs without a log. _ = logx.Init() - defer logx.Close() + defer func() { _ = logx.Close() }() m := model{ provisioning: map[string]provState{}, cloudInit: map[string]string{}, diff --git a/internal/tui/detail.go b/internal/tui/detail.go index a8275776..7ef95600 100644 --- a/internal/tui/detail.go +++ b/internal/tui/detail.go @@ -71,7 +71,7 @@ func tailLog(name string, n int) string { if err != nil { return "" } - defer r.Close() + defer func() { _ = r.Close() }() b, err := io.ReadAll(r) if err != nil { return "" @@ -478,9 +478,7 @@ func (m model) viewDetail() string { } parts = append(parts, "") - for _, l := range provLinesExcept(m, v.Name) { - parts = append(parts, l) - } + parts = append(parts, provLinesExcept(m, v.Name)...) // An always-present slot: an appearing status line replaces blank // space instead of pushing the footer down. parts = append(parts, warnStyle.Render(m.status)) diff --git a/internal/tui/detail_test.go b/internal/tui/detail_test.go index 3c28324d..b64afb15 100644 --- a/internal/tui/detail_test.go +++ b/internal/tui/detail_test.go @@ -115,7 +115,7 @@ func TestToggleInstalledFailedSaveLeavesMemoryUnchanged(t *testing.T) { if err := os.Chmod(tomlPath, 0o444); err != nil { t.Fatalf("chmod vm.toml: %v", err) } - t.Cleanup(func() { os.Chmod(tomlPath, 0o644) }) // let TempDir cleanup remove it + t.Cleanup(func() { _ = os.Chmod(tomlPath, 0o644) }) // let TempDir cleanup remove it v, err := core.Get(cv.Name) if err != nil { diff --git a/internal/tui/edit.go b/internal/tui/edit.go index 0e586d81..4bcbc40a 100644 --- a/internal/tui/edit.go +++ b/internal/tui/edit.go @@ -107,12 +107,6 @@ func backendOf(v *config.VM) string { return "ssh" } -// parseSize is core.ParseSize under a local name. core.Update now owns disk -// size validation, so edit.go no longer calls this. form.go's create-VM -// disk field still needs it for its own presentational parse, the same -// role buildPatch's RAM/CPUs/SSHPort checks play here. -var parseSize = core.ParseSize - // name is this VM's identity for core.Update and config.Load: the // directory, never the vm.toml `name` field, which can diverge from it (see // core.VM.Name's own comment on why the directory is authoritative). diff --git a/internal/tui/imagemodal.go b/internal/tui/imagemodal.go index 8735b34d..b64fd016 100644 --- a/internal/tui/imagemodal.go +++ b/internal/tui/imagemodal.go @@ -138,7 +138,7 @@ func (d imageDelegate) Render(w io.Writer, m list.Model, index int, item list.It if index == m.Index() { label = selStyle.Render(label) } - fmt.Fprint(w, cursor+label+dimStyle.Render(trailer)) + _, _ = fmt.Fprint(w, cursor+label+dimStyle.Render(trailer)) case variantItem: if it.chooseImage { // No size or status column: it isn't a file, it's a door to the @@ -147,7 +147,7 @@ func (d imageDelegate) Render(w io.Writer, m list.Model, index int, item list.It if index == m.Index() { label = selStyle.Render(label) } - fmt.Fprint(w, cursor+label+dimStyle.Render("search or paste")) + _, _ = fmt.Fprint(w, cursor+label+dimStyle.Render("search or paste")) return } // A styled substring ends in \x1b[0m, which also resets the enclosing @@ -162,9 +162,9 @@ func (d imageDelegate) Render(w io.Writer, m list.Model, index int, item list.It // Size right-aligned so the digits line up column-wise; padded plain, // then dimmed as a whole segment. size := dimStyle.Render(fmt.Sprintf("%*s", modalSizeWidth, it.opt.sizeLabel())) - fmt.Fprint(w, cursor+label+size+" "+it.opt.statusLabel()) + _, _ = fmt.Fprint(w, cursor+label+size+" "+it.opt.statusLabel()) case foundItem: - fmt.Fprint(w, cursor+foundRow(it.img, m.Width()-lipgloss.Width(cursor), index == m.Index())) + _, _ = fmt.Fprint(w, cursor+foundRow(it.img, m.Width()-lipgloss.Width(cursor), index == m.Index())) } } diff --git a/internal/tui/imagemodal_test.go b/internal/tui/imagemodal_test.go index 7e3bab50..f9ede2bd 100644 --- a/internal/tui/imagemodal_test.go +++ b/internal/tui/imagemodal_test.go @@ -957,7 +957,7 @@ func TestByoScreenFitsAndCentersAtAnIntermediateTerminal(t *testing.T) { // flush to a corner the way a too-narrow terminal forces it to. stripped := ansi.Strip(out) lines := strings.Split(stripped, "\n") - var leftmostBorder int = -1 + var leftmostBorder = -1 for _, line := range lines { if i := strings.Index(line, "╭"); i >= 0 { leftmostBorder = i diff --git a/internal/tui/list.go b/internal/tui/list.go index b8ef47e9..b68f6cd7 100644 --- a/internal/tui/list.go +++ b/internal/tui/list.go @@ -288,9 +288,7 @@ func (m model) viewList() string { // In-flight provision runs sit above the status line: they are ongoing // state, not a one-off message, and a run started from here keeps going // while the user moves around the list. - for _, l := range provLines(m) { - parts = append(parts, l) - } + parts = append(parts, provLines(m)...) parts = append(parts, warnStyle.Render(m.status)) parts = append(parts, renderFooter(listHelp{sshAvailable: sshAvailable}, m.width, m.showHelp)) return column(appContentWidth, parts...) diff --git a/internal/tui/logpager.go b/internal/tui/logpager.go index 1c1bcf68..b9d2c0b6 100644 --- a/internal/tui/logpager.go +++ b/internal/tui/logpager.go @@ -83,7 +83,7 @@ func openLogPager(name string) tea.Cmd { // console log yet. That one is empty, so reading it whole is not a // problem. func tailReadCloser(rc io.ReadCloser, max int64) (content string, truncated bool, err error) { - defer rc.Close() + defer func() { _ = rc.Close() }() if seeker, ok := rc.(io.Seeker); ok { if size, serr := seeker.Seek(0, io.SeekEnd); serr == nil { start := int64(0) diff --git a/internal/tui/provstep.go b/internal/tui/provstep.go index 19bff29d..05f0d1cd 100644 --- a/internal/tui/provstep.go +++ b/internal/tui/provstep.go @@ -40,7 +40,7 @@ func tailBytes(path string, n int64) []byte { if err != nil { return nil } - defer f.Close() + defer func() { _ = f.Close() }() fi, err := f.Stat() if err != nil { return nil diff --git a/internal/tui/snapshots.go b/internal/tui/snapshots.go index 750481f9..fa192da4 100644 --- a/internal/tui/snapshots.go +++ b/internal/tui/snapshots.go @@ -356,5 +356,5 @@ func (d snapshotDelegate) Render(w io.Writer, m list.Model, index int, item list state = upStyle.Render("disk+ram") } size := dimStyle.Render(fmt.Sprintf("%-9s", it.snap.Size)) - fmt.Fprint(w, cursor+label+size+state+" "+dimStyle.Render(it.snap.Created)) + _, _ = fmt.Fprint(w, cursor+label+size+state+" "+dimStyle.Render(it.snap.Created)) } diff --git a/internal/tui/theme.go b/internal/tui/theme.go index 05e7f24b..acfac053 100644 --- a/internal/tui/theme.go +++ b/internal/tui/theme.go @@ -110,11 +110,6 @@ func radio(label string, on bool) string { return glyphRadioOff + " " + label } -// rowGap separates rows inside a pane. A terminal has no fractional leading, -// so "a bit more line spacing" can only mean one blank line; this names it in -// one place. -const rowGap = "\n\n" - // appContentWidth is the left edge every screen's stacked panes share. Before // this, each pane centered on its own width, so switching screens, or a pane // changing size as content appeared, shifted the whole block sideways. diff --git a/internal/tui/vmlist.go b/internal/tui/vmlist.go index 69269956..78bb0125 100644 --- a/internal/tui/vmlist.go +++ b/internal/tui/vmlist.go @@ -92,7 +92,7 @@ func (d vmDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) } else { reason = downStyle.Render(reason) } - fmt.Fprint(w, lipgloss.JoinHorizontal(lipgloss.Top, glyph, reason)) + _, _ = fmt.Fprint(w, lipgloss.JoinHorizontal(lipgloss.Top, glyph, reason)) return } @@ -132,7 +132,7 @@ func (d vmDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) if selected { label = selStyle.Render(label) } - fmt.Fprint(w, cursor+dotStyle.Render(dot)+" "+label+state) + _, _ = fmt.Fprint(w, cursor+dotStyle.Render(dot)+" "+label+state) } // listWidth and listVisibleRows size the VM list. Fixed rather than derived diff --git a/justfile b/justfile index e76b777b..ab39fcba 100644 --- a/justfile +++ b/justfile @@ -86,6 +86,12 @@ check: go vet ./... go build ./... +# exactly what the lint and shellcheck CI jobs run +[group('dev')] +lint: + golangci-lint run ./... + shellcheck -S warning $(git ls-files "internal/recipes/bundled/*.sh" "internal/recipes/bundled/*/*.sh" "scripts/*.sh" ".githooks/*") + # format in place [group('dev')] fmt: From 29e198dd9cf8bff83c8733132792650590533867 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 00:16:15 +0300 Subject: [PATCH 2/9] fix(recipes): shellcheck findings Quoting and cd checks only. No script changes behaviour. Signed-off-by: NovusEdge --- .githooks/pre-commit | 1 + internal/recipes/bundled/docker/install-debian.sh | 3 ++- internal/recipes/bundled/docker/install.sh | 3 ++- scripts/e2e.sh | 2 +- 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.githooks/pre-commit b/.githooks/pre-commit index f2285ffa..d421493e 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -4,6 +4,7 @@ set -e [ -f go.mod ] || exit 0 # no Go code yet # git ls-files only lists tracked files, so this is already just our own code. # The repo does not vendor: it builds from the module cache, same as CI. +# shellcheck disable=SC2046 # git ls-files output is a list of filenames, splitting is the point unformatted=$(gofmt -l $(git ls-files '*.go') 2>/dev/null) [ -z "$unformatted" ] || { echo "gofmt needed:"; echo "$unformatted"; exit 1; } go vet ./... diff --git a/internal/recipes/bundled/docker/install-debian.sh b/internal/recipes/bundled/docker/install-debian.sh index 71a80955..6cd4290c 100755 --- a/internal/recipes/bundled/docker/install-debian.sh +++ b/internal/recipes/bundled/docker/install-debian.sh @@ -9,7 +9,8 @@ apt-get update apt-get install -y ca-certificates curl gnupg install -m 0755 -d /etc/apt/keyrings -curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg | \ +id=$(. /etc/os-release && echo "$ID") +curl -fsSL "https://download.docker.com/linux/$id/gpg" | \ gpg --batch --yes --no-tty --dearmor -o /etc/apt/keyrings/docker.gpg chmod a+r /etc/apt/keyrings/docker.gpg diff --git a/internal/recipes/bundled/docker/install.sh b/internal/recipes/bundled/docker/install.sh index e291bbe0..3ad91bed 100755 --- a/internal/recipes/bundled/docker/install.sh +++ b/internal/recipes/bundled/docker/install.sh @@ -9,7 +9,8 @@ apt-get update apt-get install -y ca-certificates curl gnupg install -m 0755 -d /etc/apt/keyrings -curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg | \ +id=$(. /etc/os-release && echo "$ID") +curl -fsSL "https://download.docker.com/linux/$id/gpg" | \ gpg --dearmor -o /etc/apt/keyrings/docker.gpg chmod a+r /etc/apt/keyrings/docker.gpg diff --git a/scripts/e2e.sh b/scripts/e2e.sh index 1aa3a622..d5a19710 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -19,7 +19,7 @@ UP_TIMEOUT=1200 # install + xfce pull + reboot-once X_TIMEOUT=90 # Xorg restart after the reboot-once # Prefer the just-built binary over whatever is on PATH. -root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) STOAT="$root/stoat" [ -x "$STOAT" ] || STOAT=stoat From 56f96e451325aa5f8c3a5c103e5883f721275746 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 00:26:38 +0300 Subject: [PATCH 3/9] ci: dependabot and a manual e2e workflow Signed-off-by: NovusEdge --- .github/dependabot.yml | 10 ++++++++++ .github/workflows/e2e.yml | 24 ++++++++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/e2e.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..51a93671 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: gomod + directory: "/" + schedule: + interval: weekly + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..950ce04f --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,24 @@ +name: e2e + +# Manual only. scripts/e2e.sh boots a real KVM VM and takes ~15 minutes; +# no hosted runner has /dev/kvm, so this runs on a self-hosted runner with +# the `kvm` label. It is the gate for the needs-live-boot label. +on: + workflow_dispatch: + +jobs: + e2e: + name: e2e + runs-on: [self-hosted, linux, kvm] + timeout-minutes: 40 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version: '1.26' + - name: kvm + run: test -r /dev/kvm && test -w /dev/kvm + - name: e2e + env: + STOAT_HOME: ${{ runner.temp }}/stoat-e2e + run: ./scripts/e2e.sh From 826e0d704b101c71ff068cf5fe298dcf4a8ae067 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 00:28:36 +0300 Subject: [PATCH 4/9] chore: PR and issue templates Signed-off-by: NovusEdge --- .github/ISSUE_TEMPLATE/bug.yml | 44 +++++++++++++++++++++++++++++++ .github/ISSUE_TEMPLATE/config.yml | 5 ++++ .github/ISSUE_TEMPLATE/guest.yml | 34 ++++++++++++++++++++++++ .github/ISSUE_TEMPLATE/recipe.yml | 34 ++++++++++++++++++++++++ .github/PULL_REQUEST_TEMPLATE.md | 17 ++++++++++++ 5 files changed, 134 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/guest.yml create mode 100644 .github/ISSUE_TEMPLATE/recipe.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 00000000..cac89597 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,44 @@ +name: Bug +description: stoat did something wrong +labels: [bug] +body: + - type: input + id: version + attributes: + label: stoat version + description: Output of `stoat --version` + validations: + required: true + - type: input + id: host + attributes: + label: Host distro and kernel + description: Output of `uname -sr` and the distro name + validations: + required: true + - type: input + id: guest + attributes: + label: Guest OS and mode + description: For example `alpine live`, `ubuntu cloud`, `debian disk` + - type: textarea + id: doctor + attributes: + label: stoat doctor + description: Paste the output of `stoat doctor` + render: text + validations: + required: true + - type: textarea + id: what + attributes: + label: What happened + description: The command or key you pressed, what you expected, what you saw + validations: + required: true + - type: textarea + id: log + attributes: + label: Log tail + description: Paste the last 50 lines of `~/.stoat/logs/stoat.log` and, for a provisioning problem, `~/.stoat//last-provision.log` + render: text diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..202d55e7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Security issue + url: https://github.com/NovusEdge/stoat/security/advisories/new + about: Report a vulnerability privately diff --git a/.github/ISSUE_TEMPLATE/guest.yml b/.github/ISSUE_TEMPLATE/guest.yml new file mode 100644 index 00000000..4eedfb9b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/guest.yml @@ -0,0 +1,34 @@ +name: Guest OS +description: Request support for a guest OS +labels: [guest] +body: + - type: input + id: name + attributes: + label: OS name and version + validations: + required: true + - type: input + id: image + attributes: + label: Cloud image URL + description: A qcow2 or raw image with cloud-init, if one exists + - type: dropdown + id: init + attributes: + label: Init system + options: [systemd, openrc, rc, other] + validations: + required: true + - type: input + id: pkg + attributes: + label: Package manager + description: For example `apt`, `dnf`, `pkg` + validations: + required: true + - type: textarea + id: notes + attributes: + label: Notes + description: Default ssh user, whether sudo or doas ships, anything the installer needs diff --git a/.github/ISSUE_TEMPLATE/recipe.yml b/.github/ISSUE_TEMPLATE/recipe.yml new file mode 100644 index 00000000..5f6f2820 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/recipe.yml @@ -0,0 +1,34 @@ +name: Recipe +description: A bundled recipe is broken, or you want a new one +labels: [recipes] +body: + - type: dropdown + id: kind + attributes: + label: Kind + options: + - A bundled recipe fails + - Request for a new recipe + validations: + required: true + - type: input + id: recipe + attributes: + label: Recipe name + validations: + required: true + - type: input + id: guest + attributes: + label: Guest OS and mode + description: For example `fedora cloud` + validations: + required: true + - type: textarea + id: detail + attributes: + label: Detail + description: For a failure, paste `~/.stoat//last-provision.log`. For a request, say what the recipe installs and which guests it should support. + render: text + validations: + required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..9189cefe --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,17 @@ +## What changed + +## Why + +## Tests run + +- [ ] `just check` and `just test` +- [ ] `just lint` +- [ ] live boot or `just e2e` (required for a change under `internal/core`, `internal/sshx`, `internal/cloudinit`, `internal/apkovl`, or a bundled recipe; paste the output below) + +## OS matrix + +Only for a recipe or guest change. One line per guest OS tested: `alpine: ok`, `debian: not tested`. + +## Docs + +- [ ] `docs/` updated, or no user-visible change From b1a0309fa856ff5949f41f676d8b53b4ad3d83ee Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 00:30:19 +0300 Subject: [PATCH 5/9] chore: CODEOWNERS, security policy, code of conduct Signed-off-by: NovusEdge --- .github/CODEOWNERS | 6 ++++ CODE_OF_CONDUCT.md | 85 ++++++++++++++++++++++++++++++++++++++++++++++ SECURITY.md | 28 +++++++++++++++ 3 files changed, 119 insertions(+) create mode 100644 .github/CODEOWNERS create mode 100644 CODE_OF_CONDUCT.md create mode 100644 SECURITY.md diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..0eb5b00b --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,6 @@ +# Every path requests review from the maintainer. The recipe and docs +# lines exist so a recipe PR shows who reviews it, even while that is +# the same person. +* @NovusEdge +/internal/recipes/bundled/ @NovusEdge +/docs/recipes/ @NovusEdge diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..65159c99 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,85 @@ + +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at a private security advisory at https://github.com/NovusEdge/stoat/security/advisories/new, or an email to the maintainer listed on the GitHub profile. All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of actions. + +**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations + diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..e7cc3d32 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,28 @@ +# Security + +## Reporting + +Open a private security advisory at +https://github.com/NovusEdge/stoat/security/advisories/new. Do not open a +public issue. + +You get an acknowledgement within 7 days and a fix or a decision within +90 days. Public disclosure waits for the fix or the 90 days, whichever +comes first. + +## What counts + +- A guest reaching host files outside the 9p share it was given. +- An MCP tool that runs host code, or accepts a host path outside + `~/.stoat/shared//`. +- A bundled recipe that pipes a download into a root shell without a + checksum or a signature check. +- A secret written to a log, a `--json` result, or a world-readable file. + +## What does not count + +- A guest escaping QEMU. That is QEMU's boundary. +- An agent destroying a VM it was given access to. Snapshots are the + mitigation. +- Prompt injection of an agent that drives stoat. See + `docs/design/core-api.md` §10.4. From eabb153a32032b78b3a54d488e1de5472cf3b9dd Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 00:36:25 +0300 Subject: [PATCH 6/9] docs: CLI conventions for new commands Signed-off-by: NovusEdge --- docs/design/core-api.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/design/core-api.md b/docs/design/core-api.md index 34d98da7..5af93960 100644 --- a/docs/design/core-api.md +++ b/docs/design/core-api.md @@ -327,3 +327,22 @@ Once more than one caller exists, two current mechanisms are unsafe: - **VM directory creation and `vm.toml` writes** are unlocked, so two `Create` calls with the same name race. The API needs a data-root lock held across allocate-and-write in `Create`, and per-VM locks for state transitions. Invisible until it corrupts something, so it is designed in rather than added after. + +## 12. CLI conventions + +Every new command follows these. A reviewer rejects a PR that does not. + +- A command's `--json` data is a struct in `internal/cli/wire`, never an + inline `map[string]any`. The struct is the schema; `json.md` documents + it; the MCP server reuses it. +- `a.fail(stdout, stderr, err)` for an error that came from `core`. + `a.failMsg(stdout, stderr, sentinel, msg)` for a condition the CLI + detected itself, such as a missing flag or a refused prompt. +- A destructive command calls the shared `confirm` helper: `-y` skips + the prompt, `--json` and `--quiet` refuse without `-y`, a TTY prompts. + No command hand-rolls that branch. +- A command that aliases another declares `aliases:"..."` on the kong + struct and shares one `toArgs` case. No duplicate command struct. +- Every TOML file type decodes through `internal/tomlx`, which wraps the + path into errors and reports unknown keys. +- Lists in a `wire` struct are never `null`; use `nonNil`. From 3c6accdc036993a6d666b0aa475bc09649ac6578 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 00:37:59 +0300 Subject: [PATCH 7/9] chore: setup installs hooks; document lint and DCO Signed-off-by: NovusEdge --- CONTRIBUTING.md | 11 +++++++---- justfile | 4 ++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9a117a09..b30e5f5c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,10 +4,11 @@ ```sh just setup # builds, installs to ~/.local/bin, reports missing host deps -just hooks # installs the pre-commit and commit-msg hooks just dev # runs the TUI against a scratch STOAT_HOME ``` +`just setup` also installs the git hooks. + 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 missing. @@ -17,8 +18,8 @@ missing. - Branch off `main`. One change per branch. - Every PR is squash merged. The PR title becomes the commit subject, so write it in the commit grammar below. -- Sign off every commit (`git commit -s`). The DCO check reads the - `Signed-off-by` trailer. +- Sign off every commit (`git commit -s`). The DCO check on the PR reads + the `Signed-off-by` trailer and blocks a merge without it. - No `Co-Authored-By` or tool-attribution trailers. The `commit-msg` hook strips them. - A PR that changes a bundled recipe, a guest, or the boot path gets the @@ -38,7 +39,9 @@ was found. ## Gates `just check` runs gofmt, `go vet`, and `go build`. The pre-commit hook -runs the same. CI runs those plus `go test ./...` on every PR. +runs the same. `just lint` runs golangci-lint and shellcheck. CI runs +all of those plus `go test ./...` on every PR. The DCO app checks the +sign-off trailer on every commit in a PR. ## Tests diff --git a/justfile b/justfile index ab39fcba..c892d88a 100644 --- a/justfile +++ b/justfile @@ -21,12 +21,12 @@ install: build # build and install stoat, interactively: checks the host too [group('build')] -setup: +setup: hooks go run ./cmd/installer # build and install stoat without a TTY: for CI and scripts [group('build')] -setup-headless: +setup-headless: hooks go run ./cmd/installer --no-tty # remove the installed binary: never touches ~/.stoat From 081ff6238d3eef75697c216b859d549a58eef9e8 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 00:49:54 +0300 Subject: [PATCH 8/9] ci: keep golangci-lint off style and print errors The spec asks this config to enable nothing that argues about style. staticcheck's ST and QF families do: QF1001 rewrote an allowlist test in qemu/sendkey.go into its De Morgan form, against the comment above it. errcheck on fmt.Fprint to stdout added 128 discard prefixes a CLI cannot act on. Signed-off-by: NovusEdge --- .golangci.yml | 12 ++++++++ internal/cli/cli.go | 18 +++++------ internal/cli/run_access.go | 14 ++++----- internal/cli/run_apply.go | 8 ++--- internal/cli/run_get.go | 30 +++++++++---------- internal/cli/run_image.go | 10 +++---- internal/cli/run_misc.go | 16 +++++----- internal/cli/run_recipes.go | 10 +++---- internal/cli/run_state.go | 34 ++++++++++----------- internal/cli/run_update.go | 4 +-- internal/cli/run_vm.go | 60 ++++++++++++++++++------------------- internal/cli/run_wait.go | 2 +- internal/qemu/run.go | 2 +- internal/qemu/sendkey.go | 2 +- internal/sshx/sharemount.go | 4 +-- internal/sshx/sshx.go | 28 ++++++++--------- internal/tui/imagemodal.go | 8 ++--- internal/tui/snapshots.go | 2 +- internal/tui/vmlist.go | 4 +-- 19 files changed, 140 insertions(+), 128 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 34ab4506..744bdcb8 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -6,3 +6,15 @@ linters: - govet - unused - misspell + settings: + errcheck: + # A CLI cannot act on a failed write to its own stdout or stderr. The + # exclusion keeps every print site free of a two-token discard. + exclude-functions: + - fmt.Fprint + - fmt.Fprintf + - fmt.Fprintln + staticcheck: + # ST and QF rewrite working code for taste. QF1001 turned an allowlist + # test into its De Morgan form and broke the comment above it. + checks: ["all", "-ST*", "-QF*"] diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 92ab393e..3ca19ae3 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -266,7 +266,7 @@ func (a *Args) fail(stdout, stderr io.Writer, err error) int { _ = wire.NewEmitter(stdout).ResultErr(a.Cmd, wire.MapError(err)) return ExitFail } - _, _ = fmt.Fprintf(stderr, "stoat: %s: %v\n", a.Cmd, err) + fmt.Fprintf(stderr, "stoat: %s: %v\n", a.Cmd, err) return ExitFail } @@ -278,7 +278,7 @@ func (a *Args) failMsg(stdout, stderr io.Writer, sentinel error, msg string) int _ = wire.NewEmitter(stdout).ResultErr(a.Cmd, wire.MapError(fmt.Errorf("%w: %s", sentinel, msg))) return ExitFail } - _, _ = fmt.Fprintf(stderr, "stoat: %s: %s\n", a.Cmd, msg) + fmt.Fprintf(stderr, "stoat: %s: %s\n", a.Cmd, msg) return ExitFail } @@ -298,7 +298,7 @@ func Main(args []string, version string, stdin io.Reader, stdout, stderr io.Writ // or a dead process, never a silent exit with neither. defer func() { if r := recover(); r != nil { - _, _ = fmt.Fprintf(stderr, "panic: %v\n", r) + fmt.Fprintf(stderr, "panic: %v\n", r) _ = wire.NewEmitter(stdout).ResultErr(cmd, wire.InternalError(fmt.Sprintf("panic: %v", r))) code = ExitFail } @@ -311,8 +311,8 @@ func Main(args []string, version string, stdin io.Reader, stdout, stderr io.Writ _ = wire.NewEmitter(stdout).ResultErr(cmd, wire.UsageError(err.Error())) return ExitUsage } - _, _ = fmt.Fprintln(stderr, "stoat:", err) - _, _ = fmt.Fprintln(stderr, helpText()) + fmt.Fprintln(stderr, "stoat:", err) + fmt.Fprintln(stderr, helpText()) return ExitUsage } a.JSON = jsonMode @@ -328,13 +328,13 @@ func Main(args []string, version string, stdin io.Reader, stdout, stderr io.Writ if a.JSON { return a.ok(stdout, map[string]any{"usage": a.Help}) } - _, _ = fmt.Fprintln(stdout, a.Help) + fmt.Fprintln(stdout, a.Help) return ExitOK case "version": if a.JSON { return a.ok(stdout, map[string]any{"version": version, "contract": wire.ContractVersion}) } - _, _ = fmt.Fprintln(stdout, "stoat", version) + fmt.Fprintln(stdout, "stoat", version) return ExitOK } @@ -347,7 +347,7 @@ func Main(args []string, version string, stdin io.Reader, stdout, stderr io.Writ _ = wire.NewEmitter(stdout).ResultErr(a.Cmd, wire.MapError(err)) return ExitFail } - _, _ = fmt.Fprintln(stderr, "stoat:", err) + fmt.Fprintln(stderr, "stoat:", err) return ExitFail } } @@ -411,7 +411,7 @@ func Main(args []string, version string, stdin io.Reader, stdout, stderr io.Writ return runDoctor(a, stdout, stderr) default: // Unreachable: Parse already rejected anything not handled above. - _, _ = fmt.Fprintln(stderr, "stoat: unknown subcommand", a.Cmd) + fmt.Fprintln(stderr, "stoat: unknown subcommand", a.Cmd) return ExitUsage } } diff --git a/internal/cli/run_access.go b/internal/cli/run_access.go index efd325ee..3d587d06 100644 --- a/internal/cli/run_access.go +++ b/internal/cli/run_access.go @@ -40,9 +40,9 @@ func runCopy(a *Args, stdout, stderr io.Writer) int { } if !a.Quiet { if a.ToRemote { - _, _ = fmt.Fprintf(stdout, "copied %s to %s:%s\n", a.Local, a.VM, a.Remote) + fmt.Fprintf(stdout, "copied %s to %s:%s\n", a.Local, a.VM, a.Remote) } else { - _, _ = fmt.Fprintf(stdout, "copied %s:%s to %s\n", a.VM, a.Remote, a.Local) + fmt.Fprintf(stdout, "copied %s:%s to %s\n", a.VM, a.Remote, a.Local) } } return ExitOK @@ -83,8 +83,8 @@ func runExec(a *Args, stdout, stderr io.Writer) int { } // Streamed through verbatim, on the matching stream, so a caller can pipe // stdout without stderr contaminating it. - _, _ = fmt.Fprint(stdout, res.Stdout) - _, _ = fmt.Fprint(stderr, res.Stderr) + fmt.Fprint(stdout, res.Stdout) + fmt.Fprint(stderr, res.Stderr) return res.ExitCode } @@ -103,17 +103,17 @@ func runSSH(a *Args, stdout, stderr io.Writer) int { } v, err := config.Load(a.VM) if err != nil { - _, _ = fmt.Fprintln(stderr, "stoat: ssh:", err) + fmt.Fprintln(stderr, "stoat: ssh:", err) return ExitFail } path, err := exec.LookPath("ssh") if err != nil { - _, _ = fmt.Fprintln(stderr, "stoat: ssh:", err) + fmt.Fprintln(stderr, "stoat: ssh:", err) return ExitFail } argv := append([]string{"ssh"}, sshx.Args(v)...) if err := syscall.Exec(path, argv, os.Environ()); err != nil { - _, _ = fmt.Fprintln(stderr, "stoat: ssh:", err) + fmt.Fprintln(stderr, "stoat: ssh:", err) return ExitFail } return ExitOK // unreachable on success: the process image is gone diff --git a/internal/cli/run_apply.go b/internal/cli/run_apply.go index 46958173..26c66f89 100644 --- a/internal/cli/run_apply.go +++ b/internal/cli/run_apply.go @@ -34,7 +34,7 @@ func runApply(a *Args, stdout, stderr io.Writer) int { } if !a.Quiet { - _, _ = fmt.Fprintf(stdout, "applying recipes to %s...\n", a.VM) + fmt.Fprintf(stdout, "applying recipes to %s...\n", a.VM) } done := make(chan error, 1) @@ -63,7 +63,7 @@ func runApply(a *Args, stdout, stderr io.Writer) int { // bool. skipped_reason is what distinguishes the two. return a.ok(stdout, map[string]any{"vm": a.VM, "applied": []string{}, "skipped_reason": "an apply is already running"}) } - _, _ = fmt.Fprintf(stdout, "%s: an apply is already running\n", a.VM) + fmt.Fprintf(stdout, "%s: an apply is already running\n", a.VM) return ExitOK } if aerr != nil { @@ -76,7 +76,7 @@ func runApply(a *Args, stdout, stderr io.Writer) int { } return a.ok(stdout, map[string]any{"vm": a.VM, "applied": applied, "skipped_reason": ""}) } - _, _ = fmt.Fprintf(stdout, "%s: recipes applied\n", a.VM) + fmt.Fprintf(stdout, "%s: recipes applied\n", a.VM) return ExitOK } @@ -103,7 +103,7 @@ func runApplyDryRun(a *Args, stdout, stderr io.Writer) int { if p.Version != "" { reason = fmt.Sprintf("%s at %s", p.Reason, p.Version) } - _, _ = fmt.Fprintf(stdout, "%s (%s, %s)\n", p.Name, p.Action, reason) + fmt.Fprintf(stdout, "%s (%s, %s)\n", p.Name, p.Action, reason) } return ExitOK } diff --git a/internal/cli/run_get.go b/internal/cli/run_get.go index 5b649b9f..c325b260 100644 --- a/internal/cli/run_get.go +++ b/internal/cli/run_get.go @@ -17,29 +17,29 @@ func runGet(a *Args, stdout, stderr io.Writer) int { if a.JSON { return a.ok(stdout, map[string]any{"vm": wire.FromVM(v, core.GraphicalSession())}) } - _, _ = fmt.Fprintf(stdout, "name: %s\n", v.Name) - _, _ = fmt.Fprintf(stdout, "os: %s\n", v.OS) - _, _ = fmt.Fprintf(stdout, "mode: %s\n", v.Mode) - _, _ = fmt.Fprintf(stdout, "backend: %s\n", v.Backend) - _, _ = fmt.Fprintf(stdout, "state: %s\n", v.State) - _, _ = fmt.Fprintf(stdout, "cpus: %d\n", v.CPUs) - _, _ = fmt.Fprintf(stdout, "ram: %d\n", v.RAM) - _, _ = fmt.Fprintf(stdout, "disk: %s\n", v.Disk) - _, _ = fmt.Fprintf(stdout, "share: %s\n", v.Share) - _, _ = fmt.Fprintf(stdout, "ssh port: %d\n", v.SSHPort) - _, _ = fmt.Fprintf(stdout, "ssh user: %s\n", v.SSHUser) - _, _ = fmt.Fprintf(stdout, "recipes: %s\n", strings.Join(v.Recipes, ", ")) + fmt.Fprintf(stdout, "name: %s\n", v.Name) + fmt.Fprintf(stdout, "os: %s\n", v.OS) + fmt.Fprintf(stdout, "mode: %s\n", v.Mode) + fmt.Fprintf(stdout, "backend: %s\n", v.Backend) + fmt.Fprintf(stdout, "state: %s\n", v.State) + fmt.Fprintf(stdout, "cpus: %d\n", v.CPUs) + fmt.Fprintf(stdout, "ram: %d\n", v.RAM) + fmt.Fprintf(stdout, "disk: %s\n", v.Disk) + fmt.Fprintf(stdout, "share: %s\n", v.Share) + fmt.Fprintf(stdout, "ssh port: %d\n", v.SSHPort) + fmt.Fprintf(stdout, "ssh user: %s\n", v.SSHUser) + fmt.Fprintf(stdout, "recipes: %s\n", strings.Join(v.Recipes, ", ")) forwards := make([]string, len(v.Forwards)) for i, f := range v.Forwards { forwards[i] = fmt.Sprintf("%d:%d", f.HostPort, f.GuestPort) } - _, _ = fmt.Fprintf(stdout, "forwards: %s\n", strings.Join(forwards, ", ")) + fmt.Fprintf(stdout, "forwards: %s\n", strings.Join(forwards, ", ")) // The one line here that is not a vm.toml field: where the screen is. A // user who has lost their window asks `get` before they ask anything // else, and until now it was the one place that did not answer. printDisplay(stdout, core.DisplayFor(v, core.GraphicalSession())) if v.State == core.StateBroken { - _, _ = fmt.Fprintf(stdout, "error: %s\n", v.Error) + fmt.Fprintf(stdout, "error: %s\n", v.Error) } return ExitOK } @@ -52,6 +52,6 @@ func runSSHCommand(a *Args, stdout, stderr io.Writer) int { if a.JSON { return a.ok(stdout, map[string]any{"argv": argv}) } - _, _ = fmt.Fprintln(stdout, strings.Join(argv, " ")) + fmt.Fprintln(stdout, strings.Join(argv, " ")) return ExitOK } diff --git a/internal/cli/run_image.go b/internal/cli/run_image.go index c1ddecfd..1ad4c44a 100644 --- a/internal/cli/run_image.go +++ b/internal/cli/run_image.go @@ -22,7 +22,7 @@ func runImages(a *Args, stdout, stderr io.Writer) int { if a.JSON { return a.ok(stdout, map[string]any{"images": wire.FromCatalogImages(imgs)}) } - _, _ = fmt.Fprintf(stdout, "%-16s %-9s %-11s %-10s %s\n", "ID", "OS", "VARIANT", "SIZE", "STATE") + fmt.Fprintf(stdout, "%-16s %-9s %-11s %-10s %s\n", "ID", "OS", "VARIANT", "SIZE", "STATE") for _, i := range imgs { size := humanSize(i.Bytes) if !i.Exact { @@ -37,7 +37,7 @@ func runImages(a *Args, stdout, stderr io.Writer) int { id = i.File // a byo file has no catalog id state = "byo" } - _, _ = fmt.Fprintf(stdout, "%-16s %-9s %-11s %-10s %s\n", id, i.OS, i.Variant, size, state) + fmt.Fprintf(stdout, "%-16s %-9s %-11s %-10s %s\n", id, i.OS, i.Variant, size, state) } return ExitOK } @@ -68,7 +68,7 @@ func runPull(a *Args, stdout, stderr io.Writer) int { "id": a.VM, "done": done, "total": total, "percent": pct, }) case !a.Quiet: - _, _ = fmt.Fprintf(stdout, "\r%s %3d%% %s / %s", a.VM, pct, humanSize(done), humanSize(total)) + fmt.Fprintf(stdout, "\r%s %3d%% %s / %s", a.VM, pct, humanSize(done), humanSize(total)) } } res, err := core.DownloadImage(context.Background(), a.VM, progress) @@ -76,7 +76,7 @@ func runPull(a *Args, stdout, stderr io.Writer) int { if a.JSON { return a.fail(stdout, stderr, err) } - _, _ = fmt.Fprintln(stderr, "\nstoat: pull:", err) + fmt.Fprintln(stderr, "\nstoat: pull:", err) return ExitFail } if a.JSON { @@ -96,7 +96,7 @@ func runPull(a *Args, stdout, stderr io.Writer) int { if !res.ChecksumAvailable { note = ": UNVERIFIED (no published checksum)" } - _, _ = fmt.Fprintf(stdout, "\r%s downloaded%s%s\n", a.VM, note, strings.Repeat(" ", 30)) + fmt.Fprintf(stdout, "\r%s downloaded%s%s\n", a.VM, note, strings.Repeat(" ", 30)) } return ExitOK } diff --git a/internal/cli/run_misc.go b/internal/cli/run_misc.go index 7b6d6c99..b351e9a7 100644 --- a/internal/cli/run_misc.go +++ b/internal/cli/run_misc.go @@ -72,7 +72,7 @@ func runLogs(a *Args, stdout, stderr io.Writer) int { return a.ok(stdout, map[string]any{"vm": a.VM, "which": string(a.Which), "lines": lines}) } for _, l := range lines { - _, _ = fmt.Fprintln(stdout, l) + fmt.Fprintln(stdout, l) } return ExitOK } @@ -91,7 +91,7 @@ func runLogs(a *Args, stdout, stderr io.Writer) int { return a.ok(stdout, map[string]any{"lines": lines}) } for _, l := range lines { - _, _ = fmt.Fprintln(stdout, l) + fmt.Fprintln(stdout, l) } return ExitOK } @@ -141,13 +141,13 @@ func runRecipe(a *Args, stdout, stderr io.Writer) int { } return a.ok(stdout, map[string]any{"dir": recipes.Dir(), "recipes": names}) } - _, _ = fmt.Fprintln(stdout, recipes.Dir()) + fmt.Fprintln(stdout, recipes.Dir()) if len(names) == 0 { - _, _ = fmt.Fprintln(stdout, " (none)") + fmt.Fprintln(stdout, " (none)") return ExitOK } for _, n := range names { - _, _ = fmt.Fprintln(stdout, " "+n) + fmt.Fprintln(stdout, " "+n) } return ExitOK @@ -159,9 +159,9 @@ func runRecipe(a *Args, stdout, stderr io.Writer) int { if a.JSON { return a.ok(stdout, map[string]any{"path": path}) } - _, _ = fmt.Fprintln(stdout, path) + fmt.Fprintln(stdout, path) if !a.Quiet { - _, _ = fmt.Fprintln(stdout, "edit it, then pick it in the new-vm form for a matching vm") + fmt.Fprintln(stdout, "edit it, then pick it in the new-vm form for a matching vm") } return ExitOK } @@ -170,6 +170,6 @@ func runRecipe(a *Args, stdout, stderr io.Writer) int { _ = wire.NewEmitter(stdout).ResultErr(a.Cmd, wire.UsageError("recipe: unknown action "+a.Sub)) return ExitUsage } - _, _ = fmt.Fprintln(stderr, "stoat: recipe: unknown action", a.Sub) + fmt.Fprintln(stderr, "stoat: recipe: unknown action", a.Sub) return ExitUsage } diff --git a/internal/cli/run_recipes.go b/internal/cli/run_recipes.go index 0dd8d25c..3da2d1f1 100644 --- a/internal/cli/run_recipes.go +++ b/internal/cli/run_recipes.go @@ -79,12 +79,12 @@ func runRecipes(a *Args, stdout, stderr io.Writer) int { return a.ok(stdout, map[string]any{"recipes": wire.FromRecipes(rs)}) } if len(rs) == 0 { - _, _ = fmt.Fprintln(stdout, "no recipes") + fmt.Fprintln(stdout, "no recipes") return ExitOK } - _, _ = fmt.Fprintf(stdout, "%-30s %s\n", "NAME", "DESCRIPTION") + fmt.Fprintf(stdout, "%-30s %s\n", "NAME", "DESCRIPTION") for _, r := range rs { - _, _ = fmt.Fprintf(stdout, "%-30s %s\n", r.Name, r.Description) + fmt.Fprintf(stdout, "%-30s %s\n", r.Name, r.Description) } return ExitOK } @@ -108,11 +108,11 @@ func runCheckRecipes(a *Args, stdout, stderr io.Writer) int { }) } if len(issues) == 0 { - _, _ = fmt.Fprintln(stdout, "all applicable") + fmt.Fprintln(stdout, "all applicable") return ExitOK } for _, i := range issues { - _, _ = fmt.Fprintf(stdout, "%s: %s\n", i.Name, i.Reason) + fmt.Fprintf(stdout, "%s: %s\n", i.Name, i.Reason) } return ExitOK } diff --git a/internal/cli/run_state.go b/internal/cli/run_state.go index ce10acb4..bb5f3434 100644 --- a/internal/cli/run_state.go +++ b/internal/cli/run_state.go @@ -55,11 +55,11 @@ func runForward(a *Args, stdout, stderr io.Writer) int { }) } if len(v.Forwards) == 0 { - _, _ = fmt.Fprintf(stdout, "%s has no port forwards\n", a.VM) + fmt.Fprintf(stdout, "%s has no port forwards\n", a.VM) return ExitOK } for _, f := range v.Forwards { - _, _ = fmt.Fprintf(stdout, "%d:%d\n", f.HostPort, f.GuestPort) + fmt.Fprintf(stdout, "%d:%d\n", f.HostPort, f.GuestPort) } return ExitOK } @@ -88,14 +88,14 @@ func runForward(a *Args, stdout, stderr io.Writer) int { } switch { case a.Clear: - _, _ = fmt.Fprintf(stdout, "cleared %s's port forwards\n", a.VM) + fmt.Fprintf(stdout, "cleared %s's port forwards\n", a.VM) default: for _, f := range a.Forwards { - _, _ = fmt.Fprintf(stdout, "%d:%d\n", f.HostPort, f.GuestPort) + fmt.Fprintf(stdout, "%d:%d\n", f.HostPort, f.GuestPort) } } if !active { - _, _ = fmt.Fprintf(stdout, "%s is running; this takes effect at next start\n", a.VM) + fmt.Fprintf(stdout, "%s is running; this takes effect at next start\n", a.VM) } return ExitOK } @@ -115,14 +115,14 @@ func runPrune(a *Args, stdout, stderr io.Writer) int { }) } if len(removed) == 0 { - _, _ = fmt.Fprintln(stdout, "nothing to prune") + fmt.Fprintln(stdout, "nothing to prune") return ExitOK } for _, r := range removed { - _, _ = fmt.Fprintln(stdout, prunePrefix(r.Class)+r.Path) + fmt.Fprintln(stdout, prunePrefix(r.Class)+r.Path) } if a.Prune.DryRun { - _, _ = fmt.Fprintln(stdout, "\n(dry run: nothing was deleted; re-run with --apply)") + fmt.Fprintln(stdout, "\n(dry run: nothing was deleted; re-run with --apply)") } return ExitOK } @@ -170,11 +170,11 @@ func runSnapshot(a *Args, stdout, stderr io.Writer) int { if !a.Quiet { switch action { case "restore": - _, _ = fmt.Fprintf(stdout, "%s restored to %s\n", a.VM, a.Tag) + fmt.Fprintf(stdout, "%s restored to %s\n", a.VM, a.Tag) case "delete": - _, _ = fmt.Fprintf(stdout, "deleted %s\n", a.Tag) + fmt.Fprintf(stdout, "deleted %s\n", a.Tag) case "save": - _, _ = fmt.Fprintf(stdout, "saved %s\n", a.Tag) + fmt.Fprintf(stdout, "saved %s\n", a.Tag) } } return ExitOK @@ -188,16 +188,16 @@ func runSnapshot(a *Args, stdout, stderr io.Writer) int { return a.ok(stdout, map[string]any{"vm": a.VM, "snapshots": wire.FromSnapshots(snaps)}) } if len(snaps) == 0 { - _, _ = fmt.Fprintf(stdout, "%s has no snapshots\n", a.VM) + fmt.Fprintf(stdout, "%s has no snapshots\n", a.VM) return ExitOK } - _, _ = fmt.Fprintf(stdout, "%-24s %-10s %-20s %s\n", "TAG", "SIZE", "CREATED", "RAM") + fmt.Fprintf(stdout, "%-24s %-10s %-20s %s\n", "TAG", "SIZE", "CREATED", "RAM") for _, s := range snaps { ram := "no" if s.VMState { ram = "yes" } - _, _ = fmt.Fprintf(stdout, "%-24s %-10s %-20s %s\n", s.Tag, s.Size, s.Created, ram) + fmt.Fprintf(stdout, "%-24s %-10s %-20s %s\n", s.Tag, s.Size, s.Created, ram) } return ExitOK } @@ -227,13 +227,13 @@ func runDoctor(a *Args, stdout, stderr io.Writer) int { }) } if len(failed) == 0 { - _, _ = fmt.Fprintln(stdout, "ok") + fmt.Fprintln(stdout, "ok") return ExitOK } for _, c := range failed { - _, _ = fmt.Fprintf(stdout, "FAIL: %s: %s\n", c.Name, c.Detail) + fmt.Fprintf(stdout, "FAIL: %s: %s\n", c.Name, c.Detail) if len(c.Fix) > 0 { - _, _ = fmt.Fprintf(stdout, " try: %s\n", strings.Join(c.Fix, " ")) + fmt.Fprintf(stdout, " try: %s\n", strings.Join(c.Fix, " ")) } } return ExitFail diff --git a/internal/cli/run_update.go b/internal/cli/run_update.go index ab9164a6..a652322e 100644 --- a/internal/cli/run_update.go +++ b/internal/cli/run_update.go @@ -28,9 +28,9 @@ func runUpdate(a *Args, stdout, stderr io.Writer) int { }) } if !a.Quiet { - _, _ = fmt.Fprintf(stdout, "updated %s: %v\n", v.Name, a.Changed) + fmt.Fprintf(stdout, "updated %s: %v\n", v.Name, a.Changed) if appliesAt(v) == "next_start" { - _, _ = fmt.Fprintf(stdout, "%s is running; this takes effect at next start\n", v.Name) + fmt.Fprintf(stdout, "%s is running; this takes effect at next start\n", v.Name) } } return ExitOK diff --git a/internal/cli/run_vm.go b/internal/cli/run_vm.go index 982775f1..b4d76061 100644 --- a/internal/cli/run_vm.go +++ b/internal/cli/run_vm.go @@ -23,7 +23,7 @@ func runLS(a *Args, stdout, stderr io.Writer) int { return a.ok(stdout, map[string]any{"vms": wire.FromVMs(vms, core.GraphicalSession())}) } - _, _ = fmt.Fprintf(stdout, "%-15s %-5s %-8s %-5s %-6s %s\n", "NAME", "MODE", "STATE", "CPUS", "RAM", "SSH") + fmt.Fprintf(stdout, "%-15s %-5s %-8s %-5s %-6s %s\n", "NAME", "MODE", "STATE", "CPUS", "RAM", "SSH") // core.List() sorts every VM, broken ones included, together by name, // so a broken VM can interleave alphabetically with good ones. The // original two calls (config.List then config.ListBroken) printed every @@ -39,7 +39,7 @@ func runLS(a *Args, stdout, stderr io.Writer) int { if v.State == core.StateRunning { state = "running" } - _, _ = fmt.Fprintf(stdout, "%-15s %-5s %s %-5d %-6d %d\n", + fmt.Fprintf(stdout, "%-15s %-5s %s %-5d %-6d %d\n", v.Name, v.Mode, colorState(state, 8), v.CPUs, v.RAM, v.SSHPort) } // Broken VMs are real entries: hiding them is the bug that was already @@ -49,7 +49,7 @@ func runLS(a *Args, stdout, stderr io.Writer) int { if v.State != core.StateBroken { continue } - _, _ = fmt.Fprintf(stdout, "%-15s %-5s %s %-5s %-6s %-4s %s\n", + fmt.Fprintf(stdout, "%-15s %-5s %s %-5s %-6s %-4s %s\n", v.Name, "-", colorState("broken", 8), "-", "-", "-", oneLine(v.Error)) } return ExitOK @@ -71,7 +71,7 @@ func runUp(a *Args, stdout, stderr io.Writer) int { return a.failMsg(stdout, stderr, core.ErrBroken, v.Error) } if !a.Quiet { - _, _ = fmt.Fprintf(stdout, "starting %s...\n", a.VM) + fmt.Fprintf(stdout, "starting %s...\n", a.VM) } if err := core.Start(a.VM); err != nil { return a.fail(stdout, stderr, err) @@ -84,7 +84,7 @@ func runUp(a *Args, stdout, stderr io.Writer) int { } return a.ok(stdout, map[string]any{"vm": wire.FromVM(v, core.GraphicalSession())}) } - _, _ = fmt.Fprintf(stdout, "%s started (ssh :%d)\n", a.VM, v.SSHPort) + fmt.Fprintf(stdout, "%s started (ssh :%d)\n", a.VM, v.SSHPort) // Re-read for the display line too: Start is what flips a disk VM to // installed, and that flip is exactly what moves the screen off the qemu // window. The pre-Start copy would announce a window that is not there. @@ -100,11 +100,11 @@ func runUp(a *Args, stdout, stderr io.Writer) int { // boot the installed system either way. if v.Mode == "disk" && !v.Installed { if !a.Quiet { - _, _ = fmt.Fprintf(stdout, "installing %s (a few minutes)...\n", a.VM) + fmt.Fprintf(stdout, "installing %s (a few minutes)...\n", a.VM) } restarted, err := core.AutoRestartAfterInstall(context.Background(), a.VM) if err != nil || !restarted { - _, _ = fmt.Fprintf(stdout, "install did not finish; inspect: stoat logs %s\n", a.VM) + fmt.Fprintf(stdout, "install did not finish; inspect: stoat logs %s\n", a.VM) return ExitOK } if started, err := core.Get(a.VM); err == nil { @@ -135,7 +135,7 @@ func afterStart(a *Args, v core.VM, stdout, stderr io.Writer) int { } if !a.Quiet { - _, _ = fmt.Fprintf(stdout, "waiting for ssh on %s...\n", a.VM) + fmt.Fprintf(stdout, "waiting for ssh on %s...\n", a.VM) } ctx, cancel := context.WithTimeout(context.Background(), sshx.WaitTimeout) defer cancel() @@ -144,7 +144,7 @@ func afterStart(a *Args, v core.VM, stdout, stderr io.Writer) int { } if !a.Quiet { - _, _ = fmt.Fprintf(stdout, "applying recipes to %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{}) }() @@ -153,12 +153,12 @@ func afterStart(a *Args, v core.VM, stdout, stderr io.Writer) int { // 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) + fmt.Fprintf(stdout, "%s: an apply is already running\n", a.VM) return ExitOK } return a.fail(stdout, stderr, err) } - _, _ = fmt.Fprintf(stdout, "%s: recipes applied\n", a.VM) + fmt.Fprintf(stdout, "%s: recipes applied\n", a.VM) return ExitOK } @@ -172,7 +172,7 @@ func afterStart(a *Args, v core.VM, stdout, stderr io.Writer) int { func printDisplay(w io.Writer, d core.Display) { switch d.Kind { case core.DisplayWindow: - _, _ = fmt.Fprintln(w, "display: a qemu window") + fmt.Fprintln(w, "display: a qemu window") case core.DisplayVNC: if d.NoSession { // Said before the socket line, because without it "no qemu window" @@ -180,17 +180,17 @@ func printDisplay(w io.Writer, d core.Display) { // "no usable session" rather than "no session": the same line // prints when the user set STOAT_GRAPHICAL=0 on a host that plainly // has one, because its GTK cannot draw on it. - _, _ = fmt.Fprintln(w, "display: no usable graphical session on this host, so the screen") - _, _ = fmt.Fprintln(w, " is on VNC instead; attach to watch it") + fmt.Fprintln(w, "display: no usable graphical session on this host, so the screen") + fmt.Fprintln(w, " is on VNC instead; attach to watch it") } - _, _ = fmt.Fprintf(w, "display: no qemu window; the screen is on %s\n", d.Socket) + fmt.Fprintf(w, "display: no qemu window; the screen is on %s\n", d.Socket) if d.Attach.Command == "" { - _, _ = fmt.Fprintf(w, " no VNC viewer found; install one of: %s\n", strings.Join(d.Attach.Missing, ", ")) + fmt.Fprintf(w, " no VNC viewer found; install one of: %s\n", strings.Join(d.Attach.Missing, ", ")) return } - _, _ = fmt.Fprintf(w, " attach with: %s\n", d.Attach.Command) + fmt.Fprintf(w, " attach with: %s\n", d.Attach.Command) if d.Attach.Then != "" { - _, _ = fmt.Fprintf(w, " %s\n", d.Attach.Then) + fmt.Fprintf(w, " %s\n", d.Attach.Then) } } } @@ -209,7 +209,7 @@ func runDown(a *Args, stdout, stderr io.Writer) int { return a.failMsg(stdout, stderr, core.ErrNotRunning, a.VM+" is not running") } if !a.Quiet { - _, _ = fmt.Fprintf(stdout, "stopping %s...\n", a.VM) + fmt.Fprintf(stdout, "stopping %s...\n", a.VM) } if err := core.Stop(a.VM); err != nil { // The State check above catches the common case before any output @@ -226,7 +226,7 @@ func runDown(a *Args, stdout, stderr io.Writer) int { } return a.ok(stdout, map[string]any{"vm": wire.FromVM(v, core.GraphicalSession())}) } - _, _ = fmt.Fprintf(stdout, "%s stopped\n", a.VM) + fmt.Fprintf(stdout, "%s stopped\n", a.VM) return ExitOK } @@ -245,13 +245,13 @@ func runRM(a *Args, stdin io.Reader, stdout, stderr io.Writer) int { return a.fail(stdout, stderr, fmt.Errorf("%w: %s", wire.ErrConfirmationRequired, a.VM)) } if a.Quiet { - _, _ = fmt.Fprintln(stderr, "stoat: rm: refusing to delete without -y in non-interactive mode") + fmt.Fprintln(stderr, "stoat: rm: refusing to delete without -y in non-interactive mode") return ExitFail } - _, _ = fmt.Fprintf(stdout, "delete VM %s? [y/N] ", a.VM) + fmt.Fprintf(stdout, "delete VM %s? [y/N] ", a.VM) line, _ := bufio.NewReader(stdin).ReadString('\n') if strings.ToLower(strings.TrimSpace(line)) != "y" { - _, _ = fmt.Fprintln(stdout, "aborted") + fmt.Fprintln(stdout, "aborted") return ExitFail } } @@ -267,7 +267,7 @@ func runRM(a *Args, stdin io.Reader, stdout, stderr io.Writer) int { if a.JSON { return a.ok(stdout, map[string]any{"name": a.VM, "deleted": true}) } - _, _ = fmt.Fprintf(stdout, "%s deleted\n", a.VM) + fmt.Fprintf(stdout, "%s deleted\n", a.VM) return ExitOK } @@ -280,9 +280,9 @@ func runCreate(a *Args, stdout, stderr io.Writer) int { if a.JSON { return a.fail(stdout, stderr, err) } - _, _ = fmt.Fprintln(stderr, "stoat: create:", err) + fmt.Fprintln(stderr, "stoat: create:", err) if errors.Is(err, core.ErrImageNotDownloaded) { - _, _ = fmt.Fprintln(stderr, "stoat: download it from the TUI's image picker first") + fmt.Fprintln(stderr, "stoat: download it from the TUI's image picker first") } return ExitFail } @@ -290,8 +290,8 @@ func runCreate(a *Args, stdout, stderr io.Writer) int { return a.ok(stdout, map[string]any{"vm": wire.FromVM(v, core.GraphicalSession())}) } if !a.Quiet { - _, _ = fmt.Fprintf(stdout, "created %s (%s, %s, ssh port %d)\n", v.Name, v.OS, v.Mode, v.SSHPort) - _, _ = fmt.Fprintf(stdout, "start it with: stoat up %s\n", v.Name) + fmt.Fprintf(stdout, "created %s (%s, %s, ssh port %d)\n", v.Name, v.OS, v.Mode, v.SSHPort) + fmt.Fprintf(stdout, "start it with: stoat up %s\n", v.Name) } return ExitOK } @@ -314,8 +314,8 @@ func runClone(a *Args, stdout, stderr io.Writer) int { }) } if !a.Quiet { - _, _ = fmt.Fprintf(stdout, "cloned %s to %s (ssh :%d)\n", a.VM, v.Name, v.SSHPort) - _, _ = fmt.Fprintf(stdout, "port forwards were not copied; set them with: stoat forward %s ...\n", v.Name) + fmt.Fprintf(stdout, "cloned %s to %s (ssh :%d)\n", a.VM, v.Name, v.SSHPort) + fmt.Fprintf(stdout, "port forwards were not copied; set them with: stoat forward %s ...\n", v.Name) } return ExitOK } diff --git a/internal/cli/run_wait.go b/internal/cli/run_wait.go index ab7a4c7c..58234492 100644 --- a/internal/cli/run_wait.go +++ b/internal/cli/run_wait.go @@ -33,7 +33,7 @@ func runWait(a *Args, stdout, stderr io.Writer) int { }) } if !a.Quiet { - _, _ = fmt.Fprintf(stdout, "%s reached %s (%dms)\n", a.VM, a.Until, waited.Milliseconds()) + fmt.Fprintf(stdout, "%s reached %s (%dms)\n", a.VM, a.Until, waited.Milliseconds()) } return ExitOK } diff --git a/internal/qemu/run.go b/internal/qemu/run.go index 561453e7..7424d353 100644 --- a/internal/qemu/run.go +++ b/internal/qemu/run.go @@ -202,7 +202,7 @@ func Stop(v *config.VM) error { return nil } if c, err := dialMonitor(v); err == nil { - _, _ = fmt.Fprintln(c, "system_powerdown") + fmt.Fprintln(c, "system_powerdown") _ = c.Close() for i := 0; i < 100; i++ { if !Running(v) { diff --git a/internal/qemu/sendkey.go b/internal/qemu/sendkey.go index 0b9307fe..876a9a2c 100644 --- a/internal/qemu/sendkey.go +++ b/internal/qemu/sendkey.go @@ -36,7 +36,7 @@ const consoleKeyDelay = 50 * time.Millisecond func passwordKeys(password string) ([]string, error) { keys := make([]string, 0, len(password)) for _, r := range password { - if (r < '0' || r > '9') && (r < 'a' || r > 'z') { + if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'z')) { return nil, fmt.Errorf( "console password contains %q, which can't be typed reliably over the qemu monitor: enter it manually at the console", string(r)) diff --git a/internal/sshx/sharemount.go b/internal/sshx/sharemount.go index 1f17b062..6c783048 100644 --- a/internal/sshx/sharemount.go +++ b/internal/sshx/sharemount.go @@ -105,7 +105,7 @@ func mountShares(ctx context.Context, v *config.VM, log io.Writer) { if len(tags) == 0 { return } - _, _ = fmt.Fprintln(log, "\n=== mounting 9p shares ===") + fmt.Fprintln(log, "\n=== mounting 9p shares ===") cmd := exec.CommandContext(ctx, "ssh", Args(v, sudoWrap(v, []string{"sh", "-s"})...)...) cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGTERM) } cmd.WaitDelay = recipeShutdownGrace @@ -113,6 +113,6 @@ func mountShares(ctx context.Context, v *config.VM, log io.Writer) { cmd.Stdout = log cmd.Stderr = log if err := cmd.Run(); err != nil { - _, _ = fmt.Fprintf(log, "stoat: share mount step failed, continuing: %v\n", err) + fmt.Fprintf(log, "stoat: share mount step failed, continuing: %v\n", err) } } diff --git a/internal/sshx/sshx.go b/internal/sshx/sshx.go index 2751bd93..55d2235f 100644 --- a/internal/sshx/sshx.go +++ b/internal/sshx/sshx.go @@ -233,12 +233,12 @@ func Provision(ctx context.Context, v *config.VM) (err error) { } defer func() { _ = log.Close() }() - _, _ = fmt.Fprintf(log, "waiting for ssh on port %d…\n", v.SSHPort) + fmt.Fprintf(log, "waiting for ssh on port %d…\n", v.SSHPort) if err := Wait(ctx, v, WaitTimeout); err != nil { if ctx.Err() != nil { - _, _ = fmt.Fprintf(log, "CANCELLED: %v\n", err) + fmt.Fprintf(log, "CANCELLED: %v\n", err) } else { - _, _ = fmt.Fprintf(log, "FAILED: %v\n", err) + fmt.Fprintf(log, "FAILED: %v\n", err) } return err } @@ -251,7 +251,7 @@ func Provision(ctx context.Context, v *config.VM) (err error) { // cloud-init failure surfaces when the recipe itself runs. apkovl and BYO // images ship no cloud-init and skip this. if v.Backend == "cloudinit" { - _, _ = fmt.Fprintln(log, "waiting for cloud-init to finish…") + fmt.Fprintln(log, "waiting for cloud-init to finish…") ci := exec.CommandContext(ctx, "ssh", Args(v, sudoWrap(v, []string{"cloud-init", "status", "--wait"})...)...) ci.Cancel = func() error { return ci.Process.Signal(syscall.SIGTERM) } ci.WaitDelay = recipeShutdownGrace @@ -267,24 +267,24 @@ func Provision(ctx context.Context, v *config.VM) (err error) { // cancelled between recipes must stop here rather than start one more // ssh process it will only have to kill. if err := ctx.Err(); err != nil { - _, _ = fmt.Fprintf(log, "CANCELLED: %v\n", err) + fmt.Fprintf(log, "CANCELLED: %v\n", err) return err } body, err := recipes.ScriptBody(name, v.OS) if err != nil { - _, _ = fmt.Fprintf(log, "FAILED: recipe %s: %v\n", name, err) + fmt.Fprintf(log, "FAILED: recipe %s: %v\n", name, err) return err } runtime, err := recipes.RuntimeFor(name, v.OS) if err != nil { - _, _ = fmt.Fprintf(log, "FAILED: recipe %s: %v\n", name, err) + fmt.Fprintf(log, "FAILED: recipe %s: %v\n", name, err) return err } - _, _ = fmt.Fprintf(log, "\n%s\n", RecipeMarker(name)) + fmt.Fprintf(log, "\n%s\n", RecipeMarker(name)) if bootstrap := recipes.BootstrapScript(runtime, v.OS); bootstrap != "" { - _, _ = fmt.Fprintf(log, "ensuring %s is installed...\n", runtime) + fmt.Fprintf(log, "ensuring %s is installed...\n", runtime) bs := exec.CommandContext(ctx, "ssh", Args(v, sudoWrap(v, []string{"sh", "-s"})...)...) bs.Cancel = func() error { return bs.Process.Signal(syscall.SIGTERM) } bs.WaitDelay = recipeShutdownGrace @@ -293,10 +293,10 @@ func Provision(ctx context.Context, v *config.VM) (err error) { bs.Stderr = log if err := bs.Run(); err != nil { if ctxErr := ctx.Err(); ctxErr != nil { - _, _ = fmt.Fprintf(log, "CANCELLED: recipe %s: %v\n", name, ctxErr) + fmt.Fprintf(log, "CANCELLED: recipe %s: %v\n", name, ctxErr) return ctxErr } - _, _ = fmt.Fprintf(log, "FAILED: recipe %s: installing %s: %v\n", name, runtime, err) + fmt.Fprintf(log, "FAILED: recipe %s: installing %s: %v\n", name, runtime, err) return fmt.Errorf("recipe %s: installing %s: %w", name, runtime, err) } } @@ -315,13 +315,13 @@ func Provision(ctx context.Context, v *config.VM) (err error) { // caller sniffing Logs' text, must not read a cancellation as if // the recipe itself had failed. if ctxErr := ctx.Err(); ctxErr != nil { - _, _ = fmt.Fprintf(log, "CANCELLED: recipe %s: %v\n", name, ctxErr) + fmt.Fprintf(log, "CANCELLED: recipe %s: %v\n", name, ctxErr) return ctxErr } - _, _ = fmt.Fprintf(log, "FAILED: recipe %s: %v\n", name, err) + fmt.Fprintf(log, "FAILED: recipe %s: %v\n", name, err) return fmt.Errorf("recipe %s: %w", name, err) } } - _, _ = fmt.Fprintln(log, "\ndone") + fmt.Fprintln(log, "\ndone") return nil } diff --git a/internal/tui/imagemodal.go b/internal/tui/imagemodal.go index b64fd016..8735b34d 100644 --- a/internal/tui/imagemodal.go +++ b/internal/tui/imagemodal.go @@ -138,7 +138,7 @@ func (d imageDelegate) Render(w io.Writer, m list.Model, index int, item list.It if index == m.Index() { label = selStyle.Render(label) } - _, _ = fmt.Fprint(w, cursor+label+dimStyle.Render(trailer)) + fmt.Fprint(w, cursor+label+dimStyle.Render(trailer)) case variantItem: if it.chooseImage { // No size or status column: it isn't a file, it's a door to the @@ -147,7 +147,7 @@ func (d imageDelegate) Render(w io.Writer, m list.Model, index int, item list.It if index == m.Index() { label = selStyle.Render(label) } - _, _ = fmt.Fprint(w, cursor+label+dimStyle.Render("search or paste")) + fmt.Fprint(w, cursor+label+dimStyle.Render("search or paste")) return } // A styled substring ends in \x1b[0m, which also resets the enclosing @@ -162,9 +162,9 @@ func (d imageDelegate) Render(w io.Writer, m list.Model, index int, item list.It // Size right-aligned so the digits line up column-wise; padded plain, // then dimmed as a whole segment. size := dimStyle.Render(fmt.Sprintf("%*s", modalSizeWidth, it.opt.sizeLabel())) - _, _ = fmt.Fprint(w, cursor+label+size+" "+it.opt.statusLabel()) + fmt.Fprint(w, cursor+label+size+" "+it.opt.statusLabel()) case foundItem: - _, _ = fmt.Fprint(w, cursor+foundRow(it.img, m.Width()-lipgloss.Width(cursor), index == m.Index())) + fmt.Fprint(w, cursor+foundRow(it.img, m.Width()-lipgloss.Width(cursor), index == m.Index())) } } diff --git a/internal/tui/snapshots.go b/internal/tui/snapshots.go index fa192da4..750481f9 100644 --- a/internal/tui/snapshots.go +++ b/internal/tui/snapshots.go @@ -356,5 +356,5 @@ func (d snapshotDelegate) Render(w io.Writer, m list.Model, index int, item list state = upStyle.Render("disk+ram") } size := dimStyle.Render(fmt.Sprintf("%-9s", it.snap.Size)) - _, _ = fmt.Fprint(w, cursor+label+size+state+" "+dimStyle.Render(it.snap.Created)) + fmt.Fprint(w, cursor+label+size+state+" "+dimStyle.Render(it.snap.Created)) } diff --git a/internal/tui/vmlist.go b/internal/tui/vmlist.go index 78bb0125..69269956 100644 --- a/internal/tui/vmlist.go +++ b/internal/tui/vmlist.go @@ -92,7 +92,7 @@ func (d vmDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) } else { reason = downStyle.Render(reason) } - _, _ = fmt.Fprint(w, lipgloss.JoinHorizontal(lipgloss.Top, glyph, reason)) + fmt.Fprint(w, lipgloss.JoinHorizontal(lipgloss.Top, glyph, reason)) return } @@ -132,7 +132,7 @@ func (d vmDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) if selected { label = selStyle.Render(label) } - _, _ = fmt.Fprint(w, cursor+dotStyle.Render(dot)+" "+label+state) + fmt.Fprint(w, cursor+dotStyle.Render(dot)+" "+label+state) } // listWidth and listVisibleRows size the VM list. Fixed rather than derived From 156d0ed5f894fbd77a2ca6f699d0c20c197fa393 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 00:50:10 +0300 Subject: [PATCH 9/9] docs: link contributing to the CLI conventions The spec asks CONTRIBUTING to point at core-api.md for code conventions, and to name the tools just lint needs. Signed-off-by: NovusEdge --- CONTRIBUTING.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b30e5f5c..d4d65e73 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,7 +11,7 @@ just dev # runs the TUI against a scratch STOAT_HOME 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 -missing. +missing. `just lint` also needs `golangci-lint` v2 and `shellcheck`. ## Branches and pull requests @@ -77,6 +77,11 @@ A change to a subsystem or an interface another package depends on starts as an issue that states the design, discussed before the implementation PR. Settled decisions go in `docs/design/`. +`docs/design/core-api.md` §12 holds the conventions a new CLI command +follows: the `wire` struct behind `--json`, `fail` against `failMsg`, the +shared `confirm` helper, kong aliases, and `tomlx` for a TOML file. A +reviewer rejects a PR that ignores them. + ## Recipes Bundled recipes are `xfce`, `docker`, `devtools`, `tailscale`, and the set