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
@@ -1,380 +0,0 @@
1
- /**
2
- * Version tracking tests
3
- *
4
- * The Claude CLI's `plugin install` command does NOT update `installedPluginVersions`
5
- * in settings.json — it only manages `enabledPlugins`. Claudeup must save the version
6
- * itself after a successful CLI install/update.
7
- *
8
- * PluginsScreen: no saveVersionAfterInstall helper (removed in v4.5.0), but each
9
- * action handler calls saveVersionForScope after CLI success.
10
- * Prerunner: calls saveGlobalInstalledPluginVersion after successful updatePlugin,
11
- * but NOT when CLI is unavailable or update fails (no phantom state).
12
- */
13
-
14
- import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
15
- import * as fs from "node:fs";
16
- import * as path from "node:path";
17
- import * as url from "node:url";
18
- import * as actualClaudeCli from "../services/claude-cli.js";
19
- // Real modules captured before the mock.module() overrides below, so the mocks
20
- // can spread the full export surface instead of stripping it.
21
- import * as actualClaudeSettings from "../services/claude-settings.js";
22
- import * as actualPluginManager from "../services/plugin-manager.js";
23
-
24
- // ---------------------------------------------------------------------------
25
- // PART 1: PluginsScreen source-level policy tests
26
- //
27
- // The dual-write calls in PluginsScreen are inside React event handlers.
28
- // Rendering the full TUI component in a unit test would require the opentui
29
- // terminal runtime. Instead we assert the source does NOT contain the
30
- // forbidden call pattern — this is a code-policy test that:
31
- // - FAILS before the fix (6 saveVersionAfterInstall calls follow CLI calls)
32
- // - PASSES after the fix (those calls are removed)
33
- // ---------------------------------------------------------------------------
34
-
35
- const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
36
- const PLUGINS_SCREEN_PATH = path.resolve(
37
- __dirname,
38
- "../ui/screens/PluginsScreen.tsx",
39
- );
40
-
41
- describe("PluginsScreen — no dual-write after CLI operations", () => {
42
- let source: string;
43
-
44
- beforeEach(() => {
45
- source = fs.readFileSync(PLUGINS_SCREEN_PATH, "utf8");
46
- });
47
-
48
- it("does not call saveVersionAfterInstall immediately after cliUpdatePlugin", () => {
49
- // Find every occurrence of cliUpdatePlugin in the source. Each one should
50
- // NOT be immediately followed (within 3 lines) by saveVersionAfterInstall.
51
- const lines = source.split("\n");
52
- const violations: number[] = [];
53
-
54
- for (let i = 0; i < lines.length; i++) {
55
- if (lines[i].includes("cliUpdatePlugin(")) {
56
- // Check the next 3 lines for a dual-write call
57
- for (let j = i + 1; j <= Math.min(i + 3, lines.length - 1); j++) {
58
- if (lines[j].includes("saveVersionAfterInstall(")) {
59
- violations.push(i + 1); // 1-indexed line number
60
- break;
61
- }
62
- }
63
- }
64
- }
65
-
66
- expect(violations).toEqual([]);
67
- });
68
-
69
- it("does not call saveVersionAfterInstall immediately after cliInstallPlugin", () => {
70
- const lines = source.split("\n");
71
- const violations: number[] = [];
72
-
73
- for (let i = 0; i < lines.length; i++) {
74
- if (lines[i].includes("cliInstallPlugin(")) {
75
- for (let j = i + 1; j <= Math.min(i + 3, lines.length - 1); j++) {
76
- if (lines[j].includes("saveVersionAfterInstall(")) {
77
- violations.push(i + 1);
78
- break;
79
- }
80
- }
81
- }
82
- }
83
-
84
- expect(violations).toEqual([]);
85
- });
86
- });
87
-
88
- // ---------------------------------------------------------------------------
89
- // PART 2: Prerunner — saveGlobalInstalledPluginVersion guarded by CLI success
90
- //
91
- // We mock the prerunner's module dependencies so we can call prerunClaude()
92
- // directly and inspect call counts.
93
- // ---------------------------------------------------------------------------
94
-
95
- // Mutable state shared between mock factories and test assertions.
96
- let mockSaveGlobal: ReturnType<typeof mock>;
97
- let mockSaveProject: ReturnType<typeof mock>;
98
- let mockSaveLocal: ReturnType<typeof mock>;
99
- let mockUpdatePlugin: ReturnType<typeof mock>;
100
- let mockRepairPlugin: ReturnType<typeof mock>;
101
- let mockIsClaudeAvailable: ReturnType<typeof mock>;
102
- let mockGetAvailablePlugins: ReturnType<typeof mock>;
103
-
104
- // Spread the REAL modules and override only the functions under test, so this
105
- // file's mock.module doesn't strip other exports the module graph needs (which
106
- // breaks both this file in isolation and — because bun's mock.module is global
107
- // and not restored across files — every later test file). The `import * as`
108
- // bindings are evaluated before these mock.module() calls run, so they capture
109
- // the real modules.
110
- mock.module("../services/claude-settings.js", () => ({
111
- ...actualClaudeSettings,
112
- recoverMarketplaceSettings: mock(() =>
113
- Promise.resolve({ enabledAutoUpdate: [], removed: [], reregistered: [] }),
114
- ),
115
- migrateMarketplaceRename: mock(() =>
116
- Promise.resolve({
117
- projectMigrated: 0,
118
- globalMigrated: 0,
119
- localMigrated: 0,
120
- registryMigrated: 0,
121
- knownMarketplacesMigrated: false,
122
- }),
123
- ),
124
- getGlobalEnabledPlugins: mock(() => Promise.resolve({})),
125
- getEnabledPlugins: mock(() => Promise.resolve({})),
126
- getLocalEnabledPlugins: mock(() => Promise.resolve({})),
127
- saveGlobalInstalledPluginVersion: (...args: unknown[]) =>
128
- mockSaveGlobal(...args),
129
- saveLocalInstalledPluginVersion: (...args: unknown[]) =>
130
- mockSaveLocal(...args),
131
- readGlobalSettings: mock(() => Promise.resolve({ hooks: {} })),
132
- writeGlobalSettings: mock(() => Promise.resolve()),
133
- }));
134
-
135
- mock.module("../services/claude-cli.js", () => ({
136
- ...actualClaudeCli,
137
- updatePlugin: (...args: unknown[]) => mockUpdatePlugin(...args),
138
- repairPlugin: (...args: unknown[]) => mockRepairPlugin(...args),
139
- isClaudeAvailable: (...args: unknown[]) => mockIsClaudeAvailable(...args),
140
- addMarketplace: mock(() => Promise.resolve()),
141
- updateMarketplace: mock(() => Promise.resolve()),
142
- }));
143
-
144
- mock.module("../services/plugin-manager.js", () => ({
145
- ...actualPluginManager,
146
- getAvailablePlugins: (...args: unknown[]) => mockGetAvailablePlugins(...args),
147
- saveInstalledPluginVersion: (...args: unknown[]) => mockSaveProject(...args),
148
- clearMarketplaceCache: mock(() => undefined),
149
- }));
150
-
151
- mock.module("../services/update-cache.js", () => ({
152
- UpdateCache: class {
153
- shouldCheckForUpdates = mock(() => Promise.resolve(true));
154
- saveCheck = mock(() => Promise.resolve());
155
- },
156
- }));
157
-
158
- mock.module("../services/claude-runner.js", () => ({
159
- runClaude: mock(() => Promise.resolve(0)),
160
- }));
161
-
162
- mock.module("../data/marketplaces.js", () => ({
163
- defaultMarketplaces: [],
164
- }));
165
-
166
- mock.module("../utils/string-utils.js", () => ({
167
- parsePluginId: mock((_id: string) => null),
168
- }));
169
-
170
- mock.module("fs-extra", () => ({
171
- default: {
172
- pathExists: mock(() => Promise.resolve(false)),
173
- readJson: mock(() => Promise.resolve({})),
174
- writeJson: mock(() => Promise.resolve()),
175
- ensureDir: mock(() => Promise.resolve()),
176
- },
177
- }));
178
-
179
- // Now import the module under test (after mock.module registrations).
180
- const { prerunClaude } = await import("../prerunner/index.js");
181
-
182
- interface ScopeStatusLike {
183
- enabled: boolean;
184
- version?: string;
185
- }
186
- interface PluginLike {
187
- id: string;
188
- enabled: boolean;
189
- hasUpdate: boolean;
190
- contentStale?: boolean;
191
- installedVersion: string;
192
- version: string;
193
- userScope?: ScopeStatusLike;
194
- projectScope?: ScopeStatusLike;
195
- localScope?: ScopeStatusLike;
196
- }
197
-
198
- /**
199
- * Minimal PluginInfo-like object. The per-scope status matters: the prerunner
200
- * updates the scopes that actually hold an outdated install, so a fixture with
201
- * no scope status describes a plugin installed nowhere and is correctly skipped.
202
- */
203
- function makePlugin(overrides: Partial<PluginLike> = {}): PluginLike {
204
- return {
205
- id: "test-plugin@magus",
206
- enabled: true,
207
- hasUpdate: true,
208
- installedVersion: "1.0.0",
209
- version: "1.1.0",
210
- userScope: { enabled: true, version: "1.0.0" },
211
- ...overrides,
212
- };
213
- }
214
-
215
- describe("prerunner — saveGlobalInstalledPluginVersion call count", () => {
216
- beforeEach(() => {
217
- mockSaveGlobal = mock(() => Promise.resolve());
218
- mockSaveProject = mock(() => Promise.resolve());
219
- mockSaveLocal = mock(() => Promise.resolve());
220
- mockUpdatePlugin = mock(() => Promise.resolve());
221
- mockRepairPlugin = mock(() => Promise.resolve());
222
- mockIsClaudeAvailable = mock(() => Promise.resolve(true));
223
- mockGetAvailablePlugins = mock(() => Promise.resolve([makePlugin()]));
224
- });
225
-
226
- it("calls saveGlobalInstalledPluginVersion once after successful CLI update", async () => {
227
- // CLI is available, updatePlugin succeeds.
228
- // The CLI does NOT update installedPluginVersions, so claudeup must save
229
- // the version after a successful update.
230
- mockIsClaudeAvailable = mock(() => Promise.resolve(true));
231
- mockUpdatePlugin = mock(() => Promise.resolve());
232
-
233
- await prerunClaude(["--help"], { force: true });
234
-
235
- expect(mockSaveGlobal).toHaveBeenCalledTimes(1);
236
- });
237
-
238
- /**
239
- * The prerunner used to call `updatePlugin(id, "user")` unconditionally.
240
- * `hasUpdate` is computed from the CURRENT PROJECT's resolved version, but
241
- * installs live per project in installed_plugins.json — 495 of 496 magus
242
- * installs on the reporter's machine are project-scoped. So it detected
243
- * project drift, updated user scope, reported success, and left the project
244
- * untouched, re-detecting the identical drift on every subsequent run.
245
- */
246
- it("updates the PROJECT scope when that is where the outdated install is", async () => {
247
- mockGetAvailablePlugins = mock(() =>
248
- Promise.resolve([
249
- makePlugin({
250
- userScope: undefined,
251
- projectScope: { enabled: true, version: "1.0.0" },
252
- }),
253
- ]),
254
- );
255
-
256
- await prerunClaude(["--help"], { force: true });
257
-
258
- expect(mockUpdatePlugin).toHaveBeenCalledTimes(1);
259
- expect(mockUpdatePlugin.mock.calls[0]?.[1]).toBe("project");
260
- expect(mockSaveProject).toHaveBeenCalledTimes(1);
261
- expect(mockSaveGlobal).toHaveBeenCalledTimes(0);
262
- });
263
-
264
- it("updates every scope that is outdated, not just the first", async () => {
265
- mockGetAvailablePlugins = mock(() =>
266
- Promise.resolve([
267
- makePlugin({
268
- userScope: { enabled: true, version: "1.0.0" },
269
- projectScope: { enabled: true, version: "1.0.0" },
270
- localScope: { enabled: true, version: "1.1.0" }, // already current
271
- }),
272
- ]),
273
- );
274
-
275
- await prerunClaude(["--help"], { force: true });
276
-
277
- const scopes = mockUpdatePlugin.mock.calls
278
- .map((c: unknown[]) => c[1])
279
- .sort();
280
- expect(scopes).toEqual(["project", "user"]);
281
- expect(mockSaveLocal).toHaveBeenCalledTimes(0);
282
- });
283
-
284
- /**
285
- * A content-stale plugin has no version bump to install over, and
286
- * `claude plugin install` no-ops on "already installed" (verified against the
287
- * real marketplace in autotest/plugin-system/probe-05). The prerunner must
288
- * therefore repair — uninstall+install — not update.
289
- */
290
- it("repairs a content-stale plugin instead of updating it", async () => {
291
- mockGetAvailablePlugins = mock(() =>
292
- Promise.resolve([
293
- makePlugin({
294
- hasUpdate: false,
295
- contentStale: true,
296
- installedVersion: "1.0.0",
297
- version: "1.0.0",
298
- userScope: undefined,
299
- projectScope: { enabled: true, version: "1.0.0" },
300
- }),
301
- ]),
302
- );
303
-
304
- await prerunClaude(["--help"], { force: true });
305
-
306
- expect(mockRepairPlugin).toHaveBeenCalledTimes(1);
307
- expect(mockRepairPlugin.mock.calls[0]?.[1]).toBe("project");
308
- expect(mockUpdatePlugin).toHaveBeenCalledTimes(0);
309
- });
310
-
311
- it("prefers a real version update over a repair when both apply", async () => {
312
- // A pending version bump reinstalls the files anyway, so repairing as well
313
- // would double the work and the uninstall window.
314
- mockGetAvailablePlugins = mock(() =>
315
- Promise.resolve([makePlugin({ hasUpdate: true, contentStale: true })]),
316
- );
317
-
318
- await prerunClaude(["--help"], { force: true });
319
-
320
- expect(mockUpdatePlugin).toHaveBeenCalledTimes(1);
321
- expect(mockRepairPlugin).toHaveBeenCalledTimes(0);
322
- });
323
-
324
- it("does not repair when the CLI is unavailable", async () => {
325
- mockIsClaudeAvailable = mock(() => Promise.resolve(false));
326
- mockGetAvailablePlugins = mock(() =>
327
- Promise.resolve([
328
- makePlugin({
329
- hasUpdate: false,
330
- contentStale: true,
331
- version: "1.0.0",
332
- projectScope: { enabled: true, version: "1.0.0" },
333
- }),
334
- ]),
335
- );
336
-
337
- await prerunClaude(["--help"], { force: true });
338
-
339
- expect(mockRepairPlugin).toHaveBeenCalledTimes(0);
340
- });
341
-
342
- it("does nothing when no scope holds an outdated install", async () => {
343
- // hasUpdate can be true from a stale settings base while every real
344
- // install is already current. Updating "somewhere" would be a guess.
345
- mockGetAvailablePlugins = mock(() =>
346
- Promise.resolve([
347
- makePlugin({
348
- userScope: { enabled: true, version: "1.1.0" },
349
- projectScope: undefined,
350
- }),
351
- ]),
352
- );
353
-
354
- await prerunClaude(["--help"], { force: true });
355
-
356
- expect(mockUpdatePlugin).toHaveBeenCalledTimes(0);
357
- expect(mockSaveGlobal).toHaveBeenCalledTimes(0);
358
- });
359
-
360
- it("does NOT call saveGlobalInstalledPluginVersion when CLI is unavailable", async () => {
361
- // CLI unavailable — we must NOT write phantom state.
362
- mockIsClaudeAvailable = mock(() => Promise.resolve(false));
363
-
364
- await prerunClaude(["--help"], { force: true });
365
-
366
- expect(mockSaveGlobal).toHaveBeenCalledTimes(0);
367
- });
368
-
369
- it("does NOT call saveGlobalInstalledPluginVersion when CLI updatePlugin throws", async () => {
370
- // CLI available but updatePlugin fails — must NOT write phantom state.
371
- mockIsClaudeAvailable = mock(() => Promise.resolve(true));
372
- mockUpdatePlugin = mock(() =>
373
- Promise.reject(new Error("CLI update failed")),
374
- );
375
-
376
- await prerunClaude(["--help"], { force: true });
377
-
378
- expect(mockSaveGlobal).toHaveBeenCalledTimes(0);
379
- });
380
- });