claudeup 6.5.1 → 6.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/active-closure.test.ts +184 -0
  3. package/src/__tests__/blackbox-active-closure.test.ts +119 -0
  4. package/src/__tests__/blackbox-convergence.test.ts +334 -0
  5. package/src/__tests__/blackbox-parity.test.ts +684 -0
  6. package/src/__tests__/blackbox-plugin-state.test.ts +523 -0
  7. package/src/__tests__/blackbox-tool-state.test.ts +669 -0
  8. package/src/__tests__/cli-apply-seams.test.ts +2 -0
  9. package/src/__tests__/cli-tool-commands.test.ts +1 -1
  10. package/src/__tests__/cli-tools-adapter.test.ts +294 -0
  11. package/src/__tests__/cli-update-view.test.ts +142 -2
  12. package/src/__tests__/content-drift.test.ts +60 -0
  13. package/src/__tests__/enabled-not-installed.test.ts +1 -2
  14. package/src/__tests__/fixtures/tool-probe/brew-list-versions-tmux.txt +2 -0
  15. package/src/__tests__/fixtures/tool-probe/brew-outdated-behind-homebrew-spec.txt +29 -0
  16. package/src/__tests__/fixtures/tool-probe/brew-outdated-tmux-current.txt +7 -0
  17. package/src/__tests__/fixtures/tool-probe/brew-outdated-unknown-formula.txt +6 -0
  18. package/src/__tests__/fixtures/tool-probe/go-version-m-tmux-mcp.txt +27 -0
  19. package/src/__tests__/fixtures/tool-probe/npm-dist-tags-missing-scoped.txt +4 -0
  20. package/src/__tests__/fixtures/tool-probe/npm-dist-tags-openai-codex.txt +4 -0
  21. package/src/__tests__/fixtures/tool-probe/pypi-cowsay.txt +3 -0
  22. package/src/__tests__/fixtures/tool-probe/uv-receipt-browser-use.toml +10 -0
  23. package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-git.toml +8 -0
  24. package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-pinned.toml +8 -0
  25. package/src/__tests__/fixtures/tool-probe/uv-receipt-graphifyy.toml +7 -0
  26. package/src/__tests__/fixtures/tool-probe/uv-receipt-mcp.toml +6 -0
  27. package/src/__tests__/fixtures/tool-probe/uv-receipt-serena-agent.toml +9 -0
  28. package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated-pinned.txt +5 -0
  29. package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated.txt +11 -0
  30. package/src/__tests__/fixtures/tool-probe/uv-tool-list.txt +22 -0
  31. package/src/__tests__/{gitignore-prerun.test.ts → gitignore-check.test.ts} +8 -6
  32. package/src/__tests__/plugin-cli-argv.test.ts +1 -1
  33. package/src/__tests__/plugin-row-text.test.ts +336 -0
  34. package/src/__tests__/scope-action.test.ts +69 -19
  35. package/src/__tests__/shell-script-callers.test.ts +8 -8
  36. package/src/__tests__/tool-probe.test.ts +1400 -0
  37. package/src/__tests__/update-apply.test.ts +283 -1
  38. package/src/__tests__/update-plan.test.ts +773 -31
  39. package/src/__tests__/version-unknown-state.test.ts +388 -0
  40. package/src/cli/router.ts +0 -5
  41. package/src/cli/update-view.ts +92 -12
  42. package/src/cli/update.ts +114 -38
  43. package/src/services/active-closure.ts +117 -0
  44. package/src/services/catalog-cache-store.ts +6 -2
  45. package/src/services/claude-settings.ts +1 -173
  46. package/src/services/{gitignore-prerun.ts → gitignore-check.ts} +14 -10
  47. package/src/services/gitignore-fixer.ts +1 -1
  48. package/src/services/marketplace-refresh.ts +30 -0
  49. package/src/services/plugin-manager.ts +200 -283
  50. package/src/services/resolver.ts +7 -2
  51. package/src/services/tool-probe.ts +1637 -0
  52. package/src/services/toolchain.ts +97 -0
  53. package/src/services/update-engine.ts +220 -32
  54. package/src/services/update-plan.ts +640 -160
  55. package/src/services/version-snapshot.ts +1 -1
  56. package/src/types/bun.d.ts +6 -0
  57. package/src/ui/App.tsx +2 -27
  58. package/src/ui/adapters/cliToolsAdapter.ts +176 -0
  59. package/src/ui/adapters/pluginsAdapter.ts +57 -0
  60. package/src/ui/components/modals/ModalContainer.tsx +0 -26
  61. package/src/ui/hooks/index.ts +0 -1
  62. package/src/ui/hooks/useGitignoreModal.ts +2 -3
  63. package/src/ui/renderers/cliToolRenderers.tsx +108 -67
  64. package/src/ui/renderers/pluginRenderers.tsx +157 -98
  65. package/src/ui/renderers/pluginRowText.ts +70 -0
  66. package/src/ui/renderers/pluginStatusText.ts +56 -0
  67. package/src/ui/screens/CliToolsScreen.tsx +154 -280
  68. package/src/ui/screens/PluginsScreen.tsx +156 -140
  69. package/src/ui/state/types.ts +0 -7
  70. package/src/__tests__/dual-write-prevention.test.ts +0 -380
  71. package/src/__tests__/gap-fill-versions.test.ts +0 -389
  72. package/src/__tests__/plugin-version-check.test.ts +0 -762
  73. package/src/cli/claude.ts +0 -21
  74. package/src/prerunner/index.ts +0 -500
  75. package/src/services/plugin-version-check.ts +0 -360
  76. package/src/services/update-cache.ts +0 -78
  77. package/src/ui/components/modals/VersionMismatchModal.tsx +0 -138
  78. package/src/ui/hooks/useMismatchModal.ts +0 -89
@@ -0,0 +1,7 @@
1
+ # captured 2026-09-12 00:42 AEST, read-only: cat ~/.local/share/uv/tools/graphifyy/uv-receipt.toml (uv 0.11.15)
2
+ [tool]
3
+ requirements = [{ name = "graphifyy", extras = ["mcp"] }]
4
+ entrypoints = [
5
+ { name = "graphify", install-path = "/Users/jack/.local/bin/graphify", from = "graphifyy" },
6
+ { name = "graphify-mcp", install-path = "/Users/jack/.local/bin/graphify-mcp", from = "graphifyy" },
7
+ ]
@@ -0,0 +1,6 @@
1
+ # captured 2026-09-12 00:42 AEST, read-only: cat ~/.local/share/uv/tools/mcp/uv-receipt.toml (uv 0.11.15)
2
+ [tool]
3
+ requirements = [{ name = "mcp" }]
4
+ entrypoints = [
5
+ { name = "mcp", install-path = "/Users/jack/.local/bin/mcp", from = "mcp" },
6
+ ]
@@ -0,0 +1,9 @@
1
+ # captured 2026-09-12 00:42 AEST, read-only: cat ~/.local/share/uv/tools/serena-agent/uv-receipt.toml (uv 0.11.15)
2
+ [tool]
3
+ requirements = [{ name = "serena-agent" }]
4
+ python = "3.13"
5
+ entrypoints = [
6
+ { name = "serena", install-path = "/Users/jack/.local/bin/serena", from = "serena-agent" },
7
+ { name = "serena-agent", install-path = "/Users/jack/.local/bin/serena-agent", from = "serena-agent" },
8
+ { name = "serena-hooks", install-path = "/Users/jack/.local/bin/serena-hooks", from = "serena-agent" },
9
+ ]
@@ -0,0 +1,5 @@
1
+ # captured 2026-09-12 00:54 AEST: UV_TOOL_DIR=$SESSION/validation/captures/uv-scratch/tools UV_TOOL_BIN_DIR=$SESSION/validation/captures/uv-scratch/bin UV_CACHE_DIR=$SESSION/validation/captures/uv-scratch/cache UV_PYTHON_INSTALL_DIR=$SESSION/validation/captures/uv-scratch/python NO_COLOR=1 uv tool list --outdated (exit=0)
2
+ # cowsay was installed as 'cowsay==6.0' (receipt specifier ==6.0); uv still lists it as outdated.
3
+ # $SESSION = claudish worktree update-fix2 ai-docs/sessions/dev-feature-claudeup-unify-20260911-2240 (the user's real uv tools were never touched)
4
+ cowsay v6.0 [latest: 6.1]
5
+ - cowsay
@@ -0,0 +1,11 @@
1
+ # captured 2026-09-12 00:42 AEST on the reporter's machine, read-only: uv tool list --outdated (uv 0.11.15, exit=0)
2
+ # Same text as the 2026-09-11 23:12 baseline. browser-use and mcp are absent: current.
3
+ claude-swap v0.24.1 [latest: 0.26.0]
4
+ - claude-swap
5
+ - cswap
6
+ graphifyy v0.9.50 [latest: 0.9.58]
7
+ - graphify
8
+ - graphify-mcp
9
+ mistral-vibe v2.5.0 [latest: 2.25.3]
10
+ - vibe
11
+ - vibe-acp
@@ -0,0 +1,22 @@
1
+ # captured 2026-09-12 00:42 AEST on the reporter's machine, read-only: uv tool list (uv 0.11.15, exit=0)
2
+ browser-use v0.13.10
3
+ - browser
4
+ - browser-use
5
+ - browser-use-tui
6
+ - browseruse
7
+ - bu
8
+ claude-swap v0.24.1
9
+ - claude-swap
10
+ - cswap
11
+ graphifyy v0.9.50
12
+ - graphify
13
+ - graphify-mcp
14
+ mcp v2.2.0
15
+ - mcp
16
+ mistral-vibe v2.5.0
17
+ - vibe
18
+ - vibe-acp
19
+ serena-agent v1.7.0
20
+ - serena
21
+ - serena-agent
22
+ - serena-hooks
@@ -6,8 +6,8 @@ import { join } from "node:path";
6
6
  import { BUILTIN_DEFAULTS } from "../data/gitignore-defaults.js";
7
7
  import {
8
8
  checkGitignore,
9
- formatPrerunWarning,
10
- } from "../services/gitignore-prerun";
9
+ formatGitignoreWarning,
10
+ } from "../services/gitignore-check";
11
11
 
12
12
  function git(cwd: string, ...args: string[]): void {
13
13
  spawnSync("git", ["-c", "core.excludesFile=/dev/null", ...args], { cwd });
@@ -17,7 +17,7 @@ describe("checkGitignore", () => {
17
17
  let projectDir: string;
18
18
 
19
19
  beforeEach(async () => {
20
- projectDir = await mkdtemp(join(tmpdir(), "gitig-prerun-"));
20
+ projectDir = await mkdtemp(join(tmpdir(), "gitig-check-"));
21
21
  });
22
22
 
23
23
  afterEach(async () => {
@@ -55,13 +55,15 @@ describe("checkGitignore", () => {
55
55
  });
56
56
  });
57
57
 
58
- describe("formatPrerunWarning", () => {
58
+ describe("formatGitignoreWarning", () => {
59
59
  it("returns null when no violations", () => {
60
- expect(formatPrerunWarning({ violationCount: 0, warnings: [] })).toBeNull();
60
+ expect(
61
+ formatGitignoreWarning({ violationCount: 0, warnings: [] }),
62
+ ).toBeNull();
61
63
  });
62
64
 
63
65
  it("returns a warning string when violations exist", () => {
64
- const w = formatPrerunWarning({ violationCount: 3, warnings: [] });
66
+ const w = formatGitignoreWarning({ violationCount: 3, warnings: [] });
65
67
  expect(w).not.toBeNull();
66
68
  expect(w).toContain("3 git state");
67
69
  expect(w).toContain("Git State tab");
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * Both exit 0. The no-op is indistinguishable from success at every layer
17
17
  * above, so every claudeup update path — the TUI's `U`, "update all", the
18
- * scope menu, `claudeup update`, the prerunner's auto-update — reported
18
+ * scope menu, `claudeup update` — reported
19
19
  * success and changed nothing, for ever.
20
20
  *
21
21
  * Mocking the CLI cannot catch that: a stub answers whatever the test asks it
@@ -0,0 +1,336 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { PluginInfo } from "../services/plugin-manager.js";
3
+ import {
4
+ type PluginUpdateItem,
5
+ classifyPluginRows,
6
+ planPluginUpdates,
7
+ } from "../services/update-plan.js";
8
+ import type { Marketplace } from "../types/index.js";
9
+ import {
10
+ type PluginPluginItem,
11
+ buildPluginBrowserItems,
12
+ } from "../ui/adapters/pluginsAdapter.js";
13
+ import { pluginRowText } from "../ui/renderers/pluginRowText.js";
14
+ import { pluginStatusText } from "../ui/renderers/pluginStatusText.js";
15
+
16
+ /**
17
+ * `pluginRowText` is exactly what a Plugins row prints after the name, in
18
+ * every branch of `pluginRow` — selected, unselected, orphan. So asserting on
19
+ * it asserts on the screen.
20
+ *
21
+ * Rows here are built the way the screen builds them: `classifyPluginRows` over
22
+ * the plugin list and the profile's pins, then `buildPluginBrowserItems`. The
23
+ * note each row must contain is taken from `planPluginUpdates` — the CLI's own
24
+ * answer — never retyped, so the two front ends are compared, not two copies.
25
+ */
26
+
27
+ const OFFICIAL: Marketplace = {
28
+ name: "claude-plugins-official",
29
+ displayName: "Anthropic Official",
30
+ source: { source: "github", repo: "anthropics/claude-plugins-official" },
31
+ description: "",
32
+ } as Marketplace;
33
+
34
+ const MAGUS: Marketplace = {
35
+ name: "magus",
36
+ displayName: "Magus",
37
+ source: { source: "github", repo: "MadAppGang/magus" },
38
+ description: "",
39
+ owned: true,
40
+ } as Marketplace;
41
+
42
+ function info(over: Partial<PluginInfo> & { id: string }): PluginInfo {
43
+ const [name, marketplace] = over.id.split("@");
44
+ return {
45
+ name,
46
+ version: null,
47
+ description: "",
48
+ marketplace,
49
+ marketplaceDisplay: "",
50
+ enabled: true,
51
+ author: { name: "Anthropic" },
52
+ ...over,
53
+ } as PluginInfo;
54
+ }
55
+
56
+ /** Real: `code-review@claude-plugins-official` in /Users/jack/mag/claudish. */
57
+ const CODE_REVIEW = info({
58
+ id: "code-review@claude-plugins-official",
59
+ version: null, // the official catalog publishes no version for it
60
+ installedVersion: "3ea32df27be7",
61
+ projectScope: { enabled: true, version: "3ea32df27be7" },
62
+ });
63
+
64
+ /** A registry "0.0.0" — the value the old row hid every status behind. */
65
+ const ZERO = info({
66
+ id: "zero-version@claude-plugins-official",
67
+ version: null,
68
+ installedVersion: "0.0.0",
69
+ projectScope: { enabled: true, version: "0.0.0" },
70
+ });
71
+
72
+ /** Other listed ids, so each marketplace demonstrably was read. */
73
+ const LISTED = [
74
+ info({ id: "dev@magus", version: "4.7.0", author: { name: "MadAppGang" } }),
75
+ info({ id: "github@claude-plugins-official", version: null }),
76
+ ];
77
+
78
+ function rows(
79
+ plugins: PluginInfo[],
80
+ pins: Record<string, string> = {},
81
+ ): Map<string, PluginPluginItem> {
82
+ const all = [...plugins, ...LISTED];
83
+ const items = buildPluginBrowserItems({
84
+ marketplaces: [MAGUS, OFFICIAL],
85
+ plugins: all,
86
+ collapsedMarketplaces: new Set(),
87
+ statuses: classifyPluginRows(all, pins),
88
+ });
89
+ return new Map(
90
+ items
91
+ .filter((i): i is PluginPluginItem => i.kind === "plugin")
92
+ .map((i) => [i.plugin.id, i]),
93
+ );
94
+ }
95
+
96
+ /** The note `claudeup update` prints for this plugin under this pin. */
97
+ function cliNote(
98
+ plugins: PluginInfo[],
99
+ id: string,
100
+ pin = "latest",
101
+ ): string | undefined {
102
+ const all = [...plugins, ...LISTED];
103
+ const [item] = planPluginUpdates(
104
+ { [id]: pin },
105
+ new Map(all.map((p) => [p.id, p])),
106
+ );
107
+ return item?.note;
108
+ }
109
+
110
+ describe("pluginRowText — a status never depends on a displayable version", () => {
111
+ test("a sha-versioned unknown carries the CLI's reason verbatim", () => {
112
+ // The old row said "? not comparable" here; the CLI said the sentence
113
+ // below. Same state, different reason — the R2 violation.
114
+ const row = rows([CODE_REVIEW]).get(CODE_REVIEW.id);
115
+ const note = cliNote([CODE_REVIEW], CODE_REVIEW.id);
116
+ expect(note).toBe(
117
+ "its marketplace publishes no version for this plugin, so there is nothing to compare",
118
+ );
119
+ const { text } = pluginRowText(row as PluginPluginItem);
120
+ expect(text).toBe(` v3ea32df27be7 ? ${note}`);
121
+ expect(text).not.toContain("not comparable");
122
+ });
123
+
124
+ test('REGRESSION: a "0.0.0" unknown is no longer blank', () => {
125
+ // Every old status clause was gated on a displayable version, so this row
126
+ // printed nothing after its name and read as healthy.
127
+ const row = rows([ZERO]).get(ZERO.id) as PluginPluginItem;
128
+ expect(row.status?.action).toBe("unknown");
129
+ const { text, segments } = pluginRowText(row);
130
+ expect(text).toBe(` ? ${cliNote([ZERO], ZERO.id)}`);
131
+ // The version itself is still never printed.
132
+ expect(text).not.toContain("0.0.0");
133
+ expect(segments.at(-1)?.tone).toBe("warning");
134
+ });
135
+ });
136
+
137
+ describe("pluginRowText — orphans say what the CLI says (F9)", () => {
138
+ test("an orphan that moved names where it went", () => {
139
+ const seo = info({
140
+ id: "seo@magus",
141
+ version: "1.8.0",
142
+ installedVersion: "1.8.0",
143
+ isOrphaned: true,
144
+ movedTo: "magus-marketing",
145
+ projectScope: { enabled: true, version: "1.8.0" },
146
+ });
147
+ const row = rows([seo]).get(seo.id) as PluginPluginItem;
148
+ expect(cliNote([seo], seo.id)).toBe("moved to magus-marketing");
149
+ expect(pluginRowText(row).text).toBe(" v1.8.0 ? moved to magus-marketing");
150
+ });
151
+
152
+ test("an orphan with no movedTo, from a marketplace we read, is 'not offered'", () => {
153
+ const gone = info({
154
+ id: "gone@magus",
155
+ version: "2.0.0",
156
+ installedVersion: "2.0.0",
157
+ isOrphaned: true,
158
+ projectScope: { enabled: true, version: "2.0.0" },
159
+ });
160
+ const row = rows([gone]).get(gone.id) as PluginPluginItem;
161
+ expect(pluginRowText(row).text).toBe(
162
+ ` v2.0.0 ? ${cliNote([gone], gone.id)}`,
163
+ );
164
+ expect(pluginRowText(row).text).toContain(
165
+ "not offered by any configured marketplace",
166
+ );
167
+ });
168
+
169
+ test("the old words are gone: no 'deprecated', no 'moved →'", () => {
170
+ const seo = info({
171
+ id: "seo@magus",
172
+ installedVersion: "1.8.0",
173
+ isOrphaned: true,
174
+ movedTo: "magus-marketing",
175
+ projectScope: { enabled: true, version: "1.8.0" },
176
+ });
177
+ const { text } = pluginRowText(rows([seo]).get(seo.id) as PluginPluginItem);
178
+ expect(text).not.toContain("deprecated");
179
+ expect(text).not.toContain("moved →");
180
+ });
181
+ });
182
+
183
+ describe("pluginRowText — history badges combine with the state", () => {
184
+ const updated = (over: Partial<PluginInfo>) =>
185
+ info({
186
+ id: "terminal@magus",
187
+ version: "3.3.1",
188
+ installedVersion: "3.0.1",
189
+ projectScope: { enabled: true, version: "3.0.1" },
190
+ author: { name: "MadAppGang" },
191
+ ...over,
192
+ });
193
+
194
+ test("'updated' and a pending update are both shown", () => {
195
+ // An external update moved 2.9.0 → 3.0.1 while 3.3.1 is already out.
196
+ const p = updated({ recentlyUpdatedFrom: "2.9.0" });
197
+ const row = rows([p]).get(p.id) as PluginPluginItem;
198
+ const { text, segments } = pluginRowText(row);
199
+ expect(text).toBe(" v2.9.0 → v3.0.1 updated → v3.3.1 available");
200
+ expect(segments.map((s) => s.tone)).toEqual(["success", "warning"]);
201
+ });
202
+
203
+ test("'new' and a pending update are both shown", () => {
204
+ const p = updated({ recentlyInstalled: true });
205
+ const row = rows([p]).get(p.id) as PluginPluginItem;
206
+ expect(pluginRowText(row).text).toBe(" v3.0.1 new → v3.3.1 available");
207
+ });
208
+
209
+ test("'new' with no displayable version still says new", () => {
210
+ const p = updated({
211
+ recentlyInstalled: true,
212
+ version: "0.0.0",
213
+ installedVersion: "0.0.0",
214
+ projectScope: { enabled: true, version: "0.0.0" },
215
+ });
216
+ const row = rows([p]).get(p.id) as PluginPluginItem;
217
+ expect(pluginRowText(row).text).toBe(" new");
218
+ });
219
+
220
+ test("a current row shows only its version", () => {
221
+ const p = updated({
222
+ version: "3.0.1",
223
+ });
224
+ const row = rows([p]).get(p.id) as PluginPluginItem;
225
+ expect(row.status?.action).toBe("current");
226
+ expect(pluginRowText(row).text).toBe(" v3.0.1");
227
+ });
228
+ });
229
+
230
+ describe("pluginRowText — rows with no state claim none", () => {
231
+ test("an undeclared, uninstalled catalog entry has no status and no text", () => {
232
+ const browse = info({
233
+ id: "kanban@magus",
234
+ version: "1.6.2",
235
+ author: { name: "MadAppGang" },
236
+ });
237
+ const row = rows([browse]).get(browse.id) as PluginPluginItem;
238
+ expect(row.status).toBeNull();
239
+ expect(pluginRowText(row).text).toBe("");
240
+ });
241
+
242
+ test("the same entry DECLARED by the profile says the profile wants it", () => {
243
+ const declared = info({
244
+ id: "kanban@magus",
245
+ version: "1.6.2",
246
+ author: { name: "MadAppGang" },
247
+ });
248
+ const row = rows([declared], { "kanban@magus": "latest" }).get(
249
+ declared.id,
250
+ ) as PluginPluginItem;
251
+ expect(pluginRowText(row).text).toBe(
252
+ " not installed — profile declares it",
253
+ );
254
+ });
255
+ });
256
+
257
+ describe("pluginStatusText — absent is not enabled-not-installed (LOW 6)", () => {
258
+ const status = (over: Partial<PluginUpdateItem>): PluginUpdateItem => ({
259
+ pluginId: "x@magus",
260
+ pinned: "latest",
261
+ installed: null,
262
+ available: "1.0.0",
263
+ target: "1.0.0",
264
+ action: "install",
265
+ reason: "absent",
266
+ scopes: [],
267
+ beforeVersions: {},
268
+ ...over,
269
+ });
270
+
271
+ test("absent — only ever from a profile pin — may say the profile declares it", () => {
272
+ expect(pluginStatusText(status({}))).toEqual({
273
+ suffix: " not installed — profile declares it",
274
+ tone: "warning",
275
+ });
276
+ });
277
+
278
+ test("enabled-not-installed shows its note and never claims a declaration", () => {
279
+ const got = pluginStatusText(
280
+ status({
281
+ reason: "enabled-not-installed",
282
+ note: "enabled in settings, but no files on disk",
283
+ }),
284
+ );
285
+ expect(got).toEqual({
286
+ suffix: " not installed (enabled in settings, but no files on disk)",
287
+ tone: "danger",
288
+ });
289
+ expect(got.suffix).not.toContain("profile");
290
+ });
291
+
292
+ test("an undeclared enabled-but-not-installed ROW gets the enabled text", () => {
293
+ const broken = info({
294
+ id: "kanban@magus",
295
+ version: "1.6.2",
296
+ author: { name: "MadAppGang" },
297
+ localScope: { enabled: true },
298
+ });
299
+ const row = rows([broken]).get(broken.id) as PluginPluginItem;
300
+ expect(row.status?.reason).toBe("enabled-not-installed");
301
+ expect(pluginRowText(row).text).toBe(
302
+ " not installed (enabled in settings, but no files on disk)",
303
+ );
304
+ });
305
+
306
+ test("null status says nothing", () => {
307
+ expect(pluginStatusText(null)).toEqual({ suffix: "", tone: "muted" });
308
+ });
309
+
310
+ test("update with a pin-undeliverable note carries it", () => {
311
+ expect(
312
+ pluginStatusText(
313
+ status({
314
+ action: "update",
315
+ reason: "pin-undeliverable",
316
+ target: "9.9.9",
317
+ note: "manifest pins 4.6.1; the marketplace offers only 9.9.9",
318
+ }),
319
+ ).suffix,
320
+ ).toBe(
321
+ " → v9.9.9 available (manifest pins 4.6.1; the marketplace offers only 9.9.9)",
322
+ );
323
+ });
324
+
325
+ test("repair carries its note", () => {
326
+ expect(
327
+ pluginStatusText(
328
+ status({
329
+ action: "repair",
330
+ reason: "content-stale",
331
+ note: "files changed without a version bump",
332
+ }),
333
+ ).suffix,
334
+ ).toBe(" stale — reinstall (files changed without a version bump)");
335
+ });
336
+ });
@@ -1,8 +1,10 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import {
3
+ type PluginInfo,
3
4
  isInstalledInScope,
4
5
  resolveScopeAction,
5
6
  } from "../services/plugin-manager.js";
7
+ import { planPluginUpdates, scopeIsBehind } from "../services/update-plan.js";
6
8
 
7
9
  /**
8
10
  * The scope keys (u / p / l) and the Enter scope picker both decide between
@@ -29,10 +31,18 @@ describe("isInstalledInScope", () => {
29
31
  expect(isInstalledInScope({ enabled: true })).toBe(false);
30
32
  });
31
33
 
32
- test("a version with the flag off is not installed either", () => {
33
- expect(isInstalledInScope({ enabled: false, version: "3.0.2" })).toBe(
34
- false,
35
- );
34
+ test("a registry version with the flag off IS installed", () => {
35
+ // Installation and enablement are separate facts. This asserted `false`
36
+ // until it was measured against the other direction: a plugin with a real
37
+ // registry row that the user had simply switched off reported "not
38
+ // installed", so its row offered to install what was already on disk, and
39
+ // the header counted it out.
40
+ //
41
+ // The hazard the sibling test above guards — pressing a scope key on
42
+ // enabled-with-no-version and having it run `uninstall` — is untouched:
43
+ // that case has no version, so it is still not installed, and still
44
+ // resolves to `install`.
45
+ expect(isInstalledInScope({ enabled: false, version: "3.0.2" })).toBe(true);
36
46
  });
37
47
 
38
48
  test("an absent scope is not installed", () => {
@@ -45,40 +55,80 @@ describe("isInstalledInScope", () => {
45
55
  });
46
56
 
47
57
  describe("resolveScopeAction", () => {
58
+ // The second argument is "is this scope provably behind?", which the screen
59
+ // takes from the classifier (`scopeIsBehind`). It used to be the catalog
60
+ // version, compared here with raw string inequality — a fourth rule.
61
+
48
62
  test("REGRESSION: enabled but not installed resolves to install, never uninstall", () => {
49
63
  // This is the reported bug. Before the fix this returned "uninstall".
50
- expect(resolveScopeAction({ enabled: true }, "3.0.2")).toBe("install");
64
+ expect(resolveScopeAction({ enabled: true }, false)).toBe("install");
65
+ // Whatever the classifier says: the safety branch comes first.
66
+ expect(resolveScopeAction({ enabled: true }, true)).toBe("install");
51
67
  });
52
68
 
53
69
  test("a scope that was never enabled resolves to install", () => {
54
- expect(resolveScopeAction(undefined, "3.0.2")).toBe("install");
55
- expect(resolveScopeAction({ enabled: false }, "3.0.2")).toBe("install");
70
+ expect(resolveScopeAction(undefined, false)).toBe("install");
71
+ expect(resolveScopeAction({ enabled: false }, false)).toBe("install");
56
72
  });
57
73
 
58
74
  test("a healthy install at the latest version resolves to uninstall (toggle off)", () => {
59
- expect(
60
- resolveScopeAction({ enabled: true, version: "3.0.2" }, "3.0.2"),
61
- ).toBe("uninstall");
75
+ expect(resolveScopeAction({ enabled: true, version: "3.0.2" }, false)).toBe(
76
+ "uninstall",
77
+ );
62
78
  });
63
79
 
64
80
  test("a healthy install behind the catalog resolves to update", () => {
65
- expect(
66
- resolveScopeAction({ enabled: true, version: "2.9.0" }, "3.0.2"),
67
- ).toBe("update");
81
+ expect(resolveScopeAction({ enabled: true, version: "2.9.0" }, true)).toBe(
82
+ "update",
83
+ );
68
84
  });
85
+ });
69
86
 
87
+ /** The screen's path: classify, then ask `scopeIsBehind` for the pressed scope. */
88
+ function keyFor(installed: string, catalogVersion: string | null) {
89
+ const scope = { enabled: true, version: installed };
90
+ const [status] = planPluginUpdates(
91
+ { "x@magus": "latest" },
92
+ new Map([
93
+ [
94
+ "x@magus",
95
+ {
96
+ id: "x@magus",
97
+ name: "x",
98
+ version: catalogVersion,
99
+ description: "",
100
+ marketplace: "magus",
101
+ marketplaceDisplay: "Magus",
102
+ enabled: true,
103
+ projectScope: scope,
104
+ } as PluginInfo,
105
+ ],
106
+ ]),
107
+ );
108
+ return resolveScopeAction(scope, scopeIsBehind(status, "project"));
109
+ }
110
+
111
+ describe("resolveScopeAction through the classifier — the screen's real path", () => {
70
112
  test('an unknown catalog version ("0.0.0") never fabricates an update', () => {
71
113
  // There is nothing to update *to*, so toggling must still mean uninstall.
72
- expect(
73
- resolveScopeAction({ enabled: true, version: "3.0.2" }, "0.0.0"),
74
- ).toBe("uninstall");
114
+ expect(keyFor("3.0.2", "0.0.0")).toBe("uninstall");
75
115
  });
76
116
 
77
117
  test("installed with unknown version resolves to update when a real version is published", () => {
78
118
  // "0.0.0" installed against a real catalog version is a genuine upgrade
79
119
  // path, not the broken state — it must not be treated as uninstall.
80
- expect(
81
- resolveScopeAction({ enabled: true, version: "0.0.0" }, "3.0.2"),
82
- ).toBe("update");
120
+ expect(keyFor("0.0.0", "3.0.2")).toBe("update");
121
+ });
122
+
123
+ test("REGRESSION: a scope AHEAD of the catalog no longer resolves to update", () => {
124
+ // Raw string inequality called 3.1.0 vs 3.0.2 "behind" and offered an
125
+ // update that could only reinstall the older version or no-op.
126
+ expect(keyFor("3.1.0", "3.0.2")).toBe("uninstall");
127
+ });
128
+
129
+ test("REGRESSION: an unorderable scope no longer resolves to update", () => {
130
+ // A sha against a semver cannot be ordered. The row says "?", so the key
131
+ // must not act as though it knew the scope was behind.
132
+ expect(keyFor("3ea32df27be7", "3.0.2")).toBe("uninstall");
83
133
  });
84
134
  });
@@ -66,18 +66,18 @@ describe("only the toolchain bootstrap goes through a shell", () => {
66
66
  expect(await callersOf(/\brunShellScript\(/)).toEqual(ALLOWED);
67
67
  });
68
68
 
69
- test("nothing spawns with `shell: true` outside utils/run.ts", async () => {
69
+ test("nothing spawns with a shell outside utils/run.ts", async () => {
70
70
  // Catches someone reaching for `spawn`/`exec` directly rather than
71
71
  // through the seam — a second shell path that never names
72
72
  // `runShellScript`.
73
73
  //
74
- // Scope, stated honestly: this checks `shell: true` only. Several
75
- // version-DETECTION helpers in CliToolsScreen still pass
76
- // `shell: "/bin/bash"` to run pipelines like `which -a x 2>/dev/null`.
77
- // Those interpolate a catalogue `name`, not an installer command, and
78
- // were out of scope here so this assertion would be false if it
79
- // claimed no shell exists anywhere.
80
- expect(await callersOf(/shell:\s*true/)).toEqual([]);
74
+ // Both spellings: `shell: true` and a named shell (`shell: "/bin/bash"`).
75
+ // The CLI Tools screen used the second for its version probe (`which -a`
76
+ // pipelines, a string-form `exec` of each check command); that probe now
77
+ // lives in services/tool-probe.ts and runs argv only. `shell: false` and
78
+ // the alias writer's `shell: ShellKind` values are not spawn options and
79
+ // do not match.
80
+ expect(await callersOf(/shell:\s*(true|["'`])/)).toEqual([]);
81
81
  });
82
82
 
83
83
  test("the scan can actually fail", async () => {