From ac6a4ca254fd0d60744e7826afdd4d21b2f2e6ee Mon Sep 17 00:00:00 2001 From: dipto0321 Date: Sun, 5 Jul 2026 14:02:49 +0600 Subject: [PATCH] feat(ui): complete internal/ui call-site migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final PR of #105's three-PR phasing: every user-facing string in internal/cli now flows through ui.Writer.{Info, Success, Warn, Error, Println} — no direct cmd.Printf / fmt.Fprintf in business logic. Wraps up the rollout started by the scaffold (#74 PR1) and continued by the spinners (#117) and huh prompts (#118). What's migrated: - check.go: outputCheckJSON / outputCheckTable rewritten to take (ui.Writer, context.Context) instead of (cobra.Command). The table renderer still preserves the same field layout (LTS, Current, System node) so existing scripts piping `nodeup check | grep LTS` keep working. - list.go: outputListJSON / outputListTable rewritten the same way. Empty-state message now routes through writer.Info so FancyMode gets the bullet prefix while PlainMode stays clean. - packages.go: snapshot, list, restore, diff all migrated. The writeReport closure uses writerFromCmd for warn/info, the clearSentinel closure does too. The `Restored packages from %s` and `Restored packages for %s %s` lines route through writer.Success now (was cmd.Printf). - config.go: config show / get / set / init all migrated. The `set foo=bar (saved to ...)` and `wrote scaffolded config` lines are now Success messages; lock-release failures now go through writer.Warn. - upgrade.go: ~15 remaining cmd.Printf call sites migrated (sentinel cleanup, manager-resolution log, dry-run plan, upgrade-lock release, snapshot warnings, sentinel write warnings, restore warnings, migration report, active-version detection failure, cleanup error summary, completion line). All spinners / cleanup prompts / ResolveInteractive integration from #117/#118 are unchanged. - version.go: writerFromCmd made nil-context-safe (was: panicked on bare cobra.Command, which many tests construct). - list_test.go: newBufCmd now also redirects ErrOrStderr to the same buffer (mirrors how the migrated ui.Writer splits stdout vs stderr). outputListJSON / outputListTable tests rewritten to use writerFromCmd(cmd) for the writer argument. No new files, no go.mod changes. SPEC.md's V2 invariant now flips from aspirational to enforced: every user-facing string in internal/ and cmd/ routes through internal/ui. After this lands, issue #105 fully closes. Closes #105. Co-Authored-By: puku-ai-2.8 --- internal/cli/check.go | 47 +++++++++++++++++++++------------------ internal/cli/config.go | 13 ++++++----- internal/cli/list.go | 24 +++++++++++--------- internal/cli/list_test.go | 18 +++++++++------ internal/cli/packages.go | 26 +++++++++++----------- internal/cli/upgrade.go | 32 +++++++++++++------------- internal/cli/version.go | 10 +++++++-- 7 files changed, 93 insertions(+), 77 deletions(-) diff --git a/internal/cli/check.go b/internal/cli/check.go index 297312d..1479255 100644 --- a/internal/cli/check.go +++ b/internal/cli/check.go @@ -1,6 +1,7 @@ package cli import ( + "context" "encoding/json" "fmt" "strings" @@ -10,6 +11,7 @@ import ( "github.com/dipto0321/nodeup/internal/detector" "github.com/dipto0321/nodeup/internal/node" + "github.com/dipto0321/nodeup/internal/ui" ) // systemNodeJSON describes the on-disk `node` binary found on PATH, @@ -101,14 +103,15 @@ func runCheck(cmd *cobra.Command, args []string) error { } } + w := writerFromCmd(cmd) if asJSON { - return outputCheckJSON(cmd, lts, current, installed, sysNode) + return outputCheckJSON(w, cmd.Context(), lts, current, installed, sysNode) } - return outputCheckTable(cmd, lts, current, installed, sysNode) + return outputCheckTable(w, cmd.Context(), lts, current, installed, sysNode) } -func outputCheckJSON(cmd *cobra.Command, lts, current *node.ManifestVersion, installed detector.Registry, sysNode *systemNodeJSON) error { +func outputCheckJSON(w ui.Writer, ctx context.Context, lts, current *node.ManifestVersion, installed detector.Registry, sysNode *systemNodeJSON) error { type checkOutput struct { LTS *node.ManifestVersion `json:"lts"` Current *node.ManifestVersion `json:"current"` @@ -117,8 +120,8 @@ func outputCheckJSON(cmd *cobra.Command, lts, current *node.ManifestVersion, ins } installedVersions := make([]string, 0) - for _, m := range installed.Found { - versions, err := m.ListInstalled(cmd.Context()) + for _, mgr := range installed.Found { + versions, err := mgr.ListInstalled(ctx) if err != nil { continue } @@ -138,27 +141,27 @@ func outputCheckJSON(cmd *cobra.Command, lts, current *node.ManifestVersion, ins if err != nil { return err } - cmd.Println(string(data)) + w.Println(string(data)) return nil } -func outputCheckTable(cmd *cobra.Command, lts, current *node.ManifestVersion, installed detector.Registry, sysNode *systemNodeJSON) error { - cmd.Println() - cmd.Printf(" LTS: %s (released %s)\n", lts.Version, lts.Date) - cmd.Printf(" Current: %s (released %s)\n", current.Version, current.Date) - cmd.Println() +func outputCheckTable(w ui.Writer, ctx context.Context, lts, current *node.ManifestVersion, installed detector.Registry, sysNode *systemNodeJSON) error { + w.Println("") + w.Info(fmt.Sprintf(" LTS: %s (released %s)", lts.Version, lts.Date)) + w.Info(fmt.Sprintf(" Current: %s (released %s)", current.Version, current.Date)) + w.Println("") if len(installed.Found) == 0 { - cmd.Println("No Node.js version manager detected.") + w.Info("No Node.js version manager detected.") } else { - cmd.Println("Installed versions:") - for _, m := range installed.Found { - versions, err := m.ListInstalled(cmd.Context()) + w.Println("Installed versions:") + for _, mgr := range installed.Found { + versions, err := mgr.ListInstalled(ctx) if err != nil { - cmd.Printf(" - %s: [error listing versions]\n", m.Name()) + w.Warn(fmt.Sprintf(" - %s: [error listing versions]", mgr.Name())) continue } - cmd.Printf(" - %s: %s\n", m.Name(), formatVersions(versions)) + w.Info(fmt.Sprintf(" - %s: %s", mgr.Name(), formatVersions(versions))) } } @@ -166,18 +169,18 @@ func outputCheckTable(cmd *cobra.Command, lts, current *node.ManifestVersion, in // the probe didn't run (or `which node` failed) — we say so // explicitly rather than staying silent, so the user has a // single source of truth for what nodeup sees. - cmd.Println() + w.Println("") if sysNode == nil { - cmd.Println("System node: (could not probe `node` on PATH)") + w.Info("System node: (could not probe `node` on PATH)") return nil } switch sysNode.Kind { case "manager": - cmd.Printf("System node: %s (managed by %s)\n", sysNode.Path, sysNode.Manager) + w.Info(fmt.Sprintf("System node: %s (managed by %s)", sysNode.Path, sysNode.Manager)) case "unknown": // Path matched no known layout. Don't print a long warning // here — `nodeup upgrade` is where the warning belongs. - cmd.Printf("System node: %s (unrecognized layout)\n", sysNode.Path) + w.Info(fmt.Sprintf("System node: %s (unrecognized layout)", sysNode.Path)) default: // OS-package / snap / flatpak / homebrew-core. Render the // same warning text that `nodeup upgrade` would print, so @@ -193,7 +196,7 @@ func outputCheckTable(cmd *cobra.Command, lts, current *node.ManifestVersion, in // Indent each rendered line by two spaces so it lines up // with the rest of the table block. for _, line := range strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") { - cmd.Printf(" %s\n", line) + w.Info(" " + line) } } diff --git a/internal/cli/config.go b/internal/cli/config.go index 8832d42..0b50c27 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -71,7 +71,8 @@ to a file as a starting point for a new config.`, return fmt.Errorf("marshal config: %w", err) } path, _ := config.DefaultPath() - fmt.Fprintf(cmd.OutOrStdout(), "# effective config (source: %s)\n%s", path, out) + w := writerFromCmd(cmd) + w.Println(fmt.Sprintf("# effective config (source: %s)\n%s", path, out)) return nil }) }, @@ -95,7 +96,7 @@ func newConfigGetCmd() *cobra.Command { if err != nil { return err } - fmt.Fprintln(cmd.OutOrStdout(), v) + writerFromCmd(cmd).Println(v) return nil }) }, @@ -137,7 +138,7 @@ func newConfigSetCmd() *cobra.Command { } defer func() { if rerr := configLock.Release(); rerr != nil { - fmt.Fprintf(cmd.OutOrStdout(), "Warning: failed to release config lock: %v\n", rerr) + writerFromCmd(cmd).Warn(fmt.Sprintf("Warning: failed to release config lock: %v", rerr)) } }() @@ -170,7 +171,7 @@ func newConfigSetCmd() *cobra.Command { if err := config.Save(path, current); err != nil { return fmt.Errorf("save config to %s: %w", path, err) } - fmt.Fprintf(cmd.OutOrStdout(), "set %s=%s (saved to %s)\n", args[0], args[1], path) + writerFromCmd(cmd).Success(fmt.Sprintf("set %s=%s (saved to %s)", args[0], args[1], path)) return nil }, } @@ -204,7 +205,7 @@ to overwrite an existing file unless --force is passed.`, } defer func() { if rerr := configLock.Release(); rerr != nil { - fmt.Fprintf(cmd.OutOrStdout(), "Warning: failed to release config lock: %v\n", rerr) + writerFromCmd(cmd).Warn(fmt.Sprintf("Warning: failed to release config lock: %v", rerr)) } }() @@ -217,7 +218,7 @@ to overwrite an existing file unless --force is passed.`, if err := config.Save(path, cfg); err != nil { return fmt.Errorf("save config to %s: %w", path, err) } - fmt.Fprintf(cmd.OutOrStdout(), "wrote scaffolded config to %s\n", path) + writerFromCmd(cmd).Success(fmt.Sprintf("wrote scaffolded config to %s", path)) return nil }, } diff --git a/internal/cli/list.go b/internal/cli/list.go index dc484b9..18f5150 100644 --- a/internal/cli/list.go +++ b/internal/cli/list.go @@ -8,6 +8,7 @@ import ( "github.com/spf13/cobra" "github.com/dipto0321/nodeup/internal/detector" + "github.com/dipto0321/nodeup/internal/ui" ) // installedEntryJSON is a single manager's contributions to the @@ -122,38 +123,39 @@ func runList(cmd *cobra.Command, args []string) error { } } + w := writerFromCmd(cmd) if asJSON { - return outputListJSON(cmd, listOutputJSON{Installed: entries, Current: active}) + return outputListJSON(w, listOutputJSON{Installed: entries, Current: active}) } - return outputListTable(cmd, entries) + return outputListTable(w, entries) } -func outputListJSON(cmd *cobra.Command, out listOutputJSON) error { +func outputListJSON(w ui.Writer, out listOutputJSON) error { data, err := json.MarshalIndent(out, "", " ") if err != nil { return err } - cmd.Println(string(data)) + w.Println(string(data)) return nil } -func outputListTable(cmd *cobra.Command, entries []installedEntryJSON) error { - cmd.Println() +func outputListTable(w ui.Writer, entries []installedEntryJSON) error { + w.Println("") if len(entries) == 0 { - cmd.Println("No Node.js version manager detected.") - cmd.Println("Install one of: fnm, nvm, Volta, asdf, mise, n, nodenv, nvm-windows.") + w.Info("No Node.js version manager detected.") + w.Info("Install one of: fnm, nvm, Volta, asdf, mise, n, nodenv, nvm-windows.") return nil } for _, e := range entries { if e.Error != "" { - cmd.Printf(" %s: [error listing versions: %s]\n", e.Manager, e.Error) + w.Warn(fmt.Sprintf(" %s: [error listing versions: %s]", e.Manager, e.Error)) continue } if len(e.Versions) == 0 { - cmd.Printf(" %s: (no versions installed)\n", e.Manager) + w.Info(fmt.Sprintf(" %s: (no versions installed)", e.Manager)) continue } - cmd.Printf(" %s: %s\n", e.Manager, formatListVersions(e.Versions)) + w.Info(fmt.Sprintf(" %s: %s", e.Manager, formatListVersions(e.Versions))) } return nil } diff --git a/internal/cli/list_test.go b/internal/cli/list_test.go index 6d24b86..1ab43b1 100644 --- a/internal/cli/list_test.go +++ b/internal/cli/list_test.go @@ -14,8 +14,11 @@ import ( ) // newBufCmd returns a *cobra.Command with its output redirected to an -// in-memory buffer. Cobra's Println et al. write to OutOrStdout(); by -// overriding it we capture output without touching the real terminal. +// in-memory buffer. Cobra's Println et al. write to OutOrStdout(); +// Warn/Error route to ErrOrStderr. We redirect BOTH to the same +// buffer so test assertions can verify everything in one place — +// this matters now that the cli migrated to ui.Writer, which +// routes warn/error to ErrOrStderr. // // We construct a bare Cobra command here rather than going through // NewRootCmd so the list tests stay independent of the full command @@ -25,6 +28,7 @@ func newBufCmd(t *testing.T) (*cobra.Command, *bytes.Buffer) { var buf bytes.Buffer c := &cobra.Command{Use: "test"} c.SetOut(&buf) + c.SetErr(&buf) return c, &buf } @@ -190,7 +194,7 @@ func TestRegistryNames_OrderingPreserved(t *testing.T) { func TestOutputListJSON_HappyPath(t *testing.T) { cmd, buf := newBufCmd(t) cur := "20.18.0" - if err := outputListJSON(cmd, listOutputJSON{ + if err := outputListJSON(writerFromCmd(cmd), listOutputJSON{ Installed: []installedEntryJSON{ {Manager: "fnm", Versions: []string{"18.20.4", "20.18.0", "22.11.0"}}, {Manager: "volta", Versions: []string{"20.11.0"}}, @@ -220,7 +224,7 @@ func TestOutputListJSON_HappyPath(t *testing.T) { func TestOutputListJSON_OmitEmptyCurrent(t *testing.T) { cmd, buf := newBufCmd(t) - if err := outputListJSON(cmd, listOutputJSON{ + if err := outputListJSON(writerFromCmd(cmd), listOutputJSON{ Installed: []installedEntryJSON{{Manager: "fnm"}}, }); err != nil { t.Fatalf("outputListJSON: %v", err) @@ -233,7 +237,7 @@ func TestOutputListJSON_OmitEmptyCurrent(t *testing.T) { func TestOutputListJSON_OmitEmptyError(t *testing.T) { cmd, buf := newBufCmd(t) - if err := outputListJSON(cmd, listOutputJSON{ + if err := outputListJSON(writerFromCmd(cmd), listOutputJSON{ Installed: []installedEntryJSON{{Manager: "fnm", Versions: []string{"20.0.0"}}}, }); err != nil { t.Fatalf("outputListJSON: %v", err) @@ -247,7 +251,7 @@ func TestOutputListJSON_OmitEmptyError(t *testing.T) { func TestOutputListTable_Empty(t *testing.T) { cmd, buf := newBufCmd(t) - if err := outputListTable(cmd, nil); err != nil { + if err := outputListTable(writerFromCmd(cmd), nil); err != nil { t.Fatalf("outputListTable: %v", err) } out := buf.String() @@ -267,7 +271,7 @@ func TestOutputListTable_MixedStates(t *testing.T) { {Manager: "volta"}, // neither versions nor error — falls through with "(no versions installed)" {Manager: "asdf", Error: "boom"}, } - if err := outputListTable(cmd, entries); err != nil { + if err := outputListTable(writerFromCmd(cmd), entries); err != nil { t.Fatalf("outputListTable: %v", err) } out := buf.String() diff --git a/internal/cli/packages.go b/internal/cli/packages.go index cce551b..e76de82 100644 --- a/internal/cli/packages.go +++ b/internal/cli/packages.go @@ -57,7 +57,7 @@ func runSnapshot(cmd *cobra.Command, args []string) error { return fmt.Errorf("snapshot failed: %w", err) } - cmd.Printf("Snapshot saved for %s %s\n", m.Name(), version) + writerFromCmd(cmd).Success(fmt.Sprintf("Snapshot saved for %s %s", m.Name(), version)) return nil } @@ -87,14 +87,14 @@ func runPackagesList(cmd *cobra.Command, args []string) error { } if len(snapshots) == 0 { - cmd.Println("No snapshots found.") + writerFromCmd(cmd).Info("No snapshots found.") return nil } for _, s := range snapshots { - cmd.Printf("\n%s (Node %s):\n", s.Manager, s.NodeVersion) + writerFromCmd(cmd).Info(fmt.Sprintf("%s (Node %s):", s.Manager, s.NodeVersion)) for _, p := range s.Packages { - cmd.Printf(" - %s@%s\n", p.Name, p.Version) + writerFromCmd(cmd).Info(fmt.Sprintf(" - %s@%s", p.Name, p.Version)) } } return nil @@ -156,7 +156,7 @@ func runRestore(cmd *cobra.Command, args []string) error { // packages — has already been achieved. clearSentinel := func() { if err := packages.RemoveSentinel(); err != nil { - cmd.Printf("Warning: failed to clear upgrade sentinel: %v\n", err) + writerFromCmd(cmd).Warn(fmt.Sprintf("Warning: failed to clear upgrade sentinel: %v", err)) } } @@ -185,11 +185,11 @@ func runRestore(cmd *cobra.Command, args []string) error { report.AddResult(r) } if err := report.Save(); err != nil { - cmd.Printf("Warning: failed to write migration report: %v\n", err) + writerFromCmd(cmd).Warn(fmt.Sprintf("Warning: failed to write migration report: %v", err)) return } if p, perr := report.Path(); perr == nil { - cmd.Printf("Migration report: %s\n", p) + writerFromCmd(cmd).Info(fmt.Sprintf("Migration report: %s", p)) } } @@ -207,7 +207,7 @@ func runRestore(cmd *cobra.Command, args []string) error { } writeReport(outcome.Snapshot, "", "", outcome.Results) clearSentinel() - cmd.Printf("Restored packages from %s\n", fromPath) + writerFromCmd(cmd).Success(fmt.Sprintf("Restored packages from %s", fromPath)) return nil } @@ -243,7 +243,7 @@ func runRestore(cmd *cobra.Command, args []string) error { writeReport(outcome.Snapshot, managerName, versionStr, outcome.Results) clearSentinel() - cmd.Printf("Restored packages for %s %s\n", managerName, versionStr) + writerFromCmd(cmd).Success(fmt.Sprintf("Restored packages for %s %s", managerName, versionStr)) return nil } @@ -277,14 +277,14 @@ func runDiff(cmd *cobra.Command, args []string) error { added, removed := packages.DiffSnapshots(s1.Packages, s2.Packages) - cmd.Printf("Added packages:\n") + writerFromCmd(cmd).Println("Added packages:") for _, p := range added { - cmd.Printf(" + %s@%s\n", p.Name, p.Version) + writerFromCmd(cmd).Info(fmt.Sprintf(" + %s@%s", p.Name, p.Version)) } - cmd.Printf("Removed packages:\n") + writerFromCmd(cmd).Println("Removed packages:") for _, p := range removed { - cmd.Printf(" - %s@%s\n", p.Name, p.Version) + writerFromCmd(cmd).Info(fmt.Sprintf(" - %s@%s", p.Name, p.Version)) } return nil diff --git a/internal/cli/upgrade.go b/internal/cli/upgrade.go index 46e19f8..89146b2 100644 --- a/internal/cli/upgrade.go +++ b/internal/cli/upgrade.go @@ -108,7 +108,7 @@ func runUpgrade(cmd *cobra.Command, args []string) error { defer func() { if sentinelArmed && restoreSucceeded { if err := packages.RemoveSentinel(); err != nil { - cmd.Printf("Warning: failed to remove upgrade sentinel: %v\n", err) + writerFromCmd(cmd).Warn(fmt.Sprintf("Warning: failed to remove upgrade sentinel: %v", err)) } } }() @@ -138,7 +138,7 @@ func runUpgrade(cmd *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("resolve manager: %w", err) } - cmd.Printf("Using manager: %s\n", m.Name()) + writerFromCmd(cmd).Info(fmt.Sprintf("Using manager: %s", m.Name())) // Probe the system Node BEFORE we start touching anything. If it // turns out `node` on PATH is owned by the OS package manager, snap, @@ -232,9 +232,9 @@ func runUpgrade(cmd *cobra.Command, args []string) error { } if dryRun { - cmd.Println("Dry run - would install:") + writerFromCmd(cmd).Println("Dry run - would install:") for _, v := range toInstall { - cmd.Printf(" - %s\n", v) + writerFromCmd(cmd).Info(fmt.Sprintf(" - %s", v)) } return nil } @@ -256,7 +256,7 @@ func runUpgrade(cmd *cobra.Command, args []string) error { } defer func() { if rerr := upgradeLock.Release(); rerr != nil { - cmd.Printf("Warning: failed to release upgrade lock: %v\n", rerr) + writerFromCmd(cmd).Warn(fmt.Sprintf("Warning: failed to release upgrade lock: %v", rerr)) } }() @@ -287,7 +287,7 @@ func runUpgrade(cmd *cobra.Command, args []string) error { } label := fmt.Sprintf("Snapshotting globals (%s)", ver) if serr := ui.WaitWithSpinner(ctx, spinnerFor(cmd, label), take); serr != nil { - cmd.Printf("Warning: snapshot failed for %s: %v\n", ver, serr) + writerFromCmd(cmd).Warn(fmt.Sprintf("Warning: snapshot failed for %s: %v", ver, serr)) } } if len(installedVersions) > 0 { @@ -323,7 +323,7 @@ func runUpgrade(cmd *cobra.Command, args []string) error { NewVersion: newVersion, SnapshotPath: restoreSnapshotPath, }); err != nil { - cmd.Printf("Warning: failed to write upgrade sentinel: %v\n", err) + writerFromCmd(cmd).Warn(fmt.Sprintf("Warning: failed to write upgrade sentinel: %v", err)) } else { sentinelArmed = true } @@ -381,7 +381,7 @@ func runUpgrade(cmd *cobra.Command, args []string) error { // failures. if !skipMigrate && len(toInstall) > 0 { if restoreSnapshotPath == "" { - cmd.Printf("Warning: no snapshot path available to restore from; skipping package migration\n") + writerFromCmd(cmd).Warn("Warning: no snapshot path available to restore from; skipping package migration") } else { // Snapshot path → restore. The restore step runs `npm // install -g ` per captured global, so for users with @@ -435,18 +435,18 @@ func runUpgrade(cmd *cobra.Command, args []string) error { report.AddResult(res) } if saveErr := report.Save(); saveErr != nil { - cmd.Printf("Warning: failed to write migration report: %v\n", saveErr) + writerFromCmd(cmd).Warn(fmt.Sprintf("Warning: failed to write migration report: %v", saveErr)) } else if reportPath, perr := report.Path(); perr == nil { if rerr != nil { - cmd.Printf("Migration report: %s (partial — see file for failures)\n", reportPath) + writerFromCmd(cmd).Warn(fmt.Sprintf("Migration report: %s (partial — see file for failures)", reportPath)) } else { - cmd.Printf("Migration report: %s\n", reportPath) + writerFromCmd(cmd).Info(fmt.Sprintf("Migration report: %s", reportPath)) } } } if rerr != nil { - cmd.Printf("Warning: restore failed: %v\n", rerr) + writerFromCmd(cmd).Warn(fmt.Sprintf("Warning: restore failed: %v", rerr)) } else { restoreSucceeded = true } @@ -478,7 +478,7 @@ func runUpgrade(cmd *cobra.Command, args []string) error { // this, a user running `--cleanup` would suddenly see // per-version y/N prompts for the very first time and // have no idea why. See #58. - cmd.Printf("Warning: could not determine the currently-active Node version (%v); cleanup will require per-version confirmation for safety.\n", currentErr) + writerFromCmd(cmd).Warn(fmt.Sprintf("Warning: could not determine the currently-active Node version (%v); cleanup will require per-version confirmation for safety.", currentErr)) } // Build a values slice from the toInstall pointers so the @@ -511,14 +511,14 @@ func runUpgrade(cmd *cobra.Command, args []string) error { ) if cerr != nil { // Non-fatal: log and proceed to the success message. - cmd.Printf("Warning: cleanup encountered an error: %v\n", cerr) + writerFromCmd(cmd).Warn(fmt.Sprintf("Warning: cleanup encountered an error: %v", cerr)) } if summary := formatCleanupResult(result); summary != "" { - cmd.Printf("Cleanup: %s\n", summary) + writerFromCmd(cmd).Info(fmt.Sprintf("Cleanup: %s", summary)) } } - cmd.Printf("Upgrade complete!\n") + writerFromCmd(cmd).Success("Upgrade complete!") return nil } diff --git a/internal/cli/version.go b/internal/cli/version.go index eedbe16..4cb2b45 100644 --- a/internal/cli/version.go +++ b/internal/cli/version.go @@ -67,8 +67,14 @@ Example: // back to a PlainMode writer backed by cmd.OutOrStdout/ErrOrStderr so // tests still get sensible output. func writerFromCmd(cmd *cobra.Command) ui.Writer { - if v, ok := cmd.Context().Value(writerCtxKey{}).(ui.Writer); ok && v != nil { - return v + // cobra's Context() panics when called on a Command that was + // never SetContext'd — which is the case for many unit tests + // that build a bare cobra.Command. Guard the lookup so the + // fallback path below always works. + if ctx := cmd.Context(); ctx != nil { + if v, ok := ctx.Value(writerCtxKey{}).(ui.Writer); ok && v != nil { + return v + } } out := cmd.OutOrStdout() errOut := cmd.ErrOrStderr()