diff --git a/internal/clients/claudecode/claudecode.go b/internal/clients/claudecode/claudecode.go index bb0381a..a535bde 100644 --- a/internal/clients/claudecode/claudecode.go +++ b/internal/clients/claudecode/claudecode.go @@ -73,6 +73,17 @@ func (c *Client) Install(_ *config.Global) clients.InstallPlan { } } +// Upgrade implements UpgradePlan for Claude Code +func (c *Client) Upgrade() clients.UpgradePlan { + return clients.PlanUpgrade(clients.UpgradeSpec{ + BinaryName: binaryName, + ExtraPaths: commonBinaryPaths(), + NPMPackage: "@anthropic-ai/claude-code", + BrewName: "claude-code", + SelfUpdateArgs: []string{"update"}, + }) +} + // Uninstall implements clients.Client. func (c *Client) Uninstall() clients.UninstallPlan { return clients.UninstallPlan{ diff --git a/internal/clients/codex/codex.go b/internal/clients/codex/codex.go index 569c12d..0b9b91f 100644 --- a/internal/clients/codex/codex.go +++ b/internal/clients/codex/codex.go @@ -55,6 +55,17 @@ func (c *Client) Install(_ *config.Global) clients.InstallPlan { } } +// Upgrade creates an UpgradePlan for Codex +func (c *Client) Upgrade() clients.UpgradePlan { + return clients.PlanUpgrade(clients.UpgradeSpec{ + BinaryName: binaryName, + ExtraPaths: commonBinaryPaths(), + NPMPackage: "@openai/codex", + BrewName: "codex", + NativeUpgradeCommand: "curl -fsSL https://chatgpt.com/codex/install.sh | sh", // no self-update command + }) +} + // Uninstall implements clients.Client. func (c *Client) Uninstall() clients.UninstallPlan { return clients.UninstallPlan{ diff --git a/internal/clients/codex/codex_test.go b/internal/clients/codex/codex_test.go index ef43a4a..5d5b55e 100644 --- a/internal/clients/codex/codex_test.go +++ b/internal/clients/codex/codex_test.go @@ -100,6 +100,16 @@ func TestInstallUninstall(t *testing.T) { if uninstall.Hint != "npm uninstall -g @openai/codex" { t.Errorf("Uninstall.Hint = %q", uninstall.Hint) } + + // The upgrade command depends on how (and whether) codex is installed + // on the host, only assert plan is populated + upgrade := c.Upgrade() + if upgrade.Hint == "" { + t.Error("Upgrade.Hint is empty") + } + if upgrade.Run == nil { + t.Error("Upgrade.Run is nil") + } } func TestReplay_StaleProvider(t *testing.T) { diff --git a/internal/clients/copilot/copilot.go b/internal/clients/copilot/copilot.go index 07f5531..c6378fd 100644 --- a/internal/clients/copilot/copilot.go +++ b/internal/clients/copilot/copilot.go @@ -64,6 +64,17 @@ func (c *Client) Install(_ *config.Global) clients.InstallPlan { } } +// Upgrade creates an UpgradePlan for GitHub Copilot +func (c *Client) Upgrade() clients.UpgradePlan { + return clients.PlanUpgrade(clients.UpgradeSpec{ + BinaryName: binaryName, + ExtraPaths: commonBinaryPaths(), + NPMPackage: "@github/copilot", + BrewName: "copilot-cli", + SelfUpdateArgs: []string{"update"}, + }) +} + // Uninstall implements clients.Client. func (c *Client) Uninstall() clients.UninstallPlan { return clients.UninstallPlan{ diff --git a/internal/clients/gemini/gemini.go b/internal/clients/gemini/gemini.go index a3bd59b..567f4a8 100644 --- a/internal/clients/gemini/gemini.go +++ b/internal/clients/gemini/gemini.go @@ -76,6 +76,16 @@ func (c *Client) Install(_ *config.Global) clients.InstallPlan { } } +// Upgrade creates an UpgradePlan for Gemini +func (c *Client) Upgrade() clients.UpgradePlan { + return clients.PlanUpgrade(clients.UpgradeSpec{ + BinaryName: binaryName, + ExtraPaths: commonBinaryPaths(), + NPMPackage: "@google/gemini-cli", + BrewName: "gemini-cli", + }) +} + // Uninstall implements clients.Client. func (c *Client) Uninstall() clients.UninstallPlan { return clients.UninstallPlan{ diff --git a/internal/clients/opencode/opencode.go b/internal/clients/opencode/opencode.go index 45fd086..62d5b6f 100644 --- a/internal/clients/opencode/opencode.go +++ b/internal/clients/opencode/opencode.go @@ -66,6 +66,17 @@ func (c *Client) Install(_ *config.Global) clients.InstallPlan { } } +// Upgrade creates an UpgradePlan for OpenCode +func (c *Client) Upgrade() clients.UpgradePlan { + return clients.PlanUpgrade(clients.UpgradeSpec{ + BinaryName: binaryName, + ExtraPaths: commonBinaryPaths(), + NPMPackage: "opencode-ai", + BrewName: "opencode", + SelfUpdateArgs: []string{"upgrade"}, + }) +} + // Uninstall implements clients.Client. func (c *Client) Uninstall() clients.UninstallPlan { return clients.UninstallPlan{ diff --git a/internal/clients/registry.go b/internal/clients/registry.go index 893017d..823b504 100644 --- a/internal/clients/registry.go +++ b/internal/clients/registry.go @@ -11,8 +11,8 @@ import ( // Client is one AI coding agent that the launcher can install and launch. // Each client lives in its own sub-package and is wholly responsible for // its own provider/backend/model flow, env generation, config writing, -// install and uninstall — all of which are expressed through the MenuItem -// returned from Menu() plus the InstallPlan / UninstallPlan. +// install, upgrade and uninstall — all of which are expressed through the +// MenuItem returned from Menu() plus the InstallPlan / UpgradePlan / UninstallPlan. type Client interface { // Name is the user-visible display name (e.g. "Claude Code"). Name() string @@ -34,6 +34,9 @@ type Client interface { // host-dependent setup (e.g. writing platform config before download). Install(g *config.Global) InstallPlan + // Upgrade describes how to upgrade the client to its latest version. + Upgrade() UpgradePlan + // Uninstall describes how to uninstall the client. Uninstall() UninstallPlan @@ -62,6 +65,16 @@ type InstallPlan struct { Run func() (*exec.Cmd, error) } +// UpgradePlan describes how to upgrade an installed client. +type UpgradePlan struct { + // Hint is shown to the user before confirming; e.g. + // "npm install -g @openai/codex@latest". + Hint string + // Run returns the command to execute on confirmation. If nil, the + // upgrade is manual-only: the TUI shows Hint and does nothing. + Run func() (*exec.Cmd, error) +} + // UninstallPlan describes how to uninstall a client. type UninstallPlan struct { // Hint is shown to the user before confirming. diff --git a/internal/clients/upgrade.go b/internal/clients/upgrade.go new file mode 100644 index 0000000..95bfdd6 --- /dev/null +++ b/internal/clients/upgrade.go @@ -0,0 +1,154 @@ +package clients + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// InstallMethod identifies which package manager owns a client binary +type InstallMethod int + +const ( + MethodNative InstallMethod = iota // client's own installer + MethodNPM //global npm package + MethodHomebrew //Homebrew formula or cask. +) + +// UpgradeSpec describes the upgrade paths a client supports +type UpgradeSpec struct { + // locate the installed binary + BinaryName string + ExtraPaths []string + NPMPackage string // global npm package name + BrewName string // Homebrew formula/cask name + SelfUpdateArgs []string // invoke the binary's built-in updater + NativeUpgradeCommand string // re-runs the client's official standalone installer +} + +// PlanUpgrade builds an UpgradePlan client whose command matches how the binary was installed +func PlanUpgrade(s UpgradeSpec) UpgradePlan { + bin := FindBinary(s.BinaryName, s.ExtraPaths) + if bin == "" { + return UpgradePlan{ + Hint: s.BinaryName + " is not installed", + Run: func() (*exec.Cmd, error) { + return nil, fmt.Errorf("%s binary not found", s.BinaryName) + }, + } + } + method := DetectInstallMethod(bin, s.NPMPackage) + if method == MethodHomebrew { + // prefer the installed package name + if name := brewPackageName(resolvePath(bin)); name != "" { + s.BrewName = name + } + } + hint := upgradeCommand(s, s.BinaryName, method) + cmd := upgradeCommand(s, bin, method) + if cmd == "" { + return UpgradePlan{Hint: "No known upgrade path for " + bin} + } + cmd += " && " + shellQuote(bin) + " --version" + return UpgradePlan{ + Hint: hint, + Run: func() (*exec.Cmd, error) { + return exec.Command("/bin/sh", "-c", cmd), nil + }, + } +} + +// upgradeCommand returns the upgrade command for the detected install method +func upgradeCommand(s UpgradeSpec, bin string, m InstallMethod) string { + switch m { + case MethodNPM: + if s.NPMPackage != "" { + return "npm install -g " + s.NPMPackage + "@latest" + } + case MethodHomebrew: + if s.BrewName != "" { + return "brew upgrade " + s.BrewName + } + } + if len(s.SelfUpdateArgs) > 0 { + return shellQuote(bin) + " " + strings.Join(s.SelfUpdateArgs, " ") + } + if s.NativeUpgradeCommand != "" { + return s.NativeUpgradeCommand + } + if s.NPMPackage != "" { + return "npm install -g " + s.NPMPackage + "@latest" + } + return "" +} + +// DetectInstallMethod inspects a binary's on-disk location to determine which package manager owns it +func DetectInstallMethod(bin, npmPkg string) InstallMethod { + resolved := resolvePath(bin) + // check npm first; with a Homebrew-installed node, npm's global + // prefix also lives under the Homebrew prefix. + if npmOwns(bin, resolved, npmPkg) { + return MethodNPM + } + if brewOwns(resolved) { + return MethodHomebrew + } + return MethodNative +} + +func resolvePath(bin string) string { + if r, err := filepath.EvalSymlinks(bin); err == nil { + return r + } + return bin +} + +// brewPackageName extracts the formula/cask name from a resolved Cellar or Caskroom path +func brewPackageName(resolved string) string { + segs := strings.Split(filepath.ToSlash(resolved), "/") + for i, seg := range segs { + if (seg == "Cellar" || seg == "Caskroom") && i+1 < len(segs) { + return segs[i+1] + } + } + return "" +} + +func npmOwns(bin, resolved, pkg string) bool { + if strings.Contains(resolved, "node_modules") { + return true + } + if pkg == "" { + return false + } + dir := filepath.Dir(bin) + for _, cand := range []string{ + filepath.Join(dir, "node_modules", pkg), // Windows npm prefix + filepath.Join(dir, "..", "lib", "node_modules", pkg), // Unix npm prefix + } { + if _, err := os.Stat(cand); err == nil { + return true + } + } + return false +} + +func brewOwns(resolved string) bool { + for _, marker := range []string{"/Cellar/", "/Caskroom/", "/homebrew/", "/linuxbrew/"} { + if strings.Contains(resolved, marker) { + return true + } + } + return false +} + +// shellQuote single-quotes s for /bin/sh when it contains characters that +// would be interpreted by the shell. +func shellQuote(s string) string { + if !strings.ContainsAny(s, " \t'\"$`\\&|;<>(){}*?#~") { + return s + } + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} diff --git a/internal/clients/upgrade_test.go b/internal/clients/upgrade_test.go new file mode 100644 index 0000000..31352d0 --- /dev/null +++ b/internal/clients/upgrade_test.go @@ -0,0 +1,179 @@ +package clients + +import ( + "os" + "path/filepath" + "testing" +) + +func touch(t *testing.T, path string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } +} + +func symlink(t *testing.T, target, link string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(link), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } +} + +func TestDetectInstallMethod(t *testing.T) { + t.Run("npm symlink into node_modules", func(t *testing.T) { + dir := t.TempDir() + real := filepath.Join(dir, "lib", "node_modules", "@openai", "codex", "bin", "codex.js") + touch(t, real) + bin := filepath.Join(dir, "bin", "codex") + symlink(t, real, bin) + if got := DetectInstallMethod(bin, "@openai/codex"); got != MethodNPM { + t.Errorf("method = %v, want MethodNPM", got) + } + }) + + t.Run("npm sibling node_modules (Windows prefix layout)", func(t *testing.T) { + dir := t.TempDir() + touch(t, filepath.Join(dir, "node_modules", "@openai", "codex", "package.json")) + bin := filepath.Join(dir, "codex") + touch(t, bin) + if got := DetectInstallMethod(bin, "@openai/codex"); got != MethodNPM { + t.Errorf("method = %v, want MethodNPM", got) + } + }) + + t.Run("npm ../lib/node_modules (Unix prefix layout)", func(t *testing.T) { + dir := t.TempDir() + touch(t, filepath.Join(dir, "lib", "node_modules", "@google", "gemini-cli", "package.json")) + bin := filepath.Join(dir, "bin", "gemini") + touch(t, bin) + if got := DetectInstallMethod(bin, "@google/gemini-cli"); got != MethodNPM { + t.Errorf("method = %v, want MethodNPM", got) + } + }) + + t.Run("homebrew cellar symlink", func(t *testing.T) { + dir := t.TempDir() + real := filepath.Join(dir, "Cellar", "gemini-cli", "1.0.0", "bin", "gemini") + touch(t, real) + bin := filepath.Join(dir, "bin", "gemini") + symlink(t, real, bin) + if got := DetectInstallMethod(bin, "@google/gemini-cli"); got != MethodHomebrew { + t.Errorf("method = %v, want MethodHomebrew", got) + } + }) + + t.Run("npm wins over brew-owned node prefix", func(t *testing.T) { + dir := t.TempDir() + real := filepath.Join(dir, "homebrew", "lib", "node_modules", "@openai", "codex", "bin", "codex.js") + touch(t, real) + bin := filepath.Join(dir, "homebrew", "bin", "codex") + symlink(t, real, bin) + if got := DetectInstallMethod(bin, "@openai/codex"); got != MethodNPM { + t.Errorf("method = %v, want MethodNPM", got) + } + }) + + t.Run("native install", func(t *testing.T) { + dir := t.TempDir() + bin := filepath.Join(dir, ".local", "bin", "claude") + touch(t, bin) + if got := DetectInstallMethod(bin, "@anthropic-ai/claude-code"); got != MethodNative { + t.Errorf("method = %v, want MethodNative", got) + } + }) +} + +func TestUpgradeCommand(t *testing.T) { + spec := UpgradeSpec{ + BinaryName: "claude", + NPMPackage: "@anthropic-ai/claude-code", + BrewName: "claude-code", + SelfUpdateArgs: []string{"update"}, + } + tests := []struct { + name string + spec UpgradeSpec + method InstallMethod + want string + }{ + {"npm", spec, MethodNPM, "npm install -g @anthropic-ai/claude-code@latest"}, + {"homebrew", spec, MethodHomebrew, "brew upgrade claude-code"}, + {"native uses self updater", spec, MethodNative, "claude update"}, + { + "brew detected but no formula falls back to self updater", + UpgradeSpec{BinaryName: "x", SelfUpdateArgs: []string{"upgrade"}}, + MethodHomebrew, + "x upgrade", + }, + { + "native without self updater uses installer command", + UpgradeSpec{BinaryName: "codex", NPMPackage: "@openai/codex", NativeUpgradeCommand: "curl -fsSL https://example.com/install.sh | sh"}, + MethodNative, + "curl -fsSL https://example.com/install.sh | sh", + }, + { + "native without self updater falls back to npm", + UpgradeSpec{BinaryName: "codex", NPMPackage: "@openai/codex"}, + MethodNative, + "npm install -g @openai/codex@latest", + }, + {"no paths at all", UpgradeSpec{BinaryName: "x"}, MethodNative, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := upgradeCommand(tt.spec, tt.spec.BinaryName, tt.method); got != tt.want { + t.Errorf("upgradeCommand = %q, want %q", got, tt.want) + } + }) + } +} + +func TestBrewPackageName(t *testing.T) { + tests := []struct { + resolved string + want string + }{ + {"/opt/homebrew/Cellar/gemini-cli/1.0.0/bin/gemini", "gemini-cli"}, + {"/opt/homebrew/Caskroom/claude-code@latest/2.1.237/claude", "claude-code@latest"}, + {"/opt/homebrew/Caskroom/copilot-cli/1.0.80/copilot-darwin-arm64/copilot", "copilot-cli"}, + {"/home/linuxbrew/.linuxbrew/Cellar/opencode/1.2.3/bin/opencode", "opencode"}, + {"/usr/local/bin/claude", ""}, + } + for _, tt := range tests { + if got := brewPackageName(tt.resolved); got != tt.want { + t.Errorf("brewPackageName(%q) = %q, want %q", tt.resolved, got, tt.want) + } + } +} + +func TestPlanUpgrade_BinaryMissing(t *testing.T) { + plan := PlanUpgrade(UpgradeSpec{ + BinaryName: "definitely-not-a-real-binary-aperture-test", + NPMPackage: "nope", + }) + if plan.Run == nil { + t.Fatal("Run is nil") + } + if _, err := plan.Run(); err == nil { + t.Error("Run() error = nil, want not-found error") + } +} + +func TestShellQuote(t *testing.T) { + if got := shellQuote("/usr/local/bin/claude"); got != "/usr/local/bin/claude" { + t.Errorf("plain path quoted: %q", got) + } + if got := shellQuote("/Users/x y/bin/claude"); got != "'/Users/x y/bin/claude'" { + t.Errorf("spaced path = %q", got) + } + if got := shellQuote("/a/it's/bin"); got != `'/a/it'\''s/bin'` { + t.Errorf("single-quote path = %q", got) + } +} diff --git a/internal/menu/msgs.go b/internal/menu/msgs.go index d547f2f..9ba5989 100644 --- a/internal/menu/msgs.go +++ b/internal/menu/msgs.go @@ -10,6 +10,14 @@ type ExecDoneMsg struct{ Err error } // clients are installed). type InstallDoneMsg struct{ Err error } +// UpgradeDoneMsg is emitted when an agent upgrade finishes +type UpgradeDoneMsg struct { + Client string + Err error + Output string + Detail string +} + // LaunchDoneMsg is emitted when a GUI launch (desktop app) returns control // immediately. Unlike ExecDoneMsg, the TUI does not re-run preflight — // launching a desktop app does not invalidate anything. diff --git a/internal/profiles/adapter.go b/internal/profiles/adapter.go index 9a29054..2d44fc1 100644 --- a/internal/profiles/adapter.go +++ b/internal/profiles/adapter.go @@ -52,6 +52,13 @@ func (c *desktopClient) Install(g *config.Global) clients.InstallPlan { } } +func (c *desktopClient) Upgrade() clients.UpgradePlan { + // The desktop app manages its own updates. + return clients.UpgradePlan{ + Hint: "Claude Cowork updates itself; restart the app to apply pending updates.", + } +} + func (c *desktopClient) Uninstall() clients.UninstallPlan { // Desktop uninstall is user-driven via the OS — no scripted path today. return clients.UninstallPlan{ diff --git a/internal/tui/menus.go b/internal/tui/menus.go index 280d431..1b6e815 100644 --- a/internal/tui/menus.go +++ b/internal/tui/menus.go @@ -1,7 +1,9 @@ package tui import ( + "bytes" "fmt" + "io" "os" "os/exec" "strings" @@ -115,6 +117,10 @@ func (m *model) settingsMenu() *menu.Menu { Label: "Aperture Endpoints", Action: func() menu.Result { return menu.Result{Next: m.endpointsMenu()} }, }, + { + Label: "Upgrade", + Action: func() menu.Result { return menu.Result{Next: m.upgradeMenu()} }, + }, { Label: "Uninstall", Action: func() menu.Result { return menu.Result{Next: m.uninstallMenu()} }, @@ -420,6 +426,93 @@ func (m *model) installConfirmMenu(c clients.Client) *menu.Menu { } } +// upgradeMenu lists installed clients and confirms/runs each upgrade. +func (m *model) upgradeMenu() *menu.Menu { + var items []menu.MenuItem + for _, c := range registeredClients(m.g) { + if !c.IsInstalled() { + continue + } + c := c + items = append(items, menu.MenuItem{ + Label: c.Name(), + Action: func() menu.Result { return menu.Result{Next: m.upgradeConfirmMenu(c)} }, + }) + } + if len(items) == 0 { + return &menu.Menu{ + Title: "Upgrade", + Items: []menu.MenuItem{{Label: "No agents installed.", Disabled: true}}, + Hint: "Esc to go back", + } + } + return &menu.Menu{ + Title: "Upgrade", + Items: items, + Hint: "Enter to select · Esc to go back", + } +} + +func (m *model) upgradeConfirmMenu(c clients.Client) *menu.Menu { + plan := c.Upgrade() + if plan.Run == nil { + return &menu.Menu{ + Title: c.Name(), + Items: []menu.MenuItem{ + {Label: plan.Hint, Disabled: true}, + {Label: "OK", Shortcut: "y", Action: func() menu.Result { return menu.Result{Pop: true} }}, + }, + Hint: "Enter to go back", + } + } + return &menu.Menu{ + Title: "Upgrade " + c.Name() + "?", + Items: []menu.MenuItem{ + {Label: "This will run: " + plan.Hint, Disabled: true}, + { + Label: "Upgrade", + Shortcut: "y", + Action: func() menu.Result { + return menu.Result{Cmd: runUpgradeCmd(c.Name(), plan.Run)} + }, + }, + { + Label: "Cancel", + Shortcut: "n", + Action: func() menu.Result { return menu.Result{Pop: true} }, + }, + }, + Hint: "y to upgrade · n to cancel", + } +} + +// upgradeResultMenu reports whether an upgrade completed or failed, and why. +func (m *model) upgradeResultMenu(msg menu.UpgradeDoneMsg) *menu.Menu { + ok := menu.MenuItem{Label: "OK", Action: func() menu.Result { return menu.Result{Pop: true} }} + if msg.Err == nil { + pre := "" + if msg.Output != "" { + pre = "Now at: " + msg.Output + } + return &menu.Menu{ + Title: msg.Client + " upgrade complete", + Preamble: pre, + Items: []menu.MenuItem{ok}, + Hint: "Enter to continue", + } + } + pre := "Reason: " + msg.Err.Error() + if msg.Detail != "" { + pre += "\n\n" + msg.Detail + } + return &menu.Menu{ + Title: msg.Client + " upgrade failed", + Preamble: pre, + Items: []menu.MenuItem{ok}, + Hint: "Enter to continue", + } +} + // uninstallMenu lists installed clients and confirms/runs uninstall. func (m *model) uninstallMenu() *menu.Menu { var items []menu.MenuItem @@ -499,6 +592,45 @@ func runInstallCmd(producer func() (*exec.Cmd, error)) tea.Cmd { }) } +// runUpgradeCmd runs an upgrade with terminal takeover, teeing output so the +// outcome screen can report the new version or why the upgrade failed, and +// emits menu.UpgradeDoneMsg. +func runUpgradeCmd(client string, producer func() (*exec.Cmd, error)) tea.Cmd { + cmd, err := producer() + if err != nil { + return func() tea.Msg { return menu.UpgradeDoneMsg{Client: client, Err: err} } + } + if cmd == nil { + return func() tea.Msg { return menu.UpgradeDoneMsg{Client: client} } + } + var stdout, stderr bytes.Buffer + cmd.Stdin = os.Stdin + cmd.Stdout = io.MultiWriter(os.Stdout, &stdout) + cmd.Stderr = io.MultiWriter(os.Stderr, &stderr) + return tea.ExecProcess(cmd, func(err error) tea.Msg { + return menu.UpgradeDoneMsg{ + Client: client, + Err: err, + Output: tailLines(stdout.String(), 1), + Detail: tailLines(stderr.String(), 6), + } + }) +} + +// tailLines returns the last n non-blank lines of s. +func tailLines(s string, n int) string { + var out []string + for _, l := range strings.Split(s, "\n") { + if strings.TrimSpace(l) != "" { + out = append(out, strings.TrimRight(l, " \t\r")) + } + } + if len(out) > n { + out = out[len(out)-n:] + } + return strings.Join(out, "\n") +} + // runUninstallFn returns a tea.Cmd that invokes the uninstall function and // emits menu.InstallDoneMsg (we reuse the install-done flow to re-scan the // client list on completion). diff --git a/internal/tui/tui.go b/internal/tui/tui.go index bf6e0c9..297c13f 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -309,6 +309,14 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.resetStack(m.rootMenu()) return m, tea.ClearScreen + case menu.UpgradeDoneMsg: + // Rebuild root (the binary may have changed), then show the outcome. + m.step = stepMenu + m.resetStack(m.rootMenu()) + m.stack = append(m.stack, m.upgradeResultMenu(msg)) + m.cursors = append(m.cursors, 0) + return m, tea.ClearScreen + case menu.LaunchDoneMsg: // Desktop-style launch returned immediately; stay on root menu. m.popToRoot() diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 62efca9..7687a69 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -27,6 +27,9 @@ func (c *fakeClient) IsInstalled() bool { return c.installed } func (c *fakeClient) Install(*config.Global) clients.InstallPlan { return clients.InstallPlan{Hint: "install " + c.name} } +func (c *fakeClient) Upgrade() clients.UpgradePlan { + return clients.UpgradePlan{Hint: "upgrade " + c.name} +} func (c *fakeClient) Uninstall() clients.UninstallPlan { return clients.UninstallPlan{Hint: "uninstall " + c.name} } @@ -125,6 +128,74 @@ func TestRootMenu_NoQuickSelectWhenReplayNil(t *testing.T) { } } +func TestUpgradeMenu_ListsOnlyInstalled(t *testing.T) { + withFakeClients(t, []clients.Client{ + &fakeClient{name: "A", installed: true}, + &fakeClient{name: "B", installed: false}, + }) + + m := &model{g: &config.Global{}} + um := m.upgradeMenu() + if len(um.Items) != 1 { + t.Fatalf("upgrade items = %d, want 1", len(um.Items)) + } + if um.Items[0].Label != "A" { + t.Errorf("upgrade item = %q, want A", um.Items[0].Label) + } +} + +func TestUpgradeConfirmMenu_HintOnlyWhenRunNil(t *testing.T) { + fc := &fakeClient{name: "A", installed: true} + withFakeClients(t, []clients.Client{fc}) + + m := &model{g: &config.Global{}} + cm := m.upgradeConfirmMenu(fc) + // fakeClient's UpgradePlan has no Run, so the menu is hint + OK only. + if len(cm.Items) != 2 { + t.Fatalf("confirm items = %d, want 2", len(cm.Items)) + } + if cm.Items[0].Label != "upgrade A" { + t.Errorf("hint = %q, want %q", cm.Items[0].Label, "upgrade A") + } +} + +func TestUpgradeResultMenu(t *testing.T) { + m := &model{g: &config.Global{}} + + ok := m.upgradeResultMenu(menu.UpgradeDoneMsg{Client: "Claude Code", Output: "2.2.0 (Claude Code)"}) + if ok.Title != "Claude Code upgrade complete" { + t.Errorf("success title = %q", ok.Title) + } + if !strings.Contains(ok.Preamble, "2.2.0") { + t.Errorf("success preamble missing version: %q", ok.Preamble) + } + + fail := m.upgradeResultMenu(menu.UpgradeDoneMsg{ + Client: "Claude Code", + Err: fmt.Errorf("exit status 1"), + Detail: "npm ERR! EACCES permission denied", + }) + if fail.Title != "Claude Code upgrade failed" { + t.Errorf("failure title = %q", fail.Title) + } + if !strings.Contains(fail.Preamble, "exit status 1") || !strings.Contains(fail.Preamble, "EACCES") { + t.Errorf("failure preamble missing reason/detail: %q", fail.Preamble) + } +} + +func TestTailLines(t *testing.T) { + in := "one\n\ntwo \nthree\n" + if got := tailLines(in, 1); got != "three" { + t.Errorf("tailLines(1) = %q", got) + } + if got := tailLines(in, 2); got != "two\nthree" { + t.Errorf("tailLines(2) = %q", got) + } + if got := tailLines("", 3); got != "" { + t.Errorf("tailLines(empty) = %q", got) + } +} + func TestMenuEngine_PushPop(t *testing.T) { sub := &menu.Menu{ Title: "Sub",