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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudeup",
3
- "version": "4.31.0",
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.31.0",
68
- "claudeup-darwin-x64": "4.31.0",
69
- "claudeup-linux-x64": "4.31.0"
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
- // Helper: build a minimal PluginInfo-like object
177
- function makePlugin(overrides: Partial<{
178
- id: string;
183
+ interface ScopeStatusLike {
179
184
  enabled: boolean;
180
- hasUpdate: boolean;
181
- installedVersion: string;
182
- version: string;
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("skips a marketplace with autoUpdate === false", async () => {
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.skipped).toEqual(["magus"]);
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
+ });