claudeup 4.7.0 → 4.8.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.
- package/package.json +1 -1
- package/src/__tests__/gap-fill-versions.test.ts +382 -0
- package/src/prerunner/index.js +31 -17
- package/src/prerunner/index.ts +35 -18
- package/src/services/claude-settings.js +74 -0
- package/src/services/claude-settings.ts +92 -0
- package/src/services/plugin-manager.js +13 -16
- package/src/services/plugin-manager.ts +17 -16
package/package.json
CHANGED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for gapFillInstalledPluginVersions()
|
|
3
|
+
*
|
|
4
|
+
* This function ensures global installedPluginVersions is at least as high as
|
|
5
|
+
* the highest version found across project and local scopes. When a plugin is
|
|
6
|
+
* updated at project scope (e.g. by `claude plugin marketplace update` in a
|
|
7
|
+
* project directory), global may lag behind, causing Claude Code to resolve
|
|
8
|
+
* stale cache paths.
|
|
9
|
+
*
|
|
10
|
+
* Each test uses a real temp filesystem with mocked os.homedir() to exercise
|
|
11
|
+
* the actual read/write paths in claude-settings.ts.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
describe,
|
|
16
|
+
it,
|
|
17
|
+
expect,
|
|
18
|
+
beforeEach,
|
|
19
|
+
afterEach,
|
|
20
|
+
afterAll,
|
|
21
|
+
mock,
|
|
22
|
+
} from "bun:test";
|
|
23
|
+
import fs from "fs-extra";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
|
|
26
|
+
// We need real os functions (tmpdir, etc.) but must mock homedir().
|
|
27
|
+
// Since mock.module is hoisted and replaces the module for ALL consumers,
|
|
28
|
+
// we grab the real os module via require before the mock takes effect.
|
|
29
|
+
const realOs = await import("node:os");
|
|
30
|
+
const realTmpdir = realOs.tmpdir();
|
|
31
|
+
const realHomedir = realOs.homedir();
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Temp directory helpers
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
// Initialize tmpHome eagerly so module-level os.homedir() calls in
|
|
38
|
+
// claude-settings.ts (for INSTALLED_PLUGINS_FILE, KNOWN_MARKETPLACES_FILE)
|
|
39
|
+
// get a valid path during import. The mock closure reads this variable.
|
|
40
|
+
const initTmpHome = fs.mkdtempSync(path.join(realTmpdir, "gfill-init-"));
|
|
41
|
+
let tmpHome: string = initTmpHome;
|
|
42
|
+
let tmpProject: string;
|
|
43
|
+
|
|
44
|
+
/** Write a global settings file at <tmpHome>/.claude/settings.json */
|
|
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 });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Read back global settings after function execution */
|
|
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 {};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Write project settings at <tmpProject>/.claude/settings.json */
|
|
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 });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Write local settings at <tmpProject>/.claude/settings.local.json */
|
|
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
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// Mock os.homedir() so getGlobalClaudeDir() points to our temp dir.
|
|
78
|
+
//
|
|
79
|
+
// Bun hoists mock.module() calls, so this must come before the import of the
|
|
80
|
+
// module under test.
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
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
|
+
};
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Import function under test AFTER mock registration
|
|
93
|
+
const { gapFillInstalledPluginVersions } = await import(
|
|
94
|
+
"../services/claude-settings.js"
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Test suite
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
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
|
+
});
|
|
382
|
+
});
|
package/src/prerunner/index.js
CHANGED
|
@@ -4,10 +4,10 @@ import os from "node:os";
|
|
|
4
4
|
import { UpdateCache } from "../services/update-cache.js";
|
|
5
5
|
import { getAvailablePlugins, clearMarketplaceCache, } from "../services/plugin-manager.js";
|
|
6
6
|
import { runClaude } from "../services/claude-runner.js";
|
|
7
|
-
import { recoverMarketplaceSettings, migrateMarketplaceRename, cleanupExtraKnownMarketplaces, getGlobalEnabledPlugins, getEnabledPlugins, getLocalEnabledPlugins, readGlobalSettings, writeGlobalSettings, saveGlobalInstalledPluginVersion, } from "../services/claude-settings.js";
|
|
7
|
+
import { recoverMarketplaceSettings, migrateMarketplaceRename, cleanupExtraKnownMarketplaces, getGlobalEnabledPlugins, getEnabledPlugins, getLocalEnabledPlugins, readGlobalSettings, writeGlobalSettings, saveGlobalInstalledPluginVersion, gapFillInstalledPluginVersions, } from "../services/claude-settings.js";
|
|
8
8
|
import { parsePluginId } from "../utils/string-utils.js";
|
|
9
9
|
import { defaultMarketplaces } from "../data/marketplaces.js";
|
|
10
|
-
import { updatePlugin, addMarketplace,
|
|
10
|
+
import { updatePlugin, addMarketplace, isClaudeAvailable, } from "../services/claude-cli.js";
|
|
11
11
|
const MARKETPLACES_DIR = path.join(os.homedir(), ".claude", "plugins", "marketplaces");
|
|
12
12
|
/**
|
|
13
13
|
* Collect all unique marketplace names from enabled plugins across all settings scopes.
|
|
@@ -56,10 +56,16 @@ async function getReferencedMarketplaces(projectPath) {
|
|
|
56
56
|
* Check which referenced marketplaces are missing locally and auto-add them.
|
|
57
57
|
* Only adds marketplaces with known repos (from defaultMarketplaces).
|
|
58
58
|
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
59
|
+
* IMPORTANT: Only uses `marketplace add` (never `marketplace update`).
|
|
60
|
+
* Claude Code's `marketplace update` calls cacheMarketplaceFromGit() which
|
|
61
|
+
* deletes the marketplace directory before re-cloning. If the clone fails
|
|
62
|
+
* (network timeout, auth error), the directory stays permanently deleted
|
|
63
|
+
* and ALL plugins from that marketplace break. See:
|
|
64
|
+
* ai-docs/plugin-marketplace-bug-investigation.md
|
|
65
|
+
*
|
|
66
|
+
* Claude Code's own background autoupdate handles marketplace refreshing
|
|
67
|
+
* after session start — claudeup should only recover genuinely missing
|
|
68
|
+
* marketplaces, not trigger additional refresh cycles.
|
|
63
69
|
*/
|
|
64
70
|
async function autoAddMissingMarketplaces(projectPath) {
|
|
65
71
|
const referenced = await getReferencedMarketplaces(projectPath);
|
|
@@ -74,19 +80,11 @@ async function autoAddMissingMarketplaces(projectPath) {
|
|
|
74
80
|
if (!defaultMp?.source.repo)
|
|
75
81
|
continue;
|
|
76
82
|
try {
|
|
77
|
-
|
|
78
|
-
await updateMarketplace(mpName);
|
|
83
|
+
await addMarketplace(defaultMp.source.repo);
|
|
79
84
|
added.push(mpName);
|
|
80
85
|
}
|
|
81
|
-
catch {
|
|
82
|
-
|
|
83
|
-
try {
|
|
84
|
-
await addMarketplace(defaultMp.source.repo);
|
|
85
|
-
added.push(mpName);
|
|
86
|
-
}
|
|
87
|
-
catch (error) {
|
|
88
|
-
console.warn(`⚠ Failed to auto-add marketplace ${mpName}:`, error instanceof Error ? error.message : "Unknown error");
|
|
89
|
-
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
console.warn(`⚠ Failed to auto-add marketplace ${mpName}:`, error instanceof Error ? error.message : "Unknown error");
|
|
90
88
|
}
|
|
91
89
|
}
|
|
92
90
|
return added;
|
|
@@ -186,6 +184,22 @@ export async function prerunClaude(claudeArgs, options = {}) {
|
|
|
186
184
|
if (addedHooks) {
|
|
187
185
|
console.log(`✓ Added tmux-claude-continuity hooks to ~/.claude/settings.json`);
|
|
188
186
|
}
|
|
187
|
+
// STEP 0.7: Gap-fill installedPluginVersions across scopes
|
|
188
|
+
// When a plugin is updated at project/local scope, global may lag behind.
|
|
189
|
+
// Sync the highest version from any scope up to global so Claude Code
|
|
190
|
+
// resolves the latest cached plugin paths at session startup.
|
|
191
|
+
try {
|
|
192
|
+
const gapFilled = await gapFillInstalledPluginVersions(process.cwd());
|
|
193
|
+
if (gapFilled.length > 0) {
|
|
194
|
+
console.log(`✓ Synced ${gapFilled.length} plugin version(s) to global:`);
|
|
195
|
+
for (const { pluginId, oldVersion, newVersion } of gapFilled) {
|
|
196
|
+
console.log(` - ${pluginId}: ${oldVersion} → ${newVersion}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
// Non-fatal: gap-fill is best-effort
|
|
202
|
+
}
|
|
189
203
|
// STEP 1: Check if we should update (time-based cache, or forced)
|
|
190
204
|
const shouldUpdate = options.force || (await cache.shouldCheckForUpdates());
|
|
191
205
|
if (options.force) {
|
package/src/prerunner/index.ts
CHANGED
|
@@ -17,13 +17,13 @@ import {
|
|
|
17
17
|
readGlobalSettings,
|
|
18
18
|
writeGlobalSettings,
|
|
19
19
|
saveGlobalInstalledPluginVersion,
|
|
20
|
+
gapFillInstalledPluginVersions,
|
|
20
21
|
} from "../services/claude-settings.js";
|
|
21
22
|
import { parsePluginId } from "../utils/string-utils.js";
|
|
22
23
|
import { defaultMarketplaces } from "../data/marketplaces.js";
|
|
23
24
|
import {
|
|
24
25
|
updatePlugin,
|
|
25
26
|
addMarketplace,
|
|
26
|
-
updateMarketplace,
|
|
27
27
|
isClaudeAvailable,
|
|
28
28
|
} from "../services/claude-cli.js";
|
|
29
29
|
|
|
@@ -88,10 +88,16 @@ async function getReferencedMarketplaces(
|
|
|
88
88
|
* Check which referenced marketplaces are missing locally and auto-add them.
|
|
89
89
|
* Only adds marketplaces with known repos (from defaultMarketplaces).
|
|
90
90
|
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
91
|
+
* IMPORTANT: Only uses `marketplace add` (never `marketplace update`).
|
|
92
|
+
* Claude Code's `marketplace update` calls cacheMarketplaceFromGit() which
|
|
93
|
+
* deletes the marketplace directory before re-cloning. If the clone fails
|
|
94
|
+
* (network timeout, auth error), the directory stays permanently deleted
|
|
95
|
+
* and ALL plugins from that marketplace break. See:
|
|
96
|
+
* ai-docs/plugin-marketplace-bug-investigation.md
|
|
97
|
+
*
|
|
98
|
+
* Claude Code's own background autoupdate handles marketplace refreshing
|
|
99
|
+
* after session start — claudeup should only recover genuinely missing
|
|
100
|
+
* marketplaces, not trigger additional refresh cycles.
|
|
95
101
|
*/
|
|
96
102
|
async function autoAddMissingMarketplaces(
|
|
97
103
|
projectPath?: string,
|
|
@@ -109,20 +115,13 @@ async function autoAddMissingMarketplaces(
|
|
|
109
115
|
if (!defaultMp?.source.repo) continue;
|
|
110
116
|
|
|
111
117
|
try {
|
|
112
|
-
|
|
113
|
-
await updateMarketplace(mpName);
|
|
118
|
+
await addMarketplace(defaultMp.source.repo);
|
|
114
119
|
added.push(mpName);
|
|
115
|
-
} catch {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
} catch (error) {
|
|
121
|
-
console.warn(
|
|
122
|
-
`⚠ Failed to auto-add marketplace ${mpName}:`,
|
|
123
|
-
error instanceof Error ? error.message : "Unknown error",
|
|
124
|
-
);
|
|
125
|
-
}
|
|
120
|
+
} catch (error) {
|
|
121
|
+
console.warn(
|
|
122
|
+
`⚠ Failed to auto-add marketplace ${mpName}:`,
|
|
123
|
+
error instanceof Error ? error.message : "Unknown error",
|
|
124
|
+
);
|
|
126
125
|
}
|
|
127
126
|
}
|
|
128
127
|
|
|
@@ -256,6 +255,24 @@ export async function prerunClaude(
|
|
|
256
255
|
);
|
|
257
256
|
}
|
|
258
257
|
|
|
258
|
+
// STEP 0.7: Gap-fill installedPluginVersions across scopes
|
|
259
|
+
// When a plugin is updated at project/local scope, global may lag behind.
|
|
260
|
+
// Sync the highest version from any scope up to global so Claude Code
|
|
261
|
+
// resolves the latest cached plugin paths at session startup.
|
|
262
|
+
try {
|
|
263
|
+
const gapFilled = await gapFillInstalledPluginVersions(process.cwd());
|
|
264
|
+
if (gapFilled.length > 0) {
|
|
265
|
+
console.log(
|
|
266
|
+
`✓ Synced ${gapFilled.length} plugin version(s) to global:`,
|
|
267
|
+
);
|
|
268
|
+
for (const { pluginId, oldVersion, newVersion } of gapFilled) {
|
|
269
|
+
console.log(` - ${pluginId}: ${oldVersion} → ${newVersion}`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
} catch {
|
|
273
|
+
// Non-fatal: gap-fill is best-effort
|
|
274
|
+
}
|
|
275
|
+
|
|
259
276
|
// STEP 1: Check if we should update (time-based cache, or forced)
|
|
260
277
|
const shouldUpdate = options.force || (await cache.shouldCheckForUpdates());
|
|
261
278
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "fs-extra";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import os from "node:os";
|
|
4
|
+
import semver from "semver";
|
|
4
5
|
import { parsePluginId } from "../utils/string-utils.js";
|
|
5
6
|
const CLAUDE_DIR = ".claude";
|
|
6
7
|
const SETTINGS_FILE = "settings.json";
|
|
@@ -377,6 +378,79 @@ export async function saveGlobalInstalledPluginVersion(pluginId, version) {
|
|
|
377
378
|
// Update registry for user scope
|
|
378
379
|
await updateInstalledPluginsRegistry(pluginId, version, "user");
|
|
379
380
|
}
|
|
381
|
+
/**
|
|
382
|
+
* Gap-fill: ensure global installedPluginVersions is at least as high as
|
|
383
|
+
* project and local scopes. When a plugin is updated at project scope,
|
|
384
|
+
* global may lag behind, causing Claude Code to resolve stale cache paths.
|
|
385
|
+
*
|
|
386
|
+
* @returns Array of { pluginId, oldVersion, newVersion } for plugins that were bumped
|
|
387
|
+
*/
|
|
388
|
+
export async function gapFillInstalledPluginVersions(projectPath) {
|
|
389
|
+
const globalVersions = await getGlobalInstalledPluginVersions();
|
|
390
|
+
// Collect versions from project and local scopes
|
|
391
|
+
const allVersions = {};
|
|
392
|
+
// Start with global versions
|
|
393
|
+
for (const [id, ver] of Object.entries(globalVersions)) {
|
|
394
|
+
allVersions[id] = [ver];
|
|
395
|
+
}
|
|
396
|
+
// Add project scope versions
|
|
397
|
+
if (projectPath) {
|
|
398
|
+
try {
|
|
399
|
+
const settings = await readSettings(projectPath);
|
|
400
|
+
for (const [id, ver] of Object.entries(settings.installedPluginVersions || {})) {
|
|
401
|
+
if (!allVersions[id])
|
|
402
|
+
allVersions[id] = [];
|
|
403
|
+
allVersions[id].push(ver);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
/* skip unreadable */
|
|
408
|
+
}
|
|
409
|
+
// Add local scope versions
|
|
410
|
+
try {
|
|
411
|
+
const localSettings = await readLocalSettings(projectPath);
|
|
412
|
+
for (const [id, ver] of Object.entries(localSettings.installedPluginVersions || {})) {
|
|
413
|
+
if (!allVersions[id])
|
|
414
|
+
allVersions[id] = [];
|
|
415
|
+
allVersions[id].push(ver);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
catch {
|
|
419
|
+
/* skip unreadable */
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
// Find plugins where global needs bumping
|
|
423
|
+
const bumped = [];
|
|
424
|
+
for (const [pluginId, versions] of Object.entries(allVersions)) {
|
|
425
|
+
// Filter to valid semver versions and sort descending
|
|
426
|
+
const valid = versions.filter((v) => semver.valid(v));
|
|
427
|
+
if (valid.length === 0)
|
|
428
|
+
continue;
|
|
429
|
+
const highest = valid.sort((a, b) => semver.rcompare(a, b))[0];
|
|
430
|
+
const globalVer = globalVersions[pluginId];
|
|
431
|
+
// If global is missing, invalid semver, or lower than the highest, bump it
|
|
432
|
+
if (!globalVer ||
|
|
433
|
+
!semver.valid(globalVer) ||
|
|
434
|
+
semver.lt(globalVer, highest)) {
|
|
435
|
+
bumped.push({
|
|
436
|
+
pluginId,
|
|
437
|
+
oldVersion: globalVer || "(missing)",
|
|
438
|
+
newVersion: highest,
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
// Apply bumps in a single write
|
|
443
|
+
if (bumped.length > 0) {
|
|
444
|
+
const settings = await readGlobalSettings();
|
|
445
|
+
settings.installedPluginVersions =
|
|
446
|
+
settings.installedPluginVersions || {};
|
|
447
|
+
for (const { pluginId, newVersion } of bumped) {
|
|
448
|
+
settings.installedPluginVersions[pluginId] = newVersion;
|
|
449
|
+
}
|
|
450
|
+
await writeGlobalSettings(settings);
|
|
451
|
+
}
|
|
452
|
+
return bumped;
|
|
453
|
+
}
|
|
380
454
|
export async function removeGlobalInstalledPluginVersion(pluginId) {
|
|
381
455
|
const settings = await readGlobalSettings();
|
|
382
456
|
if (settings.installedPluginVersions) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "fs-extra";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import os from "node:os";
|
|
4
|
+
import semver from "semver";
|
|
4
5
|
import type {
|
|
5
6
|
ClaudeSettings,
|
|
6
7
|
ClaudeLocalSettings,
|
|
@@ -544,6 +545,97 @@ export async function saveGlobalInstalledPluginVersion(
|
|
|
544
545
|
await updateInstalledPluginsRegistry(pluginId, version, "user");
|
|
545
546
|
}
|
|
546
547
|
|
|
548
|
+
/**
|
|
549
|
+
* Gap-fill: ensure global installedPluginVersions is at least as high as
|
|
550
|
+
* project and local scopes. When a plugin is updated at project scope,
|
|
551
|
+
* global may lag behind, causing Claude Code to resolve stale cache paths.
|
|
552
|
+
*
|
|
553
|
+
* @returns Array of { pluginId, oldVersion, newVersion } for plugins that were bumped
|
|
554
|
+
*/
|
|
555
|
+
export async function gapFillInstalledPluginVersions(
|
|
556
|
+
projectPath?: string,
|
|
557
|
+
): Promise<Array<{ pluginId: string; oldVersion: string; newVersion: string }>> {
|
|
558
|
+
const globalVersions = await getGlobalInstalledPluginVersions();
|
|
559
|
+
|
|
560
|
+
// Collect versions from project and local scopes
|
|
561
|
+
const allVersions: Record<string, string[]> = {};
|
|
562
|
+
|
|
563
|
+
// Start with global versions
|
|
564
|
+
for (const [id, ver] of Object.entries(globalVersions)) {
|
|
565
|
+
allVersions[id] = [ver];
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// Add project scope versions
|
|
569
|
+
if (projectPath) {
|
|
570
|
+
try {
|
|
571
|
+
const settings = await readSettings(projectPath);
|
|
572
|
+
for (const [id, ver] of Object.entries(
|
|
573
|
+
settings.installedPluginVersions || {},
|
|
574
|
+
)) {
|
|
575
|
+
if (!allVersions[id]) allVersions[id] = [];
|
|
576
|
+
allVersions[id].push(ver);
|
|
577
|
+
}
|
|
578
|
+
} catch {
|
|
579
|
+
/* skip unreadable */
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Add local scope versions
|
|
583
|
+
try {
|
|
584
|
+
const localSettings = await readLocalSettings(projectPath);
|
|
585
|
+
for (const [id, ver] of Object.entries(
|
|
586
|
+
localSettings.installedPluginVersions || {},
|
|
587
|
+
)) {
|
|
588
|
+
if (!allVersions[id]) allVersions[id] = [];
|
|
589
|
+
allVersions[id].push(ver);
|
|
590
|
+
}
|
|
591
|
+
} catch {
|
|
592
|
+
/* skip unreadable */
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Find plugins where global needs bumping
|
|
597
|
+
const bumped: Array<{
|
|
598
|
+
pluginId: string;
|
|
599
|
+
oldVersion: string;
|
|
600
|
+
newVersion: string;
|
|
601
|
+
}> = [];
|
|
602
|
+
|
|
603
|
+
for (const [pluginId, versions] of Object.entries(allVersions)) {
|
|
604
|
+
// Filter to valid semver versions and sort descending
|
|
605
|
+
const valid = versions.filter((v) => semver.valid(v));
|
|
606
|
+
if (valid.length === 0) continue;
|
|
607
|
+
|
|
608
|
+
const highest = valid.sort((a, b) => semver.rcompare(a, b))[0];
|
|
609
|
+
const globalVer = globalVersions[pluginId];
|
|
610
|
+
|
|
611
|
+
// If global is missing, invalid semver, or lower than the highest, bump it
|
|
612
|
+
if (
|
|
613
|
+
!globalVer ||
|
|
614
|
+
!semver.valid(globalVer) ||
|
|
615
|
+
semver.lt(globalVer, highest)
|
|
616
|
+
) {
|
|
617
|
+
bumped.push({
|
|
618
|
+
pluginId,
|
|
619
|
+
oldVersion: globalVer || "(missing)",
|
|
620
|
+
newVersion: highest,
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Apply bumps in a single write
|
|
626
|
+
if (bumped.length > 0) {
|
|
627
|
+
const settings = await readGlobalSettings();
|
|
628
|
+
settings.installedPluginVersions =
|
|
629
|
+
settings.installedPluginVersions || {};
|
|
630
|
+
for (const { pluginId, newVersion } of bumped) {
|
|
631
|
+
settings.installedPluginVersions[pluginId] = newVersion;
|
|
632
|
+
}
|
|
633
|
+
await writeGlobalSettings(settings);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
return bumped;
|
|
637
|
+
}
|
|
638
|
+
|
|
547
639
|
export async function removeGlobalInstalledPluginVersion(
|
|
548
640
|
pluginId: string,
|
|
549
641
|
): Promise<void> {
|
|
@@ -5,7 +5,6 @@ import { getConfiguredMarketplaces, getEnabledPlugins, readSettings, writeSettin
|
|
|
5
5
|
import { defaultMarketplaces } from "../data/marketplaces.js";
|
|
6
6
|
import { scanLocalMarketplaces, repairAllMarketplaces, } from "./local-marketplace.js";
|
|
7
7
|
import { formatMarketplaceName, isValidGitHubRepo, parsePluginId, } from "../utils/string-utils.js";
|
|
8
|
-
import { updateMarketplace } from "./claude-cli.js";
|
|
9
8
|
// Cache for local marketplaces (session-level) - Promise-based to prevent race conditions
|
|
10
9
|
let localMarketplacesPromise = null;
|
|
11
10
|
// Session-level cache for fetched marketplace data (no TTL - persists until explicit refresh)
|
|
@@ -434,9 +433,14 @@ export async function getLocalMarketplacesInfo() {
|
|
|
434
433
|
const autoSyncedMarketplaces = new Set();
|
|
435
434
|
/**
|
|
436
435
|
* If the remote manifest lists plugins that aren't in the local cache,
|
|
437
|
-
* the local clone is stale.
|
|
438
|
-
*
|
|
439
|
-
*
|
|
436
|
+
* the local clone is stale. Log a warning but do NOT trigger a marketplace
|
|
437
|
+
* update from claudeup.
|
|
438
|
+
*
|
|
439
|
+
* Previously this called `updateMarketplace()` which invokes Claude Code's
|
|
440
|
+
* `cacheMarketplaceFromGit()` — a non-atomic delete-then-clone that can
|
|
441
|
+
* permanently delete the marketplace directory on clone failure.
|
|
442
|
+
* Claude Code's own background autoupdate handles marketplace refreshing
|
|
443
|
+
* after session start. See: ai-docs/plugin-marketplace-bug-investigation.md
|
|
440
444
|
*/
|
|
441
445
|
async function autoSyncIfStale(mpName, remotePluginNames, localMarketplaces) {
|
|
442
446
|
if (autoSyncedMarketplaces.has(mpName))
|
|
@@ -445,20 +449,13 @@ async function autoSyncIfStale(mpName, remotePluginNames, localMarketplaces) {
|
|
|
445
449
|
if (!localMp)
|
|
446
450
|
return localMarketplaces;
|
|
447
451
|
const localNames = new Set(localMp.plugins.map((p) => p.name));
|
|
448
|
-
const
|
|
449
|
-
if (
|
|
452
|
+
const missingPlugins = remotePluginNames.filter((name) => !localNames.has(name));
|
|
453
|
+
if (missingPlugins.length === 0)
|
|
450
454
|
return localMarketplaces;
|
|
451
455
|
autoSyncedMarketplaces.add(mpName);
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
localMarketplacesPromise = null;
|
|
456
|
-
return getLocalMarketplaces();
|
|
457
|
-
}
|
|
458
|
-
catch {
|
|
459
|
-
// Update failed (no network, CLI missing, etc.) — continue with stale data
|
|
460
|
-
return localMarketplaces;
|
|
461
|
-
}
|
|
456
|
+
// Log stale state but don't trigger update — Claude Code handles refresh
|
|
457
|
+
console.log(`ℹ Marketplace ${mpName} is stale (missing: ${missingPlugins.join(", ")}). Claude Code will auto-update on next session start.`);
|
|
458
|
+
return localMarketplaces;
|
|
462
459
|
}
|
|
463
460
|
/**
|
|
464
461
|
* Refresh claudeup's internal cache
|
|
@@ -27,8 +27,6 @@ import {
|
|
|
27
27
|
isValidGitHubRepo,
|
|
28
28
|
parsePluginId,
|
|
29
29
|
} from "../utils/string-utils.js";
|
|
30
|
-
import { updateMarketplace } from "./claude-cli.js";
|
|
31
|
-
|
|
32
30
|
// Cache for local marketplaces (session-level) - Promise-based to prevent race conditions
|
|
33
31
|
let localMarketplacesPromise: Promise<Map<string, LocalMarketplace>> | null =
|
|
34
32
|
null;
|
|
@@ -619,9 +617,14 @@ const autoSyncedMarketplaces = new Set<string>();
|
|
|
619
617
|
|
|
620
618
|
/**
|
|
621
619
|
* If the remote manifest lists plugins that aren't in the local cache,
|
|
622
|
-
* the local clone is stale.
|
|
623
|
-
*
|
|
624
|
-
*
|
|
620
|
+
* the local clone is stale. Log a warning but do NOT trigger a marketplace
|
|
621
|
+
* update from claudeup.
|
|
622
|
+
*
|
|
623
|
+
* Previously this called `updateMarketplace()` which invokes Claude Code's
|
|
624
|
+
* `cacheMarketplaceFromGit()` — a non-atomic delete-then-clone that can
|
|
625
|
+
* permanently delete the marketplace directory on clone failure.
|
|
626
|
+
* Claude Code's own background autoupdate handles marketplace refreshing
|
|
627
|
+
* after session start. See: ai-docs/plugin-marketplace-bug-investigation.md
|
|
625
628
|
*/
|
|
626
629
|
async function autoSyncIfStale(
|
|
627
630
|
mpName: string,
|
|
@@ -634,19 +637,17 @@ async function autoSyncIfStale(
|
|
|
634
637
|
if (!localMp) return localMarketplaces;
|
|
635
638
|
|
|
636
639
|
const localNames = new Set(localMp.plugins.map((p) => p.name));
|
|
637
|
-
const
|
|
638
|
-
|
|
640
|
+
const missingPlugins = remotePluginNames.filter(
|
|
641
|
+
(name) => !localNames.has(name),
|
|
642
|
+
);
|
|
643
|
+
if (missingPlugins.length === 0) return localMarketplaces;
|
|
639
644
|
|
|
640
645
|
autoSyncedMarketplaces.add(mpName);
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
} catch {
|
|
647
|
-
// Update failed (no network, CLI missing, etc.) — continue with stale data
|
|
648
|
-
return localMarketplaces;
|
|
649
|
-
}
|
|
646
|
+
// Log stale state but don't trigger update — Claude Code handles refresh
|
|
647
|
+
console.log(
|
|
648
|
+
`ℹ Marketplace ${mpName} is stale (missing: ${missingPlugins.join(", ")}). Claude Code will auto-update on next session start.`,
|
|
649
|
+
);
|
|
650
|
+
return localMarketplaces;
|
|
650
651
|
}
|
|
651
652
|
|
|
652
653
|
export interface RefreshAndRepairResult {
|