claudeup 6.5.1 → 6.7.1

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 (85) 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__/conventions-manager.test.ts +45 -45
  14. package/src/__tests__/enabled-not-installed.test.ts +2 -3
  15. package/src/__tests__/fixtures/tool-probe/brew-list-versions-tmux.txt +2 -0
  16. package/src/__tests__/fixtures/tool-probe/brew-outdated-behind-homebrew-spec.txt +29 -0
  17. package/src/__tests__/fixtures/tool-probe/brew-outdated-tmux-current.txt +7 -0
  18. package/src/__tests__/fixtures/tool-probe/brew-outdated-unknown-formula.txt +6 -0
  19. package/src/__tests__/fixtures/tool-probe/go-version-m-tmux-mcp.txt +27 -0
  20. package/src/__tests__/fixtures/tool-probe/npm-dist-tags-missing-scoped.txt +4 -0
  21. package/src/__tests__/fixtures/tool-probe/npm-dist-tags-openai-codex.txt +4 -0
  22. package/src/__tests__/fixtures/tool-probe/pypi-cowsay.txt +3 -0
  23. package/src/__tests__/fixtures/tool-probe/uv-receipt-browser-use.toml +10 -0
  24. package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-git.toml +8 -0
  25. package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-pinned.toml +8 -0
  26. package/src/__tests__/fixtures/tool-probe/uv-receipt-graphifyy.toml +7 -0
  27. package/src/__tests__/fixtures/tool-probe/uv-receipt-mcp.toml +6 -0
  28. package/src/__tests__/fixtures/tool-probe/uv-receipt-serena-agent.toml +9 -0
  29. package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated-pinned.txt +5 -0
  30. package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated.txt +11 -0
  31. package/src/__tests__/fixtures/tool-probe/uv-tool-list.txt +22 -0
  32. package/src/__tests__/{gitignore-prerun.test.ts → gitignore-check.test.ts} +8 -6
  33. package/src/__tests__/models-presets-marketplace.test.ts +141 -0
  34. package/src/__tests__/plugin-cli-argv.test.ts +1 -1
  35. package/src/__tests__/plugin-manager-fallback.test.ts +3 -5
  36. package/src/__tests__/plugin-row-text.test.ts +336 -0
  37. package/src/__tests__/resolver.test.ts +3 -3
  38. package/src/__tests__/scope-action.test.ts +69 -19
  39. package/src/__tests__/shell-script-callers.test.ts +8 -8
  40. package/src/__tests__/tool-probe.test.ts +1400 -0
  41. package/src/__tests__/update-apply.test.ts +283 -1
  42. package/src/__tests__/update-plan.test.ts +773 -31
  43. package/src/__tests__/version-unknown-state.test.ts +388 -0
  44. package/src/cli/router.ts +0 -5
  45. package/src/cli/update-view.ts +92 -12
  46. package/src/cli/update.ts +114 -38
  47. package/src/data/models-presets.ts +10 -11
  48. package/src/data/predefined-profiles.ts +7 -7
  49. package/src/services/active-closure.ts +117 -0
  50. package/src/services/catalog-cache-store.ts +6 -2
  51. package/src/services/claude-settings.ts +2 -174
  52. package/src/services/community-styles.ts +1 -1
  53. package/src/services/{gitignore-prerun.ts → gitignore-check.ts} +14 -10
  54. package/src/services/gitignore-fixer.ts +1 -1
  55. package/src/services/marketplace-refresh.ts +30 -0
  56. package/src/services/plugin-manager.ts +200 -283
  57. package/src/services/resolver.ts +7 -2
  58. package/src/services/tool-probe.ts +1637 -0
  59. package/src/services/toolchain.ts +97 -0
  60. package/src/services/update-engine.ts +220 -32
  61. package/src/services/update-plan.ts +640 -160
  62. package/src/services/version-snapshot.ts +1 -1
  63. package/src/types/bun.d.ts +6 -0
  64. package/src/ui/App.tsx +2 -27
  65. package/src/ui/adapters/cliToolsAdapter.ts +176 -0
  66. package/src/ui/adapters/pluginsAdapter.ts +57 -0
  67. package/src/ui/components/modals/ModalContainer.tsx +0 -26
  68. package/src/ui/hooks/index.ts +0 -1
  69. package/src/ui/hooks/useGitignoreModal.ts +2 -3
  70. package/src/ui/renderers/cliToolRenderers.tsx +108 -67
  71. package/src/ui/renderers/pluginRenderers.tsx +157 -98
  72. package/src/ui/renderers/pluginRowText.ts +70 -0
  73. package/src/ui/renderers/pluginStatusText.ts +56 -0
  74. package/src/ui/screens/CliToolsScreen.tsx +154 -280
  75. package/src/ui/screens/PluginsScreen.tsx +156 -140
  76. package/src/ui/state/types.ts +0 -7
  77. package/src/__tests__/dual-write-prevention.test.ts +0 -380
  78. package/src/__tests__/gap-fill-versions.test.ts +0 -389
  79. package/src/__tests__/plugin-version-check.test.ts +0 -762
  80. package/src/cli/claude.ts +0 -21
  81. package/src/prerunner/index.ts +0 -500
  82. package/src/services/plugin-version-check.ts +0 -360
  83. package/src/services/update-cache.ts +0 -78
  84. package/src/ui/components/modals/VersionMismatchModal.tsx +0 -138
  85. package/src/ui/hooks/useMismatchModal.ts +0 -89
@@ -0,0 +1,388 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ buildScopeStatus,
4
+ compareVersions,
5
+ isBehind,
6
+ isInstalledInScope,
7
+ resolveScopeAction,
8
+ } from "../services/plugin-manager.js";
9
+ import type { PluginInfo } from "../services/plugin-manager.js";
10
+ import { planPluginUpdates } from "../services/update-plan.js";
11
+
12
+ /**
13
+ * REGRESSION: claudeup renders "I cannot tell" as "nothing there" in three
14
+ * separate places — Fixed in /dev:fix session dev-fix-20260910-claudeup.
15
+ *
16
+ * Measured before the fix, against the real function and real registry values:
17
+ *
18
+ * compareVersions("517b2fcd1b60", "b819188d2eea") = 0 hasUpdate=false
19
+ * compareVersions("7.2.0", "517b2fcd1b60") = 1 hasUpdate=true
20
+ *
21
+ * Two different git commits compared EQUAL, so a sha-versioned plugin could
22
+ * never report an update; and a sha compared against a semver reported a
23
+ * spurious one. `feature-dev@claude-plugins-official` is 517b2fcd1b60 in one
24
+ * project and b819188d2eea in another on the reporter's machine.
25
+ *
26
+ * The rule this file pins: an unorderable pair is `null` (unknown), never 0
27
+ * (equal) and never a direction.
28
+ */
29
+
30
+ const SHA_A = "517b2fcd1b60";
31
+ const SHA_B = "b819188d2eea";
32
+
33
+ describe("compareVersions — orderable pairs", () => {
34
+ test("semver ordering is unchanged", () => {
35
+ expect(compareVersions("7.2.0", "7.1.0")).toBe(1);
36
+ expect(compareVersions("7.1.0", "7.2.0")).toBe(-1);
37
+ expect(compareVersions("7.1.0", "7.1.0")).toBe(0);
38
+ });
39
+
40
+ test("a leading v is still stripped", () => {
41
+ expect(compareVersions("v2.0.0", "1.9.9")).toBe(1);
42
+ });
43
+
44
+ test("uneven part counts still compare", () => {
45
+ expect(compareVersions("1.2", "1.2.0")).toBe(0);
46
+ expect(compareVersions("1.2.1", "1.2")).toBe(1);
47
+ });
48
+ });
49
+
50
+ describe("compareVersions — unorderable pairs are null, not 0", () => {
51
+ test("two DIFFERENT commit shas are not equal", () => {
52
+ // The bug: this returned 0, so nothing downstream could see a difference.
53
+ expect(compareVersions(SHA_A, SHA_B)).toBeNull();
54
+ expect(compareVersions(SHA_B, SHA_A)).toBeNull();
55
+ });
56
+
57
+ test("the same commit sha is genuinely equal", () => {
58
+ expect(compareVersions(SHA_A, SHA_A)).toBe(0);
59
+ });
60
+
61
+ test("a sha against a semver is not orderable in either direction", () => {
62
+ // The bug: these returned 1 and -1, a fabricated ordering.
63
+ expect(compareVersions("7.2.0", SHA_A)).toBeNull();
64
+ expect(compareVersions(SHA_A, "7.2.0")).toBeNull();
65
+ });
66
+
67
+ test("a missing side is unknown, not equal", () => {
68
+ // The bug: `if (!a || !b) return 0` claimed two absent versions matched.
69
+ expect(compareVersions(undefined, "1.0.0")).toBeNull();
70
+ expect(compareVersions("1.0.0", null)).toBeNull();
71
+ expect(compareVersions(undefined, undefined)).toBeNull();
72
+ });
73
+
74
+ test("the 0.0.0 sentinel is comparable — official plugins record it", () => {
75
+ expect(compareVersions("0.0.0", "0.0.0")).toBe(0);
76
+ expect(compareVersions("1.0.0", "0.0.0")).toBe(1);
77
+ });
78
+ });
79
+
80
+ describe("isBehind — the question callers actually ask", () => {
81
+ test("semver behind, ahead, level", () => {
82
+ expect(isBehind("7.2.0", "7.1.0")).toBe(true);
83
+ expect(isBehind("7.1.0", "7.2.0")).toBe(false);
84
+ expect(isBehind("7.1.0", "7.1.0")).toBe(false);
85
+ });
86
+
87
+ test("a different published sha means the install is not current", () => {
88
+ // The catalog is authoritative for what is published NOW. An installed
89
+ // commit that differs from it is not current, even though the two shas
90
+ // carry no ordering between them.
91
+ expect(isBehind(SHA_B, SHA_A)).toBe(true);
92
+ expect(isBehind(SHA_A, SHA_A)).toBe(false);
93
+ });
94
+
95
+ test("a sha against a semver is unknown, never a silent false", () => {
96
+ expect(isBehind("7.2.0", SHA_A)).toBeNull();
97
+ expect(isBehind(SHA_A, "7.2.0")).toBeNull();
98
+ });
99
+
100
+ test("not installed is unknown, not up to date", () => {
101
+ // The bug: `installedVersion && compare(...)` yielded false, so a plugin
102
+ // that was not installed at all was excluded from the update count and
103
+ // read as up to date.
104
+ expect(isBehind("7.2.0", undefined)).toBeNull();
105
+ expect(isBehind("7.2.0", null)).toBeNull();
106
+ });
107
+ });
108
+
109
+ describe("buildScopeStatus — installation is a registry fact, not a settings key", () => {
110
+ test("an installed but NOT enabled plugin still reports its scope", () => {
111
+ // The bug: scope was `enabledPlugins[id] !== undefined ? {...} : undefined`,
112
+ // so a plugin with a real registry row but no settings key rendered
113
+ // identically to one that was never installed.
114
+ const scope = buildScopeStatus({}, { "dev@magus": "7.2.0" }, "dev@magus");
115
+ expect(scope).toBeDefined();
116
+ expect(scope?.version).toBe("7.2.0");
117
+ expect(scope?.enabled).toBe(false);
118
+ expect(isInstalledInScope(scope)).toBe(true);
119
+ });
120
+
121
+ test("enabled with no install is still reported, as a broken state", () => {
122
+ const scope = buildScopeStatus({ "dev@magus": true }, {}, "dev@magus");
123
+ expect(scope).toBeDefined();
124
+ expect(scope?.enabled).toBe(true);
125
+ expect(scope?.version).toBeUndefined();
126
+ expect(isInstalledInScope(scope)).toBe(false);
127
+ });
128
+
129
+ test("neither enabled nor installed has no scope at all", () => {
130
+ expect(buildScopeStatus({}, {}, "dev@magus")).toBeUndefined();
131
+ });
132
+ });
133
+
134
+ describe("isInstalledInScope — presence of files, not permission to load", () => {
135
+ test("installed and disabled is still installed", () => {
136
+ // The old rule was `!!scope?.enabled && !!scope.version`, which made
137
+ // disabling a plugin look like uninstalling it.
138
+ expect(isInstalledInScope({ enabled: false, version: "7.2.0" })).toBe(true);
139
+ });
140
+
141
+ test("the 0.0.0 sentinel counts as installed", () => {
142
+ expect(isInstalledInScope({ enabled: true, version: "0.0.0" })).toBe(true);
143
+ });
144
+
145
+ test("enabled with no version is not installed", () => {
146
+ expect(isInstalledInScope({ enabled: true, version: undefined })).toBe(
147
+ false,
148
+ );
149
+ });
150
+ });
151
+
152
+ const info = (over: Partial<PluginInfo>): PluginInfo =>
153
+ ({
154
+ id: "x@magus",
155
+ name: "x",
156
+ version: "1.0.0",
157
+ description: "",
158
+ marketplace: "magus",
159
+ marketplaceDisplay: "Magus",
160
+ enabled: true,
161
+ ...over,
162
+ }) as PluginInfo;
163
+
164
+ /**
165
+ * A catalog that HAS been read for @magus — it lists other magus plugins. That
166
+ * distinction is load-bearing: an empty catalog means "this marketplace was
167
+ * never read", where absence proves nothing, and installing is what registers
168
+ * it. Only a catalog we demonstrably hold entries from can turn absence into
169
+ * the answer "that plugin is not published".
170
+ */
171
+ const magusCatalog = (...extra: PluginInfo[]) =>
172
+ new Map(
173
+ [
174
+ info({ id: "dev@magus", version: "7.2.0" }),
175
+ info({ id: "terminal@magus", version: "5.0.1" }),
176
+ ...extra,
177
+ ].map((p) => [p.id, p]),
178
+ );
179
+
180
+ describe("planPluginUpdates — a pin its own marketplace does not offer", () => {
181
+ test("declared, not installed, marketplace read, plugin absent → unknown", () => {
182
+ // The bug: the !installed branch ran before any catalog guard, so
183
+ // `agentdev@magus` (retired) and `seo@magus` (moved to magus-marketing)
184
+ // planned as `install` with the literal pin as target on every run,
185
+ // forever, and every apply failed. The installed path already answered
186
+ // this correctly further down; the two are now consistent.
187
+ const items = planPluginUpdates(
188
+ { "agentdev@magus": "latest" },
189
+ magusCatalog(),
190
+ );
191
+ expect(items).toHaveLength(1);
192
+ expect(items[0]?.action).toBe("unknown");
193
+ expect(items[0]?.target).toBeNull();
194
+ expect(items[0]?.note).toContain(
195
+ "not offered by any configured marketplace",
196
+ );
197
+ expect(items[0]?.reason).toBe("not-offered");
198
+ });
199
+
200
+ test("an exact pin its marketplace does not offer is also unknown", () => {
201
+ const items = planPluginUpdates({ "gone@magus": "3.0.0" }, magusCatalog());
202
+ expect(items[0]?.action).toBe("unknown");
203
+ expect(items[0]?.target).toBeNull();
204
+ expect(items[0]?.reason).toBe("not-offered");
205
+ });
206
+
207
+ test("declared, not installed, and the catalog does offer it → install", () => {
208
+ const items = planPluginUpdates(
209
+ { "kanban@magus": "latest" },
210
+ magusCatalog(info({ id: "kanban@magus", version: "1.6.2" })),
211
+ );
212
+ expect(items[0]?.action).toBe("install");
213
+ expect(items[0]?.target).toBe("1.6.2");
214
+ expect(items[0]?.reason).toBe("absent");
215
+ });
216
+
217
+ test("a marketplace we have NOT read still plans an install", () => {
218
+ // Not the dead-pin case: with no entries from @newmp we never read its
219
+ // catalog, so absence is not evidence — and installing is what registers
220
+ // the marketplace in the first place. Guarding on `available === null`
221
+ // alone broke exactly this, which the existing suite caught.
222
+ const items = planPluginUpdates(
223
+ { "thing@newmp": "latest" },
224
+ magusCatalog(),
225
+ );
226
+ expect(items[0]?.action).toBe("install");
227
+ expect(items[0]?.reason).toBe("absent");
228
+ });
229
+ });
230
+
231
+ describe("REVIEW-B regressions — found by the patch-quality panel", () => {
232
+ test("a catalog entry with NO version is offered, not unknown", () => {
233
+ // Measured: 278 of the 292 plugins in claude-plugins-official declare no
234
+ // version at all, `feature-dev` among them. Keying "not offered" on
235
+ // `available === null` planned `unknown` for every one of them and never
236
+ // installed. Membership in the catalog is the real test.
237
+ const items = planPluginUpdates(
238
+ { "feature-dev@official": "latest" },
239
+ new Map(
240
+ [
241
+ info({ id: "feature-dev@official", version: null }),
242
+ info({ id: "github@official", version: null }),
243
+ ].map((p) => [p.id, p]),
244
+ ),
245
+ );
246
+ expect(items[0]?.action).toBe("install");
247
+ expect(items[0]?.reason).toBe("absent");
248
+ });
249
+
250
+ test("an installed version that cannot be compared is unknown, not current", () => {
251
+ // 21 registry rows carry the literal string "unknown" as their version.
252
+ // Against a semver catalog they are unorderable, so no scope is provably
253
+ // behind — and falling through to `current` claims they are up to date.
254
+ const items = planPluginUpdates(
255
+ { "playground@official": "latest" },
256
+ new Map([
257
+ [
258
+ "playground@official",
259
+ info({
260
+ id: "playground@official",
261
+ version: "7.3.0",
262
+ projectScope: { enabled: true, version: "unknown" },
263
+ }),
264
+ ],
265
+ ]),
266
+ );
267
+ expect(items[0]?.action).toBe("unknown");
268
+ expect(items[0]?.note).toContain("cannot be compared");
269
+ expect(items[0]?.reason).toBe("unorderable");
270
+ });
271
+
272
+ test("a genuinely current install is still current", () => {
273
+ const items = planPluginUpdates(
274
+ { "dev@magus": "latest" },
275
+ new Map([
276
+ [
277
+ "dev@magus",
278
+ info({
279
+ id: "dev@magus",
280
+ version: "7.3.0",
281
+ projectScope: { enabled: true, version: "7.3.0" },
282
+ }),
283
+ ],
284
+ ]),
285
+ );
286
+ expect(items[0]?.action).toBe("current");
287
+ expect(items[0]?.reason).toBe("up-to-date");
288
+ });
289
+ });
290
+
291
+ describe("resolveScopeAction — the scope key must never silently uninstall", () => {
292
+ test("installed but DISABLED resolves to install, not uninstall", () => {
293
+ // The hazard: the scope key runs cliUninstallPlugin with no confirmation.
294
+ // Relaxing isInstalledInScope made this combination fall through to
295
+ // "uninstall", so pressing the key on a plugin the user had merely
296
+ // switched off would delete it.
297
+ expect(
298
+ resolveScopeAction({ enabled: false, version: "3.0.2" }, false),
299
+ ).toBe("install");
300
+ });
301
+
302
+ test("installed AND enabled at the latest version still offers uninstall", () => {
303
+ expect(resolveScopeAction({ enabled: true, version: "3.0.2" }, false)).toBe(
304
+ "uninstall",
305
+ );
306
+ });
307
+
308
+ test("installed, enabled, and behind still offers update", () => {
309
+ expect(resolveScopeAction({ enabled: true, version: "3.0.1" }, true)).toBe(
310
+ "update",
311
+ );
312
+ });
313
+
314
+ test("not installed at all resolves to install", () => {
315
+ expect(resolveScopeAction(undefined, false)).toBe("install");
316
+ });
317
+ });
318
+
319
+ describe("installed with no comparable catalog version — two different causes", () => {
320
+ test("in the catalog but declaring no version is NOT 'withdrawn'", () => {
321
+ // feature-dev@claude-plugins-official, measured live: installed, listed by
322
+ // its marketplace, and declaring no version. Reporting it as "not offered
323
+ // by any configured marketplace" told the user a working, currently
324
+ // published plugin had been withdrawn.
325
+ const items = planPluginUpdates(
326
+ { "feature-dev@official": "latest" },
327
+ new Map([
328
+ [
329
+ "feature-dev@official",
330
+ info({
331
+ id: "feature-dev@official",
332
+ version: null,
333
+ projectScope: { enabled: true, version: "3ea32df27be7" },
334
+ }),
335
+ ],
336
+ ]),
337
+ );
338
+ expect(items[0]?.action).toBe("unknown");
339
+ expect(items[0]?.note).toContain("publishes no version");
340
+ expect(items[0]?.note).not.toContain("not offered");
341
+ expect(items[0]?.reason).toBe("no-published-version");
342
+ });
343
+
344
+ test("absent from the catalog entirely IS 'not offered'", () => {
345
+ const items = planPluginUpdates(
346
+ { "gone@magus": "latest" },
347
+ new Map([
348
+ [
349
+ "other@magus",
350
+ info({
351
+ id: "other@magus",
352
+ version: "1.0.0",
353
+ projectScope: { enabled: true, version: "1.0.0" },
354
+ }),
355
+ ],
356
+ ]),
357
+ );
358
+ expect(items[0]?.action).toBe("unknown");
359
+ expect(items[0]?.note).toContain(
360
+ "not offered by any configured marketplace",
361
+ );
362
+ expect(items[0]?.reason).toBe("not-offered");
363
+ });
364
+ });
365
+
366
+ describe('the "0.0.0" registry value is unknown, never healthy', () => {
367
+ test("installed at 0.0.0 with no published version is no-published-version", () => {
368
+ // Real: explanatory-output-style@claude-plugins-official is recorded as
369
+ // "0.0.0" in the registry for many project paths, and its marketplace
370
+ // publishes no version. The TUI row hid every status suffix behind a
371
+ // displayable version, so this row rendered with none at all.
372
+ const items = planPluginUpdates(
373
+ { "explanatory-output-style@official": "latest" },
374
+ new Map([
375
+ [
376
+ "explanatory-output-style@official",
377
+ info({
378
+ id: "explanatory-output-style@official",
379
+ version: null,
380
+ projectScope: { enabled: true, version: "0.0.0" },
381
+ }),
382
+ ],
383
+ ]),
384
+ );
385
+ expect(items[0]?.action).toBe("unknown");
386
+ expect(items[0]?.reason).toBe("no-published-version");
387
+ });
388
+ });
package/src/cli/router.ts CHANGED
@@ -9,7 +9,6 @@
9
9
  import type { ThemeMode } from "@opentui/core";
10
10
  import { checkForUpdates } from "../services/version-check.js";
11
11
  import { parseThemeWord } from "../ui/theme-resolve.js";
12
- import { runClaudeCommand } from "./claude.js";
13
12
  import { runDoctorCommand } from "./doctor.js";
14
13
  import { runInstallCommand } from "./install.js";
15
14
  import { runModelsCommand } from "./models.js";
@@ -116,8 +115,6 @@ export async function route(
116
115
  const [command, ...rest] = themeFlag.rest;
117
116
 
118
117
  switch (command) {
119
- case "claude":
120
- return HANDLED(await runClaudeCommand(rest));
121
118
  case "update":
122
119
  return HANDLED(await runUpdateCommand(rest));
123
120
  case "upgrade":
@@ -193,8 +190,6 @@ applied by a PreToolUse hook claudeup registers at user scope.
193
190
  models off Stop routing; subagents inherit the session model again
194
191
 
195
192
  Other commands:
196
- claude [args...] Check for plugin updates (1h cache), then run claude
197
- -f, --force Force update check (bypass 1h cache)
198
193
  upgrade Update claudeup ITSELF to the latest version
199
194
 
200
195
  Navigation (TUI):
@@ -24,7 +24,7 @@
24
24
  * own word, so the report survives NO_COLOR, a pipe, and colour blindness.
25
25
  */
26
26
 
27
- import { binInstallCommand, binUpgradeCommand } from "../services/toolchain.js";
27
+ import { binCommandFor } from "../services/update-engine.js";
28
28
  import type {
29
29
  BinUpdateItem,
30
30
  PluginUpdateItem,
@@ -88,7 +88,9 @@ const ACTIONS: Record<ActionKey, ActionStyle> = {
88
88
  upgrade: { glyph: "↑", label: "UPGRADE", color: brand.warning },
89
89
  repair: { glyph: "⟳", label: "REPAIR", color: brand.info },
90
90
  refresh: { glyph: "⟳", label: "REFRESH", color: brand.info },
91
- unknown: { glyph: "?", label: "UNKNOWN", color: brand.danger },
91
+ // Warning, not danger: "cannot tell" is not a failure, and red UNKNOWN read
92
+ // as "broken". The summary line and the TUI already use warning for it.
93
+ unknown: { glyph: "?", label: "UNKNOWN", color: brand.warning },
92
94
  };
93
95
 
94
96
  /** Widest label plus the badge's own two spaces of padding. */
@@ -251,17 +253,40 @@ function pluginDetail(item: PluginUpdateItem): string {
251
253
  }
252
254
  }
253
255
 
256
+ /**
257
+ * What a tool row says on its right.
258
+ *
259
+ * A `current` row used to print `pinned —` unconditionally, which was true only
260
+ * while "current" implied "pinned". An unpinned tool can be current now, so the
261
+ * pin is shown only when there is one.
262
+ */
254
263
  function binDetail(item: BinUpdateItem, plan: UpdatePlan): string {
255
264
  const spec = plan.binSpecs.get(item.name);
256
- if (item.action === "current" || !spec)
257
- return `pinned ${item.version ?? "—"}`;
258
- const cmd =
259
- item.action === "install"
260
- ? binInstallCommand(spec)
261
- : binUpgradeCommand(spec);
262
265
  // Rendered for the reader's eyes only — this row says what WILL run, while
263
- // applyBins runs the argv itself.
264
- return dim(formatCommand(cmd));
266
+ // applyBins runs the argv itself. Same builder, so they cannot differ.
267
+ const cmd = spec ? binCommandFor(item, spec) : null;
268
+ const command = cmd ? dim(formatCommand(cmd)) : "";
269
+ switch (item.action) {
270
+ case "current":
271
+ return `${item.installed ?? "—"}${
272
+ item.version ? ` ${dim(`pinned ${item.version}`)}` : ""
273
+ }`;
274
+ case "upgrade":
275
+ return `${dim(item.installed ?? "—")} ${fg(brand.warning, "→")} ${fg(
276
+ brand.warning,
277
+ item.target ?? "?",
278
+ )}${command ? ` ${command}` : ""}${
279
+ // An upgrade that also changes the tool's owner says so here; a
280
+ // plain one reads identically to before.
281
+ item.adopted ? ` ${dim(`(${item.adopted})`)}` : ""
282
+ }`;
283
+ case "unknown":
284
+ return `${item.installed ?? "—"}${
285
+ item.note ? ` ${dim(`(${item.note})`)}` : ""
286
+ }`;
287
+ case "install":
288
+ return command;
289
+ }
265
290
  }
266
291
 
267
292
  /**
@@ -396,22 +421,30 @@ export function applyRow(
396
421
  )}`;
397
422
  }
398
423
 
399
- /** Closing line: the run's shape as a bar, then the counts that label it. */
424
+ /**
425
+ * Closing line: the run's shape as a bar, then the counts that label it.
426
+ *
427
+ * `unchanged` is its own count: an installer that exited 0 while read-back
428
+ * shows nothing moved. Folding it into `changed` is how every run printed
429
+ * "3 changed" while changing nothing; it is not a failure either.
430
+ */
400
431
  export function summaryLine(
401
432
  changed: number,
402
433
  current: number,
403
434
  unknown: number,
404
435
  failed: number,
405
436
  ms: number,
437
+ unchanged = 0,
406
438
  ): string[] {
407
439
  const bar = stackedBar(BAR * 2, [
408
440
  { value: changed, color: brand.success },
409
441
  { value: current, color: brand.muted },
410
- { value: unknown, color: brand.warning },
442
+ { value: unchanged + unknown, color: brand.warning },
411
443
  { value: failed, color: brand.danger },
412
444
  ]);
413
445
  const parts = [
414
446
  changed > 0 ? fg(brand.success, `${changed} changed`) : "",
447
+ unchanged > 0 ? fg(brand.warning, `${unchanged} unchanged`) : "",
415
448
  current > 0 ? dim(`${current} current`) : "",
416
449
  unknown > 0 ? fg(brand.warning, `${unknown} unverified`) : "",
417
450
  failed > 0 ? fg(brand.danger, `${failed} failed`) : "",
@@ -444,3 +477,50 @@ export function info(text: string): string {
444
477
  export function note(text: string): string {
445
478
  return `${PAD} ${dim(text)}`;
446
479
  }
480
+
481
+ // -- unverified items ----------------------------------------------------------
482
+ //
483
+ // A plugin is `unknown` for several distinct reasons — its marketplace publishes
484
+ // no version, the two versions cannot be ordered, no marketplace offers it, its
485
+ // marketplace was not read, or the catalog fetch failed — and every row above
486
+ // already prints its own. These lines therefore claim only what holds for EVERY
487
+ // reason. They once advised "re-run once the catalog is reachable", which is
488
+ // false for a plugin whose marketplace publishes no version: re-running changes
489
+ // nothing, and the reader went looking for a network fault that did not exist.
490
+
491
+ /** Unverified plugins, under the plan. Never "up to date" — that is the point. */
492
+ export function unknownPluginsNotice(count: number): string {
493
+ return warn(
494
+ `${bold(String(count))} plugin(s) could not be checked — not applied. They are NOT reported as up to date. Reasons are shown above.`,
495
+ );
496
+ }
497
+
498
+ /** Unverified tools, under the plan. */
499
+ export function unknownToolsNotice(count: number, names: string[]): string {
500
+ return warn(
501
+ `${bold(String(count))} tool(s) could not be checked: ${names.join(", ")} — not applied. Reasons are shown above.`,
502
+ );
503
+ }
504
+
505
+ /**
506
+ * `--check` is about to exit 0 while N items went unchecked.
507
+ *
508
+ * The gate does not fail on an unknown — plugin or tool — because most unknowns
509
+ * are permanent (a marketplace that publishes no version, a tool another npm
510
+ * owns), and a gate that can never pass protects nothing. This line is what
511
+ * keeps the exit 0 honest: it is "nothing is KNOWN to be behind", not
512
+ * "everything was verified". It counts plugins and tools together because the
513
+ * two per-kind notices above already name which is which.
514
+ */
515
+ export function uncheckedGateNotice(count: number): string {
516
+ return warn(
517
+ `${bold(String(count))} item(s) could not be checked — they do not fail this gate. They are NOT reported as up to date. Reasons are shown above.`,
518
+ );
519
+ }
520
+
521
+ /** The run ends with nothing to apply because every remaining plugin is unverified. */
522
+ export function nothingToApplyNotice(unknownPlugins: number): string {
523
+ return warn(
524
+ `Nothing to apply. ${unknownPlugins} plugin(s) could not be checked. They are NOT reported as up to date. Reasons are shown above.`,
525
+ );
526
+ }