claudeup 4.31.0 → 4.32.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.
- package/package.json +4 -4
- package/src/__tests__/content-drift.test.ts +152 -0
- package/src/__tests__/dual-write-prevention.test.ts +151 -8
- package/src/__tests__/marketplace-refresh.test.ts +7 -2
- package/src/__tests__/moved-marketplace.test.ts +87 -0
- package/src/__tests__/scope-action.test.ts +82 -0
- package/src/__tests__/version-snapshot.test.ts +85 -0
- package/src/prerunner/index.ts +152 -6
- package/src/services/claude-cli.ts +31 -0
- package/src/services/claude-settings.ts +17 -1
- package/src/services/content-drift.ts +121 -0
- package/src/services/marketplace-refresh.ts +15 -3
- package/src/services/plugin-manager.ts +144 -2
- package/src/services/version-snapshot.ts +57 -8
- package/src/ui/renderers/pluginRenderers.tsx +116 -52
- package/src/ui/screens/PluginsScreen.tsx +100 -45
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claudeup",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.32.1",
|
|
4
4
|
"description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/main.tsx",
|
|
@@ -64,8 +64,8 @@
|
|
|
64
64
|
"typescript": "^5.6.3"
|
|
65
65
|
},
|
|
66
66
|
"optionalDependencies": {
|
|
67
|
-
"claudeup-darwin-arm64": "4.
|
|
68
|
-
"claudeup-darwin-x64": "4.
|
|
69
|
-
"claudeup-linux-x64": "4.
|
|
67
|
+
"claudeup-darwin-arm64": "4.32.1",
|
|
68
|
+
"claudeup-darwin-x64": "4.32.1",
|
|
69
|
+
"claudeup-linux-x64": "4.32.1"
|
|
70
70
|
}
|
|
71
71
|
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content drift: a plugin whose files changed without its version changing.
|
|
3
|
+
*
|
|
4
|
+
* Hermetic — builds a real git marketplace under a temp CLAUDE_CONFIG_DIR.
|
|
5
|
+
* Real git is used deliberately: the detection IS a git question, and mocking
|
|
6
|
+
* it would only test the mock.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
10
|
+
import { spawnSync } from "node:child_process";
|
|
11
|
+
import { promises as fs } from "node:fs";
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import {
|
|
15
|
+
clearContentDriftCache,
|
|
16
|
+
hasContentDrift,
|
|
17
|
+
} from "../services/content-drift.js";
|
|
18
|
+
|
|
19
|
+
let configDir: string;
|
|
20
|
+
let prevConfigDir: string | undefined;
|
|
21
|
+
let repo: string;
|
|
22
|
+
|
|
23
|
+
const git = (args: string[]) =>
|
|
24
|
+
spawnSync("git", args, { cwd: repo, encoding: "utf-8" });
|
|
25
|
+
|
|
26
|
+
const head = () => git(["rev-parse", "HEAD"]).stdout.trim();
|
|
27
|
+
|
|
28
|
+
beforeEach(async () => {
|
|
29
|
+
prevConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
|
30
|
+
configDir = await fs.mkdtemp(path.join(os.tmpdir(), "drift-"));
|
|
31
|
+
process.env.CLAUDE_CONFIG_DIR = configDir;
|
|
32
|
+
clearContentDriftCache();
|
|
33
|
+
|
|
34
|
+
repo = path.join(configDir, "plugins", "marketplaces", "probemp");
|
|
35
|
+
await fs.mkdir(path.join(repo, "plugins", "alpha", "skills"), {
|
|
36
|
+
recursive: true,
|
|
37
|
+
});
|
|
38
|
+
await fs.mkdir(path.join(repo, "plugins", "beta"), { recursive: true });
|
|
39
|
+
await fs.writeFile(path.join(repo, "plugins", "alpha", "skills", "s.md"), "v1");
|
|
40
|
+
await fs.writeFile(path.join(repo, "plugins", "beta", "readme.md"), "v1");
|
|
41
|
+
|
|
42
|
+
git(["init", "-q", "-b", "main"]);
|
|
43
|
+
git(["config", "user.email", "probe@example.invalid"]);
|
|
44
|
+
git(["config", "user.name", "probe"]);
|
|
45
|
+
git(["add", "-A"]);
|
|
46
|
+
git(["commit", "-q", "-m", "release 1.0.0"]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
afterEach(async () => {
|
|
50
|
+
if (prevConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
|
|
51
|
+
else process.env.CLAUDE_CONFIG_DIR = prevConfigDir;
|
|
52
|
+
await fs.rm(configDir, { recursive: true, force: true });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("hasContentDrift", () => {
|
|
56
|
+
test("no drift when the plugin's files are untouched", async () => {
|
|
57
|
+
const sha = head();
|
|
58
|
+
expect(
|
|
59
|
+
await hasContentDrift({
|
|
60
|
+
marketplace: "probemp",
|
|
61
|
+
pluginName: "alpha",
|
|
62
|
+
installedSha: sha,
|
|
63
|
+
}),
|
|
64
|
+
).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("drift when the plugin's files change under an unchanged version", async () => {
|
|
68
|
+
const sha = head();
|
|
69
|
+
// Republish: same version, different content — what publish-dist.sh does.
|
|
70
|
+
await fs.writeFile(
|
|
71
|
+
path.join(repo, "plugins", "alpha", "skills", "s.md"),
|
|
72
|
+
"v2 — different content, same version",
|
|
73
|
+
);
|
|
74
|
+
git(["add", "-A"]);
|
|
75
|
+
git(["commit", "-q", "-m", "republish 1.0.0"]);
|
|
76
|
+
|
|
77
|
+
expect(
|
|
78
|
+
await hasContentDrift({
|
|
79
|
+
marketplace: "probemp",
|
|
80
|
+
pluginName: "alpha",
|
|
81
|
+
installedSha: sha,
|
|
82
|
+
}),
|
|
83
|
+
).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("a change to ANOTHER plugin does not flag this one", async () => {
|
|
87
|
+
// The reason this compares the plugin subtree and not the marketplace
|
|
88
|
+
// HEAD: any commit to any plugin would otherwise flag every plugin.
|
|
89
|
+
const sha = head();
|
|
90
|
+
await fs.writeFile(path.join(repo, "plugins", "beta", "readme.md"), "v2");
|
|
91
|
+
git(["add", "-A"]);
|
|
92
|
+
git(["commit", "-q", "-m", "unrelated change"]);
|
|
93
|
+
|
|
94
|
+
expect(
|
|
95
|
+
await hasContentDrift({
|
|
96
|
+
marketplace: "probemp",
|
|
97
|
+
pluginName: "alpha",
|
|
98
|
+
installedSha: sha,
|
|
99
|
+
}),
|
|
100
|
+
).toBe(false);
|
|
101
|
+
expect(
|
|
102
|
+
await hasContentDrift({
|
|
103
|
+
marketplace: "probemp",
|
|
104
|
+
pluginName: "beta",
|
|
105
|
+
installedSha: sha,
|
|
106
|
+
}),
|
|
107
|
+
).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("deleting a skill counts as drift", async () => {
|
|
111
|
+
// The live case: dev@magus 3.0.1 lost tui-progress/SKILL.md while still
|
|
112
|
+
// declaring 3.0.1, so the version compare reported "up to date".
|
|
113
|
+
const sha = head();
|
|
114
|
+
await fs.rm(path.join(repo, "plugins", "alpha", "skills", "s.md"));
|
|
115
|
+
git(["add", "-A"]);
|
|
116
|
+
git(["commit", "-q", "-m", "drop a skill"]);
|
|
117
|
+
|
|
118
|
+
expect(
|
|
119
|
+
await hasContentDrift({
|
|
120
|
+
marketplace: "probemp",
|
|
121
|
+
pluginName: "alpha",
|
|
122
|
+
installedSha: sha,
|
|
123
|
+
}),
|
|
124
|
+
).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("stays silent when it cannot answer honestly", async () => {
|
|
128
|
+
// No recorded sha, unknown sha (force-push/shallow clone), and unknown
|
|
129
|
+
// marketplace must all report false rather than nag for a reinstall.
|
|
130
|
+
expect(
|
|
131
|
+
await hasContentDrift({
|
|
132
|
+
marketplace: "probemp",
|
|
133
|
+
pluginName: "alpha",
|
|
134
|
+
installedSha: undefined,
|
|
135
|
+
}),
|
|
136
|
+
).toBe(false);
|
|
137
|
+
expect(
|
|
138
|
+
await hasContentDrift({
|
|
139
|
+
marketplace: "probemp",
|
|
140
|
+
pluginName: "alpha",
|
|
141
|
+
installedSha: "0".repeat(40),
|
|
142
|
+
}),
|
|
143
|
+
).toBe(false);
|
|
144
|
+
expect(
|
|
145
|
+
await hasContentDrift({
|
|
146
|
+
marketplace: "does-not-exist",
|
|
147
|
+
pluginName: "alpha",
|
|
148
|
+
installedSha: head(),
|
|
149
|
+
}),
|
|
150
|
+
).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -94,7 +94,10 @@ describe("PluginsScreen — no dual-write after CLI operations", () => {
|
|
|
94
94
|
|
|
95
95
|
// Mutable state shared between mock factories and test assertions.
|
|
96
96
|
let mockSaveGlobal: ReturnType<typeof mock>;
|
|
97
|
+
let mockSaveProject: ReturnType<typeof mock>;
|
|
98
|
+
let mockSaveLocal: ReturnType<typeof mock>;
|
|
97
99
|
let mockUpdatePlugin: ReturnType<typeof mock>;
|
|
100
|
+
let mockRepairPlugin: ReturnType<typeof mock>;
|
|
98
101
|
let mockIsClaudeAvailable: ReturnType<typeof mock>;
|
|
99
102
|
let mockGetAvailablePlugins: ReturnType<typeof mock>;
|
|
100
103
|
|
|
@@ -123,6 +126,8 @@ mock.module("../services/claude-settings.js", () => ({
|
|
|
123
126
|
getLocalEnabledPlugins: mock(() => Promise.resolve({})),
|
|
124
127
|
saveGlobalInstalledPluginVersion: (...args: unknown[]) =>
|
|
125
128
|
mockSaveGlobal(...args),
|
|
129
|
+
saveLocalInstalledPluginVersion: (...args: unknown[]) =>
|
|
130
|
+
mockSaveLocal(...args),
|
|
126
131
|
readGlobalSettings: mock(() => Promise.resolve({ hooks: {} })),
|
|
127
132
|
writeGlobalSettings: mock(() => Promise.resolve()),
|
|
128
133
|
}));
|
|
@@ -130,6 +135,7 @@ mock.module("../services/claude-settings.js", () => ({
|
|
|
130
135
|
mock.module("../services/claude-cli.js", () => ({
|
|
131
136
|
...actualClaudeCli,
|
|
132
137
|
updatePlugin: (...args: unknown[]) => mockUpdatePlugin(...args),
|
|
138
|
+
repairPlugin: (...args: unknown[]) => mockRepairPlugin(...args),
|
|
133
139
|
isClaudeAvailable: (...args: unknown[]) => mockIsClaudeAvailable(...args),
|
|
134
140
|
addMarketplace: mock(() => Promise.resolve()),
|
|
135
141
|
updateMarketplace: mock(() => Promise.resolve()),
|
|
@@ -139,6 +145,7 @@ mock.module("../services/plugin-manager.js", () => ({
|
|
|
139
145
|
...actualPluginManager,
|
|
140
146
|
getAvailablePlugins: (...args: unknown[]) =>
|
|
141
147
|
mockGetAvailablePlugins(...args),
|
|
148
|
+
saveInstalledPluginVersion: (...args: unknown[]) => mockSaveProject(...args),
|
|
142
149
|
clearMarketplaceCache: mock(() => undefined),
|
|
143
150
|
}));
|
|
144
151
|
|
|
@@ -173,26 +180,35 @@ mock.module("fs-extra", () => ({
|
|
|
173
180
|
// Now import the module under test (after mock.module registrations).
|
|
174
181
|
const { prerunClaude } = await import("../prerunner/index.js");
|
|
175
182
|
|
|
176
|
-
|
|
177
|
-
function makePlugin(overrides: Partial<{
|
|
178
|
-
id: string;
|
|
183
|
+
interface ScopeStatusLike {
|
|
179
184
|
enabled: boolean;
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
}> = {}): {
|
|
185
|
+
version?: string;
|
|
186
|
+
}
|
|
187
|
+
interface PluginLike {
|
|
184
188
|
id: string;
|
|
185
189
|
enabled: boolean;
|
|
186
190
|
hasUpdate: boolean;
|
|
191
|
+
contentStale?: boolean;
|
|
187
192
|
installedVersion: string;
|
|
188
193
|
version: string;
|
|
189
|
-
|
|
194
|
+
userScope?: ScopeStatusLike;
|
|
195
|
+
projectScope?: ScopeStatusLike;
|
|
196
|
+
localScope?: ScopeStatusLike;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Minimal PluginInfo-like object. The per-scope status matters: the prerunner
|
|
201
|
+
* updates the scopes that actually hold an outdated install, so a fixture with
|
|
202
|
+
* no scope status describes a plugin installed nowhere and is correctly skipped.
|
|
203
|
+
*/
|
|
204
|
+
function makePlugin(overrides: Partial<PluginLike> = {}): PluginLike {
|
|
190
205
|
return {
|
|
191
206
|
id: "test-plugin@magus",
|
|
192
207
|
enabled: true,
|
|
193
208
|
hasUpdate: true,
|
|
194
209
|
installedVersion: "1.0.0",
|
|
195
210
|
version: "1.1.0",
|
|
211
|
+
userScope: { enabled: true, version: "1.0.0" },
|
|
196
212
|
...overrides,
|
|
197
213
|
};
|
|
198
214
|
}
|
|
@@ -200,7 +216,10 @@ function makePlugin(overrides: Partial<{
|
|
|
200
216
|
describe("prerunner — saveGlobalInstalledPluginVersion call count", () => {
|
|
201
217
|
beforeEach(() => {
|
|
202
218
|
mockSaveGlobal = mock(() => Promise.resolve());
|
|
219
|
+
mockSaveProject = mock(() => Promise.resolve());
|
|
220
|
+
mockSaveLocal = mock(() => Promise.resolve());
|
|
203
221
|
mockUpdatePlugin = mock(() => Promise.resolve());
|
|
222
|
+
mockRepairPlugin = mock(() => Promise.resolve());
|
|
204
223
|
mockIsClaudeAvailable = mock(() => Promise.resolve(true));
|
|
205
224
|
mockGetAvailablePlugins = mock(() =>
|
|
206
225
|
Promise.resolve([makePlugin()]),
|
|
@@ -219,6 +238,130 @@ describe("prerunner — saveGlobalInstalledPluginVersion call count", () => {
|
|
|
219
238
|
expect(mockSaveGlobal).toHaveBeenCalledTimes(1);
|
|
220
239
|
});
|
|
221
240
|
|
|
241
|
+
/**
|
|
242
|
+
* The prerunner used to call `updatePlugin(id, "user")` unconditionally.
|
|
243
|
+
* `hasUpdate` is computed from the CURRENT PROJECT's resolved version, but
|
|
244
|
+
* installs live per project in installed_plugins.json — 495 of 496 magus
|
|
245
|
+
* installs on the reporter's machine are project-scoped. So it detected
|
|
246
|
+
* project drift, updated user scope, reported success, and left the project
|
|
247
|
+
* untouched, re-detecting the identical drift on every subsequent run.
|
|
248
|
+
*/
|
|
249
|
+
it("updates the PROJECT scope when that is where the outdated install is", async () => {
|
|
250
|
+
mockGetAvailablePlugins = mock(() =>
|
|
251
|
+
Promise.resolve([
|
|
252
|
+
makePlugin({
|
|
253
|
+
userScope: undefined,
|
|
254
|
+
projectScope: { enabled: true, version: "1.0.0" },
|
|
255
|
+
}),
|
|
256
|
+
]),
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
await prerunClaude(["--help"], { force: true });
|
|
260
|
+
|
|
261
|
+
expect(mockUpdatePlugin).toHaveBeenCalledTimes(1);
|
|
262
|
+
expect(mockUpdatePlugin.mock.calls[0]?.[1]).toBe("project");
|
|
263
|
+
expect(mockSaveProject).toHaveBeenCalledTimes(1);
|
|
264
|
+
expect(mockSaveGlobal).toHaveBeenCalledTimes(0);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("updates every scope that is outdated, not just the first", async () => {
|
|
268
|
+
mockGetAvailablePlugins = mock(() =>
|
|
269
|
+
Promise.resolve([
|
|
270
|
+
makePlugin({
|
|
271
|
+
userScope: { enabled: true, version: "1.0.0" },
|
|
272
|
+
projectScope: { enabled: true, version: "1.0.0" },
|
|
273
|
+
localScope: { enabled: true, version: "1.1.0" }, // already current
|
|
274
|
+
}),
|
|
275
|
+
]),
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
await prerunClaude(["--help"], { force: true });
|
|
279
|
+
|
|
280
|
+
const scopes = mockUpdatePlugin.mock.calls
|
|
281
|
+
.map((c: unknown[]) => c[1])
|
|
282
|
+
.sort();
|
|
283
|
+
expect(scopes).toEqual(["project", "user"]);
|
|
284
|
+
expect(mockSaveLocal).toHaveBeenCalledTimes(0);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* A content-stale plugin has no version bump to install over, and
|
|
289
|
+
* `claude plugin install` no-ops on "already installed" (verified against the
|
|
290
|
+
* real marketplace in autotest/plugin-system/probe-05). The prerunner must
|
|
291
|
+
* therefore repair — uninstall+install — not update.
|
|
292
|
+
*/
|
|
293
|
+
it("repairs a content-stale plugin instead of updating it", async () => {
|
|
294
|
+
mockGetAvailablePlugins = mock(() =>
|
|
295
|
+
Promise.resolve([
|
|
296
|
+
makePlugin({
|
|
297
|
+
hasUpdate: false,
|
|
298
|
+
contentStale: true,
|
|
299
|
+
installedVersion: "1.0.0",
|
|
300
|
+
version: "1.0.0",
|
|
301
|
+
userScope: undefined,
|
|
302
|
+
projectScope: { enabled: true, version: "1.0.0" },
|
|
303
|
+
}),
|
|
304
|
+
]),
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
await prerunClaude(["--help"], { force: true });
|
|
308
|
+
|
|
309
|
+
expect(mockRepairPlugin).toHaveBeenCalledTimes(1);
|
|
310
|
+
expect(mockRepairPlugin.mock.calls[0]?.[1]).toBe("project");
|
|
311
|
+
expect(mockUpdatePlugin).toHaveBeenCalledTimes(0);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("prefers a real version update over a repair when both apply", async () => {
|
|
315
|
+
// A pending version bump reinstalls the files anyway, so repairing as well
|
|
316
|
+
// would double the work and the uninstall window.
|
|
317
|
+
mockGetAvailablePlugins = mock(() =>
|
|
318
|
+
Promise.resolve([
|
|
319
|
+
makePlugin({ hasUpdate: true, contentStale: true }),
|
|
320
|
+
]),
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
await prerunClaude(["--help"], { force: true });
|
|
324
|
+
|
|
325
|
+
expect(mockUpdatePlugin).toHaveBeenCalledTimes(1);
|
|
326
|
+
expect(mockRepairPlugin).toHaveBeenCalledTimes(0);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("does not repair when the CLI is unavailable", async () => {
|
|
330
|
+
mockIsClaudeAvailable = mock(() => Promise.resolve(false));
|
|
331
|
+
mockGetAvailablePlugins = mock(() =>
|
|
332
|
+
Promise.resolve([
|
|
333
|
+
makePlugin({
|
|
334
|
+
hasUpdate: false,
|
|
335
|
+
contentStale: true,
|
|
336
|
+
version: "1.0.0",
|
|
337
|
+
projectScope: { enabled: true, version: "1.0.0" },
|
|
338
|
+
}),
|
|
339
|
+
]),
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
await prerunClaude(["--help"], { force: true });
|
|
343
|
+
|
|
344
|
+
expect(mockRepairPlugin).toHaveBeenCalledTimes(0);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("does nothing when no scope holds an outdated install", async () => {
|
|
348
|
+
// hasUpdate can be true from a stale settings base while every real
|
|
349
|
+
// install is already current. Updating "somewhere" would be a guess.
|
|
350
|
+
mockGetAvailablePlugins = mock(() =>
|
|
351
|
+
Promise.resolve([
|
|
352
|
+
makePlugin({
|
|
353
|
+
userScope: { enabled: true, version: "1.1.0" },
|
|
354
|
+
projectScope: undefined,
|
|
355
|
+
}),
|
|
356
|
+
]),
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
await prerunClaude(["--help"], { force: true });
|
|
360
|
+
|
|
361
|
+
expect(mockUpdatePlugin).toHaveBeenCalledTimes(0);
|
|
362
|
+
expect(mockSaveGlobal).toHaveBeenCalledTimes(0);
|
|
363
|
+
});
|
|
364
|
+
|
|
222
365
|
it("does NOT call saveGlobalInstalledPluginVersion when CLI is unavailable", async () => {
|
|
223
366
|
// CLI unavailable — we must NOT write phantom state.
|
|
224
367
|
mockIsClaudeAvailable = mock(() => Promise.resolve(false));
|
|
@@ -214,14 +214,19 @@ describe("refreshRegisteredMarketplaces — selection & skips", () => {
|
|
|
214
214
|
expect(r.skipped).toEqual(["local"]);
|
|
215
215
|
});
|
|
216
216
|
|
|
217
|
-
it("
|
|
217
|
+
it("reports a marketplace with autoUpdate === false separately from ordinary skips", async () => {
|
|
218
|
+
// Not lumped into `skipped`: opting out here freezes the catalog, so the
|
|
219
|
+
// marketplace's plugins read as up to date forever. The caller has to be
|
|
220
|
+
// able to tell the user, which it cannot do if this looks like "no clone
|
|
221
|
+
// on disk". A real magus clone sat 11 days behind exactly this way.
|
|
218
222
|
installClone("magus");
|
|
219
223
|
publishV2();
|
|
220
224
|
configured = { magus: gh() };
|
|
221
225
|
autoUpdate = { magus: false };
|
|
222
226
|
const r = await refreshRegisteredMarketplaces();
|
|
223
227
|
expect(r.refreshed).toEqual([]);
|
|
224
|
-
expect(r.
|
|
228
|
+
expect(r.autoUpdateDisabled).toEqual(["magus"]);
|
|
229
|
+
expect(r.skipped).toEqual([]);
|
|
225
230
|
});
|
|
226
231
|
|
|
227
232
|
it("honors the skip set (already handled this run)", async () => {
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An orphaned plugin that merely changed marketplace is not deprecated.
|
|
3
|
+
*
|
|
4
|
+
* Splitting `magus` into `magus` + `magus-marketing` left `seo@magus`,
|
|
5
|
+
* `instantly@magus`, `video-editing@magus` and `nanobanana@magus` installed
|
|
6
|
+
* under a namespace that no longer lists them. They rendered as bare
|
|
7
|
+
* "deprecated", whose only action is deletion — which drops a plugin that is
|
|
8
|
+
* still published, just under a different id.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, test } from "bun:test";
|
|
12
|
+
import type { LocalMarketplace } from "../services/local-marketplace.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Mirrors `findPluginInOtherMarketplace` in plugin-manager.ts, which is module
|
|
16
|
+
* -private. Kept in lockstep with it; the behaviour, not the binding, is what
|
|
17
|
+
* these tests pin.
|
|
18
|
+
*/
|
|
19
|
+
function findPluginInOtherMarketplace(
|
|
20
|
+
pluginName: string,
|
|
21
|
+
currentMarketplace: string,
|
|
22
|
+
localMarketplaces: Map<string, LocalMarketplace>,
|
|
23
|
+
): string | undefined {
|
|
24
|
+
for (const [name, mp] of localMarketplaces) {
|
|
25
|
+
if (name === currentMarketplace) continue;
|
|
26
|
+
if (mp.plugins.some((p) => p.name === pluginName)) return name;
|
|
27
|
+
}
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const mp = (name: string, plugins: string[]): [string, LocalMarketplace] => [
|
|
32
|
+
name,
|
|
33
|
+
{
|
|
34
|
+
name,
|
|
35
|
+
description: "",
|
|
36
|
+
plugins: plugins.map((p) => ({
|
|
37
|
+
name: p,
|
|
38
|
+
version: "1.0.0",
|
|
39
|
+
description: "",
|
|
40
|
+
})),
|
|
41
|
+
},
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
describe("moved-marketplace detection", () => {
|
|
45
|
+
test("finds a plugin that moved to a sibling marketplace", () => {
|
|
46
|
+
const marketplaces = new Map([
|
|
47
|
+
mp("magus", ["dev", "terminal"]),
|
|
48
|
+
mp("magus-marketing", ["seo", "instantly", "video-editing"]),
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
expect(findPluginInOtherMarketplace("seo", "magus", marketplaces)).toBe(
|
|
52
|
+
"magus-marketing",
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("a genuinely retired plugin reports no destination", () => {
|
|
57
|
+
// `conductor` was removed at magus v8.0.0 and republished nowhere.
|
|
58
|
+
const marketplaces = new Map([
|
|
59
|
+
mp("magus", ["dev"]),
|
|
60
|
+
mp("magus-marketing", ["seo"]),
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
expect(
|
|
64
|
+
findPluginInOtherMarketplace("conductor", "magus", marketplaces),
|
|
65
|
+
).toBeUndefined();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("does not propose the marketplace it is already installed from", () => {
|
|
69
|
+
const marketplaces = new Map([mp("magus", ["dev"])]);
|
|
70
|
+
expect(findPluginInOtherMarketplace("dev", "magus", marketplaces)).toBe(
|
|
71
|
+
undefined,
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("a renamed plugin is not matched — the name is the identity", () => {
|
|
76
|
+
// nanobanana became image-generate. Names differ, so this correctly
|
|
77
|
+
// reports no destination rather than guessing at a rename.
|
|
78
|
+
const marketplaces = new Map([
|
|
79
|
+
mp("magus", ["dev"]),
|
|
80
|
+
mp("magus-marketing", ["image-generate"]),
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
expect(
|
|
84
|
+
findPluginInOtherMarketplace("nanobanana", "magus", marketplaces),
|
|
85
|
+
).toBeUndefined();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
isInstalledInScope,
|
|
4
|
+
resolveScopeAction,
|
|
5
|
+
} from "../services/plugin-manager.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The scope keys (u / p / l) and the Enter scope picker both decide between
|
|
9
|
+
* install, update and uninstall. That decision used to read `scope.enabled` —
|
|
10
|
+
* the `enabledPlugins` flag from a settings file — and ignore `scope.version`,
|
|
11
|
+
* which is the registry-backed answer to "did Claude Code actually install
|
|
12
|
+
* this".
|
|
13
|
+
*
|
|
14
|
+
* Those two disagree in a real, common state: a plugin listed in
|
|
15
|
+
* `enabledPlugins` with no entry in `installed_plugins.json` for the current
|
|
16
|
+
* project path. The list row renders it "not installed" (isEnabledButNotInstalled),
|
|
17
|
+
* and pressing its scope key ran `claude plugin uninstall`. The user asked to
|
|
18
|
+
* install the thing the UI told them was missing, and it was removed instead.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
describe("isInstalledInScope", () => {
|
|
22
|
+
test("enabled with a registry version is installed", () => {
|
|
23
|
+
expect(isInstalledInScope({ enabled: true, version: "3.0.2" })).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("enabled with NO version is not installed — the broken state", () => {
|
|
27
|
+
// dev@magus, measured: `.claude/settings.json` had "dev@magus": true while
|
|
28
|
+
// installed_plugins.json had no entry resolving for the project path.
|
|
29
|
+
expect(isInstalledInScope({ enabled: true })).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("a version with the flag off is not installed either", () => {
|
|
33
|
+
expect(isInstalledInScope({ enabled: false, version: "3.0.2" })).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("an absent scope is not installed", () => {
|
|
37
|
+
expect(isInstalledInScope(undefined)).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('"0.0.0" counts as installed — Anthropic official plugins record it', () => {
|
|
41
|
+
expect(isInstalledInScope({ enabled: true, version: "0.0.0" })).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("resolveScopeAction", () => {
|
|
46
|
+
test("REGRESSION: enabled but not installed resolves to install, never uninstall", () => {
|
|
47
|
+
// This is the reported bug. Before the fix this returned "uninstall".
|
|
48
|
+
expect(resolveScopeAction({ enabled: true }, "3.0.2")).toBe("install");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("a scope that was never enabled resolves to install", () => {
|
|
52
|
+
expect(resolveScopeAction(undefined, "3.0.2")).toBe("install");
|
|
53
|
+
expect(resolveScopeAction({ enabled: false }, "3.0.2")).toBe("install");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("a healthy install at the latest version resolves to uninstall (toggle off)", () => {
|
|
57
|
+
expect(resolveScopeAction({ enabled: true, version: "3.0.2" }, "3.0.2")).toBe(
|
|
58
|
+
"uninstall",
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("a healthy install behind the catalog resolves to update", () => {
|
|
63
|
+
expect(resolveScopeAction({ enabled: true, version: "2.9.0" }, "3.0.2")).toBe(
|
|
64
|
+
"update",
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('an unknown catalog version ("0.0.0") never fabricates an update', () => {
|
|
69
|
+
// There is nothing to update *to*, so toggling must still mean uninstall.
|
|
70
|
+
expect(resolveScopeAction({ enabled: true, version: "3.0.2" }, "0.0.0")).toBe(
|
|
71
|
+
"uninstall",
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("installed with unknown version resolves to update when a real version is published", () => {
|
|
76
|
+
// "0.0.0" installed against a real catalog version is a genuine upgrade
|
|
77
|
+
// path, not the broken state — it must not be treated as uninstall.
|
|
78
|
+
expect(resolveScopeAction({ enabled: true, version: "0.0.0" }, "3.0.2")).toBe(
|
|
79
|
+
"update",
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
2
5
|
import {
|
|
3
6
|
diffVersions,
|
|
4
7
|
isKnownVersion,
|
|
8
|
+
loadSeenVersions,
|
|
9
|
+
saveSeenVersions,
|
|
5
10
|
UNKNOWN_VERSION,
|
|
6
11
|
} from "../services/version-snapshot.js";
|
|
7
12
|
|
|
@@ -109,3 +114,83 @@ describe("diffVersions — removals and mixed sets", () => {
|
|
|
109
114
|
]);
|
|
110
115
|
});
|
|
111
116
|
});
|
|
117
|
+
|
|
118
|
+
describe("per-project baselines", () => {
|
|
119
|
+
/**
|
|
120
|
+
* The baseline used to be one flat map for the whole machine, but the
|
|
121
|
+
* versions written into it are resolved per project. Two projects
|
|
122
|
+
* legitimately on different versions overwrote each other's baseline, so
|
|
123
|
+
* every differing plugin was reported as "updated" on the next render — a
|
|
124
|
+
* change that never happened.
|
|
125
|
+
*
|
|
126
|
+
* CLAUDE_CONFIG_DIR, not HOME: os.homedir() reads the passwd database on
|
|
127
|
+
* macOS and ignores $HOME, so a HOME-only override writes to the operator's
|
|
128
|
+
* real ~/.claude. That is not hypothetical — it happened while writing these.
|
|
129
|
+
*/
|
|
130
|
+
const withSandbox = async (fn: () => Promise<void>): Promise<void> => {
|
|
131
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "vsnap-"));
|
|
132
|
+
const prev = process.env.CLAUDE_CONFIG_DIR;
|
|
133
|
+
process.env.CLAUDE_CONFIG_DIR = dir;
|
|
134
|
+
try {
|
|
135
|
+
await fn();
|
|
136
|
+
} finally {
|
|
137
|
+
if (prev === undefined) delete process.env.CLAUDE_CONFIG_DIR;
|
|
138
|
+
else process.env.CLAUDE_CONFIG_DIR = prev;
|
|
139
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
test("a project with no baseline of its own reports nothing", async () => {
|
|
144
|
+
await withSandbox(async () => {
|
|
145
|
+
await saveSeenVersions("/proj/a", { "statusline@magus": "2.2.0" });
|
|
146
|
+
const baseline = await loadSeenVersions("/proj/b");
|
|
147
|
+
expect(baseline).toBeNull();
|
|
148
|
+
expect(diffVersions(baseline, { "statusline@magus": "2.5.0" })).toEqual([]);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("saving one project preserves the others", async () => {
|
|
153
|
+
await withSandbox(async () => {
|
|
154
|
+
await saveSeenVersions("/proj/a", { "gtd@magus": "2.0.0" });
|
|
155
|
+
await saveSeenVersions("/proj/b", { "gtd@magus": "2.0.1" });
|
|
156
|
+
expect(await loadSeenVersions("/proj/a")).toEqual({ "gtd@magus": "2.0.0" });
|
|
157
|
+
expect(await loadSeenVersions("/proj/b")).toEqual({ "gtd@magus": "2.0.1" });
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("a real update inside one project is still reported", async () => {
|
|
162
|
+
await withSandbox(async () => {
|
|
163
|
+
await saveSeenVersions("/proj/a", { "dev@magus": "3.0.0" });
|
|
164
|
+
const previous = await loadSeenVersions("/proj/a");
|
|
165
|
+
expect(diffVersions(previous, { "dev@magus": "3.0.1" })).toEqual([
|
|
166
|
+
{ pluginId: "dev@magus", kind: "updated", from: "3.0.0", to: "3.0.1" },
|
|
167
|
+
]);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("a pre-migration flat baseline is adopted once, then replaced", async () => {
|
|
172
|
+
await withSandbox(async () => {
|
|
173
|
+
const file = path.join(
|
|
174
|
+
process.env.CLAUDE_CONFIG_DIR as string,
|
|
175
|
+
"claudeup-version-snapshot.json",
|
|
176
|
+
);
|
|
177
|
+
await fs.writeFile(
|
|
178
|
+
file,
|
|
179
|
+
JSON.stringify({
|
|
180
|
+
seen: { "dev@magus": "3.0.0" },
|
|
181
|
+
seededAt: "2026-07-29T00:00:00.000Z",
|
|
182
|
+
updatedAt: "2026-07-29T00:00:00.000Z",
|
|
183
|
+
}),
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
// Legacy shape carries no project, so it stands in for whoever asks first
|
|
187
|
+
// rather than being thrown away.
|
|
188
|
+
expect(await loadSeenVersions("/proj/a")).toEqual({ "dev@magus": "3.0.0" });
|
|
189
|
+
|
|
190
|
+
await saveSeenVersions("/proj/a", { "dev@magus": "3.0.1" });
|
|
191
|
+
// Once per-project entries exist, the flat map is no longer consulted.
|
|
192
|
+
expect(await loadSeenVersions("/proj/b")).toBeNull();
|
|
193
|
+
expect(await loadSeenVersions("/proj/a")).toEqual({ "dev@magus": "3.0.1" });
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
});
|