claudeup 6.2.0 → 6.3.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 (153) hide show
  1. package/README.md +22 -0
  2. package/bin/claudeup.js +6 -1
  3. package/package.json +6 -6
  4. package/src/__tests__/alias-adopt.test.ts +331 -329
  5. package/src/__tests__/alias-parser.test.ts +384 -376
  6. package/src/__tests__/alias-shell-writer.test.ts +638 -625
  7. package/src/__tests__/alias-store.test.ts +143 -143
  8. package/src/__tests__/catalog-cache-store.test.ts +265 -265
  9. package/src/__tests__/catalog-notice.test.ts +123 -123
  10. package/src/__tests__/cli-apply-seams.test.ts +12 -8
  11. package/src/__tests__/cli-router.test.ts +132 -3
  12. package/src/__tests__/cli-update-view.test.ts +27 -8
  13. package/src/__tests__/community-staleness.test.ts +0 -2
  14. package/src/__tests__/component-badge.test.ts +4 -4
  15. package/src/__tests__/content-drift.test.ts +5 -1
  16. package/src/__tests__/conventions-integration.test.ts +774 -689
  17. package/src/__tests__/conventions-manager.test.ts +1216 -1152
  18. package/src/__tests__/doctor-bins.test.ts +14 -6
  19. package/src/__tests__/doctor.test.ts +438 -425
  20. package/src/__tests__/dual-write-prevention.test.ts +277 -282
  21. package/src/__tests__/enabled-not-installed.test.ts +71 -63
  22. package/src/__tests__/file-locking.test.ts +196 -196
  23. package/src/__tests__/gap-fill-versions.test.ts +318 -311
  24. package/src/__tests__/github-budget.test.ts +170 -164
  25. package/src/__tests__/gitignore-detector.test.ts +160 -152
  26. package/src/__tests__/gitignore-fixer.test.ts +285 -247
  27. package/src/__tests__/gitignore-prerun.test.ts +57 -57
  28. package/src/__tests__/gitignore-resolver.test.ts +173 -146
  29. package/src/__tests__/gitignore-service.test.ts +121 -119
  30. package/src/__tests__/go-module.test.ts +43 -0
  31. package/src/__tests__/install-command.test.ts +3 -3
  32. package/src/__tests__/install-plan.test.ts +13 -7
  33. package/src/__tests__/manifest.test.ts +16 -5
  34. package/src/__tests__/markdown-renderer.test.ts +0 -1
  35. package/src/__tests__/marketplace-badge.test.ts +111 -90
  36. package/src/__tests__/marketplace-version.test.ts +64 -61
  37. package/src/__tests__/plugin-manager-fallback.test.ts +249 -239
  38. package/src/__tests__/plugin-requires.test.ts +55 -1
  39. package/src/__tests__/plugin-setup.test.ts +111 -9
  40. package/src/__tests__/profile-command.test.ts +1 -7
  41. package/src/__tests__/profile-materializer.test.ts +31 -8
  42. package/src/__tests__/profile-sync.test.ts +23 -5
  43. package/src/__tests__/registry-version-resolution.test.ts +61 -47
  44. package/src/__tests__/resolve-executable.test.ts +1 -1
  45. package/src/__tests__/resolver.test.ts +38 -13
  46. package/src/__tests__/scope-action.test.ts +52 -50
  47. package/src/__tests__/scope-squares.test.tsx +4 -5
  48. package/src/__tests__/skill-install-files.test.ts +111 -103
  49. package/src/__tests__/symlink-manager.test.ts +27 -10
  50. package/src/__tests__/theme-env.test.ts +281 -0
  51. package/src/__tests__/theme-resolve.test.ts +380 -0
  52. package/src/__tests__/update-apply.test.ts +1 -3
  53. package/src/__tests__/useGitignoreModal.test.ts +69 -61
  54. package/src/__tests__/version-snapshot.test.ts +183 -165
  55. package/src/cli/ansi.ts +23 -15
  56. package/src/cli/doctor.ts +10 -9
  57. package/src/cli/install.ts +3 -3
  58. package/src/cli/profile.ts +5 -4
  59. package/src/cli/prompt.ts +0 -1
  60. package/src/cli/router.ts +69 -2
  61. package/src/cli/update-view.ts +11 -6
  62. package/src/cli/update.ts +9 -4
  63. package/src/cli/upgrade.ts +6 -4
  64. package/src/data/alias-flags.ts +260 -260
  65. package/src/data/gitignore-defaults.ts +20 -20
  66. package/src/data/gitignore-reasons.ts +86 -92
  67. package/src/data/gitignore-templates.ts +14 -14
  68. package/src/data/predefined-profiles.ts +1 -2
  69. package/src/data/skill-repos.ts +43 -35
  70. package/src/main.tsx +29 -6
  71. package/src/opentui.d.ts +1 -3
  72. package/src/prerunner/index.ts +7 -8
  73. package/src/services/alias-settings.ts +20 -23
  74. package/src/services/alias-shell-writer.ts +800 -803
  75. package/src/services/alias-store.ts +77 -77
  76. package/src/services/claude-settings.ts +27 -18
  77. package/src/services/content-drift.ts +14 -4
  78. package/src/services/conventions-manager.ts +692 -617
  79. package/src/services/doctor.ts +464 -403
  80. package/src/services/gitignore-detector.ts +113 -103
  81. package/src/services/gitignore-fixer.ts +204 -189
  82. package/src/services/gitignore-prerun.ts +26 -26
  83. package/src/services/gitignore-resolver.ts +162 -144
  84. package/src/services/gitignore-service.ts +120 -117
  85. package/src/services/install-plan.ts +4 -4
  86. package/src/services/local-marketplace.ts +7 -5
  87. package/src/services/manifest.ts +1 -1
  88. package/src/services/marketplace-catalog-git.ts +5 -2
  89. package/src/services/marketplace-fetcher.ts +241 -235
  90. package/src/services/plugin-manager.ts +17 -20
  91. package/src/services/plugin-mcp-config.ts +2 -2
  92. package/src/services/plugin-requires.ts +16 -8
  93. package/src/services/plugin-setup.ts +32 -15
  94. package/src/services/plugin-version-check.ts +4 -4
  95. package/src/services/profile-materializer.ts +9 -5
  96. package/src/services/profile-sync.ts +12 -4
  97. package/src/services/profiles.ts +3 -3
  98. package/src/services/resolver.ts +10 -6
  99. package/src/services/settings-manager.ts +82 -46
  100. package/src/services/skills-manager.ts +11 -9
  101. package/src/services/symlink-manager.ts +2 -5
  102. package/src/services/toolchain.ts +3 -3
  103. package/src/services/update-cache.ts +1 -1
  104. package/src/services/update-engine.ts +4 -4
  105. package/src/services/update-plan.ts +9 -11
  106. package/src/services/version-snapshot.ts +88 -88
  107. package/src/types/gitignore.ts +28 -28
  108. package/src/ui/App.tsx +36 -36
  109. package/src/ui/adapters/settingsAdapter.ts +2 -2
  110. package/src/ui/components/CategoryHeader.tsx +1 -1
  111. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  112. package/src/ui/components/ScopeIndicator.tsx +3 -1
  113. package/src/ui/components/ScrollableList.tsx +2 -1
  114. package/src/ui/components/StyledText.tsx +1 -1
  115. package/src/ui/components/layout/FooterHints.tsx +5 -3
  116. package/src/ui/components/layout/Panel.tsx +2 -2
  117. package/src/ui/components/layout/ScreenLayout.tsx +5 -4
  118. package/src/ui/components/modals/InputModal.tsx +7 -1
  119. package/src/ui/components/modals/ModalContainer.tsx +3 -3
  120. package/src/ui/components/modals/SelectModal.tsx +1 -1
  121. package/src/ui/components/primitives/DetailSection.tsx +1 -1
  122. package/src/ui/components/primitives/KeyValueLine.tsx +1 -1
  123. package/src/ui/components/primitives/ScopeSquares.tsx +2 -2
  124. package/src/ui/hooks/index.ts +0 -1
  125. package/src/ui/hooks/useGitignoreModal.ts +1 -5
  126. package/src/ui/hooks/useMismatchModal.ts +4 -10
  127. package/src/ui/renderers/cliToolRenderers.tsx +2 -2
  128. package/src/ui/renderers/gitignoreRenderers.tsx +2 -2
  129. package/src/ui/renderers/mcpRenderers.tsx +1 -1
  130. package/src/ui/renderers/pluginRenderers.tsx +3 -9
  131. package/src/ui/renderers/profileRenderers.tsx +9 -8
  132. package/src/ui/renderers/settingsRenderers.tsx +4 -4
  133. package/src/ui/renderers/skillRenderers.tsx +14 -14
  134. package/src/ui/screens/AliasScreen.tsx +31 -34
  135. package/src/ui/screens/CliToolsScreen.tsx +14 -13
  136. package/src/ui/screens/EnvVarsScreen.tsx +8 -8
  137. package/src/ui/screens/GitignoreScreen.tsx +15 -14
  138. package/src/ui/screens/McpRegistryScreen.tsx +10 -7
  139. package/src/ui/screens/McpScreen.tsx +10 -10
  140. package/src/ui/screens/PluginsScreen.tsx +17 -21
  141. package/src/ui/screens/ProfilesScreen.tsx +25 -24
  142. package/src/ui/screens/SkillsScreen.tsx +16 -14
  143. package/src/ui/state/AppContext.tsx +4 -8
  144. package/src/ui/state/DimensionsContext.tsx +3 -2
  145. package/src/ui/theme-mode.ts +20 -38
  146. package/src/ui/theme-resolve.ts +148 -0
  147. package/src/utils/clipboard.ts +5 -5
  148. package/src/utils/command-utils.ts +2 -2
  149. package/src/utils/config-dir.ts +1 -3
  150. package/src/utils/file-locking.ts +88 -86
  151. package/src/utils/go-module.ts +36 -0
  152. package/src/ui/hooks/useAsyncData.ts +0 -127
  153. package/src/ui/state/AnimationContext.tsx +0 -76
@@ -12,16 +12,16 @@
12
12
  */
13
13
 
14
14
  import {
15
- describe,
16
- it,
17
- expect,
18
- beforeEach,
19
- afterEach,
20
- afterAll,
21
- mock,
15
+ afterAll,
16
+ afterEach,
17
+ beforeEach,
18
+ describe,
19
+ expect,
20
+ it,
21
+ mock,
22
22
  } from "bun:test";
23
- import fs from "fs-extra";
24
23
  import path from "node:path";
24
+ import fs from "fs-extra";
25
25
 
26
26
  // We need real os functions (tmpdir, etc.) but must mock homedir().
27
27
  // Since mock.module is hoisted and replaces the module for ALL consumers,
@@ -43,34 +43,34 @@ let tmpProject: string;
43
43
 
44
44
  /** Write a global settings file at <tmpHome>/.claude/settings.json */
45
45
  async function writeGlobalSettings(settings: Record<string, unknown>) {
46
- const dir = path.join(tmpHome, ".claude");
47
- await fs.ensureDir(dir);
48
- await fs.writeJson(path.join(dir, "settings.json"), settings, { spaces: 2 });
46
+ const dir = path.join(tmpHome, ".claude");
47
+ await fs.ensureDir(dir);
48
+ await fs.writeJson(path.join(dir, "settings.json"), settings, { spaces: 2 });
49
49
  }
50
50
 
51
51
  /** Read back global settings after function execution */
52
52
  async function readGlobalSettingsFromDisk(): Promise<Record<string, unknown>> {
53
- const p = path.join(tmpHome, ".claude", "settings.json");
54
- if (await fs.pathExists(p)) {
55
- return fs.readJson(p);
56
- }
57
- return {};
53
+ const p = path.join(tmpHome, ".claude", "settings.json");
54
+ if (await fs.pathExists(p)) {
55
+ return fs.readJson(p);
56
+ }
57
+ return {};
58
58
  }
59
59
 
60
60
  /** Write project settings at <tmpProject>/.claude/settings.json */
61
61
  async function writeProjectSettings(settings: Record<string, unknown>) {
62
- const dir = path.join(tmpProject, ".claude");
63
- await fs.ensureDir(dir);
64
- await fs.writeJson(path.join(dir, "settings.json"), settings, { spaces: 2 });
62
+ const dir = path.join(tmpProject, ".claude");
63
+ await fs.ensureDir(dir);
64
+ await fs.writeJson(path.join(dir, "settings.json"), settings, { spaces: 2 });
65
65
  }
66
66
 
67
67
  /** Write local settings at <tmpProject>/.claude/settings.local.json */
68
68
  async function writeLocalSettings(settings: Record<string, unknown>) {
69
- const dir = path.join(tmpProject, ".claude");
70
- await fs.ensureDir(dir);
71
- await fs.writeJson(path.join(dir, "settings.local.json"), settings, {
72
- spaces: 2,
73
- });
69
+ const dir = path.join(tmpProject, ".claude");
70
+ await fs.ensureDir(dir);
71
+ await fs.writeJson(path.join(dir, "settings.local.json"), settings, {
72
+ spaces: 2,
73
+ });
74
74
  }
75
75
 
76
76
  // ---------------------------------------------------------------------------
@@ -81,17 +81,17 @@ async function writeLocalSettings(settings: Record<string, unknown>) {
81
81
  // ---------------------------------------------------------------------------
82
82
 
83
83
  mock.module("node:os", () => {
84
- // Build a proxy that preserves all real os functions but overrides homedir
85
- const mocked = { ...realOs, homedir: () => tmpHome };
86
- return {
87
- ...mocked,
88
- default: mocked,
89
- };
84
+ // Build a proxy that preserves all real os functions but overrides homedir
85
+ const mocked = { ...realOs, homedir: () => tmpHome };
86
+ return {
87
+ ...mocked,
88
+ default: mocked,
89
+ };
90
90
  });
91
91
 
92
92
  // Import function under test AFTER mock registration
93
93
  const { gapFillInstalledPluginVersions } = await import(
94
- "../services/claude-settings.js"
94
+ "../services/claude-settings.js"
95
95
  );
96
96
 
97
97
  // ---------------------------------------------------------------------------
@@ -99,284 +99,291 @@ const { gapFillInstalledPluginVersions } = await import(
99
99
  // ---------------------------------------------------------------------------
100
100
 
101
101
  describe("gapFillInstalledPluginVersions", () => {
102
- beforeEach(async () => {
103
- // Create isolated temp dirs for each test
104
- tmpHome = await fs.mkdtemp(path.join(realTmpdir, "gfill-home-"));
105
- tmpProject = await fs.mkdtemp(path.join(realTmpdir, "gfill-proj-"));
106
- // Ensure the global .claude dir exists
107
- await fs.ensureDir(path.join(tmpHome, ".claude"));
108
- });
109
-
110
- afterEach(async () => {
111
- await fs.remove(tmpHome);
112
- await fs.remove(tmpProject);
113
- });
114
-
115
- afterAll(async () => {
116
- // Clean up the initial temp home created for module-level evaluation
117
- await fs.remove(initTmpHome).catch(() => {});
118
- });
119
-
120
- // Scenario 1: "The kanban bug" — project has newer version, global is stale
121
- it("bumps global when project has a newer version (the kanban bug)", async () => {
122
- await writeGlobalSettings({
123
- installedPluginVersions: { "kanban@magus": "1.0.0" },
124
- });
125
- await writeProjectSettings({
126
- installedPluginVersions: { "kanban@magus": "1.3.0" },
127
- });
128
-
129
- const result = await gapFillInstalledPluginVersions(tmpProject);
130
-
131
- expect(result).toEqual([
132
- {
133
- pluginId: "kanban@magus",
134
- oldVersion: "1.0.0",
135
- newVersion: "1.3.0",
136
- },
137
- ]);
138
-
139
- // Verify the file was actually updated
140
- const global = await readGlobalSettingsFromDisk();
141
- expect((global as any).installedPluginVersions["kanban@magus"]).toBe(
142
- "1.3.0",
143
- );
144
- });
145
-
146
- // Scenario 2: Global is already current — no changes needed
147
- it("returns empty array when global is already at the highest version", async () => {
148
- await writeGlobalSettings({
149
- installedPluginVersions: { "kanban@magus": "1.3.0" },
150
- });
151
- await writeProjectSettings({
152
- installedPluginVersions: { "kanban@magus": "1.3.0" },
153
- });
154
-
155
- const result = await gapFillInstalledPluginVersions(tmpProject);
156
-
157
- expect(result).toEqual([]);
158
-
159
- // Verify global was NOT rewritten (check original content is intact)
160
- const global = await readGlobalSettingsFromDisk();
161
- expect((global as any).installedPluginVersions["kanban@magus"]).toBe(
162
- "1.3.0",
163
- );
164
- });
165
-
166
- // Scenario 3: Multiple plugins with mixed staleness
167
- it("handles multiple plugins with mixed staleness correctly", async () => {
168
- await writeGlobalSettings({
169
- installedPluginVersions: {
170
- "a@mp": "1.0.0",
171
- "b@mp": "2.0.0",
172
- },
173
- });
174
- await writeProjectSettings({
175
- installedPluginVersions: {
176
- "a@mp": "2.0.0",
177
- "b@mp": "2.0.0",
178
- "c@mp": "1.0.0",
179
- },
180
- });
181
-
182
- const result = await gapFillInstalledPluginVersions(tmpProject);
183
-
184
- // a@mp should be bumped, b@mp unchanged, c@mp added (missing from global)
185
- const bumped = result.map((r) => r.pluginId).sort();
186
- expect(bumped).toEqual(["a@mp", "c@mp"]);
187
-
188
- const aEntry = result.find((r) => r.pluginId === "a@mp");
189
- expect(aEntry).toEqual({
190
- pluginId: "a@mp",
191
- oldVersion: "1.0.0",
192
- newVersion: "2.0.0",
193
- });
194
-
195
- const cEntry = result.find((r) => r.pluginId === "c@mp");
196
- expect(cEntry).toEqual({
197
- pluginId: "c@mp",
198
- oldVersion: "(missing)",
199
- newVersion: "1.0.0",
200
- });
201
-
202
- // Verify file state
203
- const global = await readGlobalSettingsFromDisk();
204
- const versions = (global as any).installedPluginVersions;
205
- expect(versions["a@mp"]).toBe("2.0.0");
206
- expect(versions["b@mp"]).toBe("2.0.0");
207
- expect(versions["c@mp"]).toBe("1.0.0");
208
- });
209
-
210
- // Scenario 4: Local scope has the highest version
211
- it("uses the highest version across all 3 scopes (local wins)", async () => {
212
- await writeGlobalSettings({
213
- installedPluginVersions: { "x@mp": "1.0.0" },
214
- });
215
- await writeProjectSettings({
216
- installedPluginVersions: { "x@mp": "1.5.0" },
217
- });
218
- await writeLocalSettings({
219
- installedPluginVersions: { "x@mp": "2.0.0" },
220
- });
221
-
222
- const result = await gapFillInstalledPluginVersions(tmpProject);
223
-
224
- expect(result).toEqual([
225
- {
226
- pluginId: "x@mp",
227
- oldVersion: "1.0.0",
228
- newVersion: "2.0.0",
229
- },
230
- ]);
231
-
232
- const global = await readGlobalSettingsFromDisk();
233
- expect((global as any).installedPluginVersions["x@mp"]).toBe("2.0.0");
234
- });
235
-
236
- // Scenario 5: No project path — only global scope
237
- it("returns empty array when called with no projectPath", async () => {
238
- await writeGlobalSettings({
239
- installedPluginVersions: { "a@mp": "1.0.0" },
240
- });
241
-
242
- const result = await gapFillInstalledPluginVersions();
243
-
244
- expect(result).toEqual([]);
245
- });
246
-
247
- // Scenario 6: Idempotency — running twice is a no-op
248
- it("is idempotent — second call returns empty array", async () => {
249
- await writeGlobalSettings({
250
- installedPluginVersions: { "a@mp": "1.0.0" },
251
- });
252
- await writeProjectSettings({
253
- installedPluginVersions: { "a@mp": "2.0.0" },
254
- });
255
-
256
- // First call: should bump
257
- const first = await gapFillInstalledPluginVersions(tmpProject);
258
- expect(first).toHaveLength(1);
259
- expect(first[0].newVersion).toBe("2.0.0");
260
-
261
- // Second call: global is now at 2.0.0, so nothing to do
262
- const second = await gapFillInstalledPluginVersions(tmpProject);
263
- expect(second).toEqual([]);
264
- });
265
-
266
- // Scenario 7: Invalid semver versions are skipped
267
- it("skips invalid semver versions in project scope", async () => {
268
- await writeGlobalSettings({
269
- installedPluginVersions: { "a@mp": "1.0.0" },
270
- });
271
- await writeProjectSettings({
272
- installedPluginVersions: { "a@mp": "not-a-version" },
273
- });
274
-
275
- const result = await gapFillInstalledPluginVersions(tmpProject);
276
-
277
- expect(result).toEqual([]);
278
-
279
- // Global should remain unchanged
280
- const global = await readGlobalSettingsFromDisk();
281
- expect((global as any).installedPluginVersions["a@mp"]).toBe("1.0.0");
282
- });
283
-
284
- // Scenario 8: Project settings file doesn't exist (new project)
285
- it("returns empty array when project settings file does not exist", async () => {
286
- await writeGlobalSettings({
287
- installedPluginVersions: { "a@mp": "1.0.0" },
288
- });
289
-
290
- // Use a path that doesn't have any .claude directory
291
- const nonExistentProject = path.join(tmpProject, "no-such-project");
292
-
293
- const result = await gapFillInstalledPluginVersions(nonExistentProject);
294
-
295
- expect(result).toEqual([]);
296
- });
297
-
298
- // Scenario 9: Global version is higher than project — no downgrade
299
- it("does not downgrade global when global is higher than project", async () => {
300
- await writeGlobalSettings({
301
- installedPluginVersions: { "kanban@magus": "2.0.0" },
302
- });
303
- await writeProjectSettings({
304
- installedPluginVersions: { "kanban@magus": "1.0.0" },
305
- });
306
-
307
- const result = await gapFillInstalledPluginVersions(tmpProject);
308
-
309
- expect(result).toEqual([]);
310
-
311
- // Verify global was NOT downgraded
312
- const global = await readGlobalSettingsFromDisk();
313
- expect((global as any).installedPluginVersions["kanban@magus"]).toBe("2.0.0");
314
- });
315
-
316
- // Scenario 10: Preserves unrelated settings fields during write
317
- it("preserves other global settings fields when bumping versions", async () => {
318
- await writeGlobalSettings({
319
- enabledPlugins: { "kanban@magus": true, "dev@magus": true },
320
- env: { SOME_VAR: "value" },
321
- installedPluginVersions: { "kanban@magus": "1.0.0" },
322
- });
323
- await writeProjectSettings({
324
- installedPluginVersions: { "kanban@magus": "2.0.0" },
325
- });
326
-
327
- const result = await gapFillInstalledPluginVersions(tmpProject);
328
-
329
- expect(result).toHaveLength(1);
330
-
331
- // Verify other fields are preserved
332
- const global = await readGlobalSettingsFromDisk();
333
- expect((global as any).enabledPlugins).toEqual({ "kanban@magus": true, "dev@magus": true });
334
- expect((global as any).env).toEqual({ SOME_VAR: "value" });
335
- expect((global as any).installedPluginVersions["kanban@magus"]).toBe("2.0.0");
336
- });
337
-
338
- // Scenario 11: Invalid semver in GLOBAL scope gets overwritten
339
- it("overwrites corrupted global version when project has valid semver", async () => {
340
- await writeGlobalSettings({
341
- installedPluginVersions: { "kanban@magus": "not-a-version" },
342
- });
343
- await writeProjectSettings({
344
- installedPluginVersions: { "kanban@magus": "1.3.0" },
345
- });
346
-
347
- const result = await gapFillInstalledPluginVersions(tmpProject);
348
-
349
- expect(result).toEqual([
350
- {
351
- pluginId: "kanban@magus",
352
- oldVersion: "not-a-version",
353
- newVersion: "1.3.0",
354
- },
355
- ]);
356
-
357
- const global = await readGlobalSettingsFromDisk();
358
- expect((global as any).installedPluginVersions["kanban@magus"]).toBe(
359
- "1.3.0",
360
- );
361
- });
362
-
363
- // Scenario 12: Both global and project have invalid semver — no crash, no change
364
- it("does nothing when all versions are invalid semver", async () => {
365
- await writeGlobalSettings({
366
- installedPluginVersions: { "kanban@magus": "broken" },
367
- });
368
- await writeProjectSettings({
369
- installedPluginVersions: { "kanban@magus": "also-broken" },
370
- });
371
-
372
- const result = await gapFillInstalledPluginVersions(tmpProject);
373
-
374
- expect(result).toEqual([]);
375
-
376
- // Global unchanged
377
- const global = await readGlobalSettingsFromDisk();
378
- expect((global as any).installedPluginVersions["kanban@magus"]).toBe(
379
- "broken",
380
- );
381
- });
102
+ beforeEach(async () => {
103
+ // Create isolated temp dirs for each test
104
+ tmpHome = await fs.mkdtemp(path.join(realTmpdir, "gfill-home-"));
105
+ tmpProject = await fs.mkdtemp(path.join(realTmpdir, "gfill-proj-"));
106
+ // Ensure the global .claude dir exists
107
+ await fs.ensureDir(path.join(tmpHome, ".claude"));
108
+ });
109
+
110
+ afterEach(async () => {
111
+ await fs.remove(tmpHome);
112
+ await fs.remove(tmpProject);
113
+ });
114
+
115
+ afterAll(async () => {
116
+ // Clean up the initial temp home created for module-level evaluation
117
+ await fs.remove(initTmpHome).catch(() => {});
118
+ });
119
+
120
+ // Scenario 1: "The kanban bug" — project has newer version, global is stale
121
+ it("bumps global when project has a newer version (the kanban bug)", async () => {
122
+ await writeGlobalSettings({
123
+ installedPluginVersions: { "kanban@magus": "1.0.0" },
124
+ });
125
+ await writeProjectSettings({
126
+ installedPluginVersions: { "kanban@magus": "1.3.0" },
127
+ });
128
+
129
+ const result = await gapFillInstalledPluginVersions(tmpProject);
130
+
131
+ expect(result).toEqual([
132
+ {
133
+ pluginId: "kanban@magus",
134
+ oldVersion: "1.0.0",
135
+ newVersion: "1.3.0",
136
+ },
137
+ ]);
138
+
139
+ // Verify the file was actually updated
140
+ const global = await readGlobalSettingsFromDisk();
141
+ expect((global as any).installedPluginVersions["kanban@magus"]).toBe(
142
+ "1.3.0",
143
+ );
144
+ });
145
+
146
+ // Scenario 2: Global is already current — no changes needed
147
+ it("returns empty array when global is already at the highest version", async () => {
148
+ await writeGlobalSettings({
149
+ installedPluginVersions: { "kanban@magus": "1.3.0" },
150
+ });
151
+ await writeProjectSettings({
152
+ installedPluginVersions: { "kanban@magus": "1.3.0" },
153
+ });
154
+
155
+ const result = await gapFillInstalledPluginVersions(tmpProject);
156
+
157
+ expect(result).toEqual([]);
158
+
159
+ // Verify global was NOT rewritten (check original content is intact)
160
+ const global = await readGlobalSettingsFromDisk();
161
+ expect((global as any).installedPluginVersions["kanban@magus"]).toBe(
162
+ "1.3.0",
163
+ );
164
+ });
165
+
166
+ // Scenario 3: Multiple plugins with mixed staleness
167
+ it("handles multiple plugins with mixed staleness correctly", async () => {
168
+ await writeGlobalSettings({
169
+ installedPluginVersions: {
170
+ "a@mp": "1.0.0",
171
+ "b@mp": "2.0.0",
172
+ },
173
+ });
174
+ await writeProjectSettings({
175
+ installedPluginVersions: {
176
+ "a@mp": "2.0.0",
177
+ "b@mp": "2.0.0",
178
+ "c@mp": "1.0.0",
179
+ },
180
+ });
181
+
182
+ const result = await gapFillInstalledPluginVersions(tmpProject);
183
+
184
+ // a@mp should be bumped, b@mp unchanged, c@mp added (missing from global)
185
+ const bumped = result.map((r) => r.pluginId).sort();
186
+ expect(bumped).toEqual(["a@mp", "c@mp"]);
187
+
188
+ const aEntry = result.find((r) => r.pluginId === "a@mp");
189
+ expect(aEntry).toEqual({
190
+ pluginId: "a@mp",
191
+ oldVersion: "1.0.0",
192
+ newVersion: "2.0.0",
193
+ });
194
+
195
+ const cEntry = result.find((r) => r.pluginId === "c@mp");
196
+ expect(cEntry).toEqual({
197
+ pluginId: "c@mp",
198
+ oldVersion: "(missing)",
199
+ newVersion: "1.0.0",
200
+ });
201
+
202
+ // Verify file state
203
+ const global = await readGlobalSettingsFromDisk();
204
+ const versions = (global as any).installedPluginVersions;
205
+ expect(versions["a@mp"]).toBe("2.0.0");
206
+ expect(versions["b@mp"]).toBe("2.0.0");
207
+ expect(versions["c@mp"]).toBe("1.0.0");
208
+ });
209
+
210
+ // Scenario 4: Local scope has the highest version
211
+ it("uses the highest version across all 3 scopes (local wins)", async () => {
212
+ await writeGlobalSettings({
213
+ installedPluginVersions: { "x@mp": "1.0.0" },
214
+ });
215
+ await writeProjectSettings({
216
+ installedPluginVersions: { "x@mp": "1.5.0" },
217
+ });
218
+ await writeLocalSettings({
219
+ installedPluginVersions: { "x@mp": "2.0.0" },
220
+ });
221
+
222
+ const result = await gapFillInstalledPluginVersions(tmpProject);
223
+
224
+ expect(result).toEqual([
225
+ {
226
+ pluginId: "x@mp",
227
+ oldVersion: "1.0.0",
228
+ newVersion: "2.0.0",
229
+ },
230
+ ]);
231
+
232
+ const global = await readGlobalSettingsFromDisk();
233
+ expect((global as any).installedPluginVersions["x@mp"]).toBe("2.0.0");
234
+ });
235
+
236
+ // Scenario 5: No project path — only global scope
237
+ it("returns empty array when called with no projectPath", async () => {
238
+ await writeGlobalSettings({
239
+ installedPluginVersions: { "a@mp": "1.0.0" },
240
+ });
241
+
242
+ const result = await gapFillInstalledPluginVersions();
243
+
244
+ expect(result).toEqual([]);
245
+ });
246
+
247
+ // Scenario 6: Idempotency — running twice is a no-op
248
+ it("is idempotent — second call returns empty array", async () => {
249
+ await writeGlobalSettings({
250
+ installedPluginVersions: { "a@mp": "1.0.0" },
251
+ });
252
+ await writeProjectSettings({
253
+ installedPluginVersions: { "a@mp": "2.0.0" },
254
+ });
255
+
256
+ // First call: should bump
257
+ const first = await gapFillInstalledPluginVersions(tmpProject);
258
+ expect(first).toHaveLength(1);
259
+ expect(first[0].newVersion).toBe("2.0.0");
260
+
261
+ // Second call: global is now at 2.0.0, so nothing to do
262
+ const second = await gapFillInstalledPluginVersions(tmpProject);
263
+ expect(second).toEqual([]);
264
+ });
265
+
266
+ // Scenario 7: Invalid semver versions are skipped
267
+ it("skips invalid semver versions in project scope", async () => {
268
+ await writeGlobalSettings({
269
+ installedPluginVersions: { "a@mp": "1.0.0" },
270
+ });
271
+ await writeProjectSettings({
272
+ installedPluginVersions: { "a@mp": "not-a-version" },
273
+ });
274
+
275
+ const result = await gapFillInstalledPluginVersions(tmpProject);
276
+
277
+ expect(result).toEqual([]);
278
+
279
+ // Global should remain unchanged
280
+ const global = await readGlobalSettingsFromDisk();
281
+ expect((global as any).installedPluginVersions["a@mp"]).toBe("1.0.0");
282
+ });
283
+
284
+ // Scenario 8: Project settings file doesn't exist (new project)
285
+ it("returns empty array when project settings file does not exist", async () => {
286
+ await writeGlobalSettings({
287
+ installedPluginVersions: { "a@mp": "1.0.0" },
288
+ });
289
+
290
+ // Use a path that doesn't have any .claude directory
291
+ const nonExistentProject = path.join(tmpProject, "no-such-project");
292
+
293
+ const result = await gapFillInstalledPluginVersions(nonExistentProject);
294
+
295
+ expect(result).toEqual([]);
296
+ });
297
+
298
+ // Scenario 9: Global version is higher than project — no downgrade
299
+ it("does not downgrade global when global is higher than project", async () => {
300
+ await writeGlobalSettings({
301
+ installedPluginVersions: { "kanban@magus": "2.0.0" },
302
+ });
303
+ await writeProjectSettings({
304
+ installedPluginVersions: { "kanban@magus": "1.0.0" },
305
+ });
306
+
307
+ const result = await gapFillInstalledPluginVersions(tmpProject);
308
+
309
+ expect(result).toEqual([]);
310
+
311
+ // Verify global was NOT downgraded
312
+ const global = await readGlobalSettingsFromDisk();
313
+ expect((global as any).installedPluginVersions["kanban@magus"]).toBe(
314
+ "2.0.0",
315
+ );
316
+ });
317
+
318
+ // Scenario 10: Preserves unrelated settings fields during write
319
+ it("preserves other global settings fields when bumping versions", async () => {
320
+ await writeGlobalSettings({
321
+ enabledPlugins: { "kanban@magus": true, "dev@magus": true },
322
+ env: { SOME_VAR: "value" },
323
+ installedPluginVersions: { "kanban@magus": "1.0.0" },
324
+ });
325
+ await writeProjectSettings({
326
+ installedPluginVersions: { "kanban@magus": "2.0.0" },
327
+ });
328
+
329
+ const result = await gapFillInstalledPluginVersions(tmpProject);
330
+
331
+ expect(result).toHaveLength(1);
332
+
333
+ // Verify other fields are preserved
334
+ const global = await readGlobalSettingsFromDisk();
335
+ expect((global as any).enabledPlugins).toEqual({
336
+ "kanban@magus": true,
337
+ "dev@magus": true,
338
+ });
339
+ expect((global as any).env).toEqual({ SOME_VAR: "value" });
340
+ expect((global as any).installedPluginVersions["kanban@magus"]).toBe(
341
+ "2.0.0",
342
+ );
343
+ });
344
+
345
+ // Scenario 11: Invalid semver in GLOBAL scope gets overwritten
346
+ it("overwrites corrupted global version when project has valid semver", async () => {
347
+ await writeGlobalSettings({
348
+ installedPluginVersions: { "kanban@magus": "not-a-version" },
349
+ });
350
+ await writeProjectSettings({
351
+ installedPluginVersions: { "kanban@magus": "1.3.0" },
352
+ });
353
+
354
+ const result = await gapFillInstalledPluginVersions(tmpProject);
355
+
356
+ expect(result).toEqual([
357
+ {
358
+ pluginId: "kanban@magus",
359
+ oldVersion: "not-a-version",
360
+ newVersion: "1.3.0",
361
+ },
362
+ ]);
363
+
364
+ const global = await readGlobalSettingsFromDisk();
365
+ expect((global as any).installedPluginVersions["kanban@magus"]).toBe(
366
+ "1.3.0",
367
+ );
368
+ });
369
+
370
+ // Scenario 12: Both global and project have invalid semver — no crash, no change
371
+ it("does nothing when all versions are invalid semver", async () => {
372
+ await writeGlobalSettings({
373
+ installedPluginVersions: { "kanban@magus": "broken" },
374
+ });
375
+ await writeProjectSettings({
376
+ installedPluginVersions: { "kanban@magus": "also-broken" },
377
+ });
378
+
379
+ const result = await gapFillInstalledPluginVersions(tmpProject);
380
+
381
+ expect(result).toEqual([]);
382
+
383
+ // Global unchanged
384
+ const global = await readGlobalSettingsFromDisk();
385
+ expect((global as any).installedPluginVersions["kanban@magus"]).toBe(
386
+ "broken",
387
+ );
388
+ });
382
389
  });