claudeup 4.4.0 → 4.5.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudeup",
3
- "version": "4.4.0",
3
+ "version": "4.5.0",
4
4
  "description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
5
5
  "type": "module",
6
6
  "main": "src/main.tsx",
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Dual-write prevention tests
3
+ *
4
+ * Verifies that claudeup does NOT write plugin state (via saveGlobalInstalledPluginVersion
5
+ * or saveVersionAfterInstall) when the Claude CLI has already handled the write.
6
+ *
7
+ * RED before fix: PluginsScreen has 6 saveVersionAfterInstall calls after CLI ops;
8
+ * prerunner calls saveGlobalInstalledPluginVersion unconditionally.
9
+ * GREEN after fix: those calls are removed.
10
+ */
11
+
12
+ import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test";
13
+ import * as fs from "node:fs";
14
+ import * as path from "node:path";
15
+ import * as url from "node:url";
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // PART 1: PluginsScreen source-level policy tests
19
+ //
20
+ // The dual-write calls in PluginsScreen are inside React event handlers.
21
+ // Rendering the full TUI component in a unit test would require the opentui
22
+ // terminal runtime. Instead we assert the source does NOT contain the
23
+ // forbidden call pattern — this is a code-policy test that:
24
+ // - FAILS before the fix (6 saveVersionAfterInstall calls follow CLI calls)
25
+ // - PASSES after the fix (those calls are removed)
26
+ // ---------------------------------------------------------------------------
27
+
28
+ const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
29
+ const PLUGINS_SCREEN_PATH = path.resolve(
30
+ __dirname,
31
+ "../ui/screens/PluginsScreen.tsx",
32
+ );
33
+
34
+ describe("PluginsScreen — no dual-write after CLI operations", () => {
35
+ let source: string;
36
+
37
+ beforeEach(() => {
38
+ source = fs.readFileSync(PLUGINS_SCREEN_PATH, "utf8");
39
+ });
40
+
41
+ it("does not call saveVersionAfterInstall immediately after cliUpdatePlugin", () => {
42
+ // Find every occurrence of cliUpdatePlugin in the source. Each one should
43
+ // NOT be immediately followed (within 3 lines) by saveVersionAfterInstall.
44
+ const lines = source.split("\n");
45
+ const violations: number[] = [];
46
+
47
+ for (let i = 0; i < lines.length; i++) {
48
+ if (lines[i].includes("cliUpdatePlugin(")) {
49
+ // Check the next 3 lines for a dual-write call
50
+ for (let j = i + 1; j <= Math.min(i + 3, lines.length - 1); j++) {
51
+ if (lines[j].includes("saveVersionAfterInstall(")) {
52
+ violations.push(i + 1); // 1-indexed line number
53
+ break;
54
+ }
55
+ }
56
+ }
57
+ }
58
+
59
+ expect(violations).toEqual([]);
60
+ });
61
+
62
+ it("does not call saveVersionAfterInstall immediately after cliInstallPlugin", () => {
63
+ const lines = source.split("\n");
64
+ const violations: number[] = [];
65
+
66
+ for (let i = 0; i < lines.length; i++) {
67
+ if (lines[i].includes("cliInstallPlugin(")) {
68
+ for (let j = i + 1; j <= Math.min(i + 3, lines.length - 1); j++) {
69
+ if (lines[j].includes("saveVersionAfterInstall(")) {
70
+ violations.push(i + 1);
71
+ break;
72
+ }
73
+ }
74
+ }
75
+ }
76
+
77
+ expect(violations).toEqual([]);
78
+ });
79
+ });
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // PART 2: Prerunner — saveGlobalInstalledPluginVersion guarded by CLI success
83
+ //
84
+ // We mock the prerunner's module dependencies so we can call prerunClaude()
85
+ // directly and inspect call counts.
86
+ // ---------------------------------------------------------------------------
87
+
88
+ // Mutable state shared between mock factories and test assertions.
89
+ let mockSaveGlobal: ReturnType<typeof mock>;
90
+ let mockUpdatePlugin: ReturnType<typeof mock>;
91
+ let mockIsClaudeAvailable: ReturnType<typeof mock>;
92
+ let mockGetAvailablePlugins: ReturnType<typeof mock>;
93
+
94
+ // We must register mock.module before importing the module under test.
95
+ // Bun hoists mock.module() calls, so the mocks are active when the
96
+ // module is first loaded.
97
+
98
+ mock.module("../services/claude-settings.js", () => ({
99
+ recoverMarketplaceSettings: mock(() =>
100
+ Promise.resolve({ enabledAutoUpdate: [], removed: [] }),
101
+ ),
102
+ migrateMarketplaceRename: mock(() =>
103
+ Promise.resolve({
104
+ projectMigrated: 0,
105
+ globalMigrated: 0,
106
+ localMigrated: 0,
107
+ registryMigrated: 0,
108
+ knownMarketplacesMigrated: false,
109
+ }),
110
+ ),
111
+ getGlobalEnabledPlugins: mock(() => Promise.resolve({})),
112
+ getEnabledPlugins: mock(() => Promise.resolve({})),
113
+ getLocalEnabledPlugins: mock(() => Promise.resolve({})),
114
+ saveGlobalInstalledPluginVersion: (...args: unknown[]) =>
115
+ mockSaveGlobal(...args),
116
+ readGlobalSettings: mock(() => Promise.resolve({ hooks: {} })),
117
+ writeGlobalSettings: mock(() => Promise.resolve()),
118
+ }));
119
+
120
+ mock.module("../services/claude-cli.js", () => ({
121
+ updatePlugin: (...args: unknown[]) => mockUpdatePlugin(...args),
122
+ isClaudeAvailable: (...args: unknown[]) => mockIsClaudeAvailable(...args),
123
+ addMarketplace: mock(() => Promise.resolve()),
124
+ }));
125
+
126
+ mock.module("../services/plugin-manager.js", () => ({
127
+ getAvailablePlugins: (...args: unknown[]) =>
128
+ mockGetAvailablePlugins(...args),
129
+ clearMarketplaceCache: mock(() => undefined),
130
+ }));
131
+
132
+ mock.module("../services/update-cache.js", () => ({
133
+ UpdateCache: class {
134
+ shouldCheckForUpdates = mock(() => Promise.resolve(true));
135
+ saveCheck = mock(() => Promise.resolve());
136
+ },
137
+ }));
138
+
139
+ mock.module("../services/claude-runner.js", () => ({
140
+ runClaude: mock(() => Promise.resolve(0)),
141
+ }));
142
+
143
+ mock.module("../data/marketplaces.js", () => ({
144
+ defaultMarketplaces: [],
145
+ }));
146
+
147
+ mock.module("../utils/string-utils.js", () => ({
148
+ parsePluginId: mock((_id: string) => null),
149
+ }));
150
+
151
+ mock.module("fs-extra", () => ({
152
+ default: {
153
+ pathExists: mock(() => Promise.resolve(false)),
154
+ readJson: mock(() => Promise.resolve({})),
155
+ writeJson: mock(() => Promise.resolve()),
156
+ ensureDir: mock(() => Promise.resolve()),
157
+ },
158
+ }));
159
+
160
+ // Now import the module under test (after mock.module registrations).
161
+ const { prerunClaude } = await import("../prerunner/index.js");
162
+
163
+ // Helper: build a minimal PluginInfo-like object
164
+ function makePlugin(overrides: Partial<{
165
+ id: string;
166
+ enabled: boolean;
167
+ hasUpdate: boolean;
168
+ installedVersion: string;
169
+ version: string;
170
+ }> = {}): {
171
+ id: string;
172
+ enabled: boolean;
173
+ hasUpdate: boolean;
174
+ installedVersion: string;
175
+ version: string;
176
+ } {
177
+ return {
178
+ id: "test-plugin@magus",
179
+ enabled: true,
180
+ hasUpdate: true,
181
+ installedVersion: "1.0.0",
182
+ version: "1.1.0",
183
+ ...overrides,
184
+ };
185
+ }
186
+
187
+ describe("prerunner — saveGlobalInstalledPluginVersion call count", () => {
188
+ beforeEach(() => {
189
+ mockSaveGlobal = mock(() => Promise.resolve());
190
+ mockUpdatePlugin = mock(() => Promise.resolve());
191
+ mockIsClaudeAvailable = mock(() => Promise.resolve(true));
192
+ mockGetAvailablePlugins = mock(() =>
193
+ Promise.resolve([makePlugin()]),
194
+ );
195
+ });
196
+
197
+ it("does NOT call saveGlobalInstalledPluginVersion when CLI is available and updatePlugin succeeds", async () => {
198
+ // CLI is available, updatePlugin succeeds.
199
+ // The FIXED code removes the saveGlobalInstalledPluginVersion call entirely —
200
+ // the CLI already wrote the state.
201
+ mockIsClaudeAvailable = mock(() => Promise.resolve(true));
202
+ mockUpdatePlugin = mock(() => Promise.resolve());
203
+
204
+ await prerunClaude(["--help"], { force: true });
205
+
206
+ expect(mockSaveGlobal).toHaveBeenCalledTimes(0);
207
+ });
208
+
209
+ it("does NOT call saveGlobalInstalledPluginVersion when CLI is unavailable", async () => {
210
+ // CLI unavailable — we must NOT write phantom state.
211
+ mockIsClaudeAvailable = mock(() => Promise.resolve(false));
212
+
213
+ await prerunClaude(["--help"], { force: true });
214
+
215
+ expect(mockSaveGlobal).toHaveBeenCalledTimes(0);
216
+ });
217
+
218
+ it("does NOT call saveGlobalInstalledPluginVersion when CLI updatePlugin throws", async () => {
219
+ // CLI available but updatePlugin fails — must NOT write phantom state.
220
+ mockIsClaudeAvailable = mock(() => Promise.resolve(true));
221
+ mockUpdatePlugin = mock(() =>
222
+ Promise.reject(new Error("CLI update failed")),
223
+ );
224
+
225
+ await prerunClaude(["--help"], { force: true });
226
+
227
+ expect(mockSaveGlobal).toHaveBeenCalledTimes(0);
228
+ });
229
+ });
@@ -4,7 +4,7 @@ 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, getGlobalEnabledPlugins, getEnabledPlugins, getLocalEnabledPlugins, saveGlobalInstalledPluginVersion, readGlobalSettings, writeGlobalSettings, } from "../services/claude-settings.js";
7
+ import { recoverMarketplaceSettings, migrateMarketplaceRename, getGlobalEnabledPlugins, getEnabledPlugins, getLocalEnabledPlugins, readGlobalSettings, writeGlobalSettings, } from "../services/claude-settings.js";
8
8
  import { parsePluginId } from "../utils/string-utils.js";
9
9
  import { defaultMarketplaces } from "../data/marketplaces.js";
10
10
  import { updatePlugin, addMarketplace, isClaudeAvailable, } from "../services/claude-cli.js";
@@ -194,10 +194,12 @@ export async function prerunClaude(claudeArgs, options = {}) {
194
194
  plugin.installedVersion &&
195
195
  plugin.version) {
196
196
  try {
197
- if (cliAvailable) {
198
- await updatePlugin(plugin.id, "user");
197
+ if (!cliAvailable) {
198
+ // CLI unavailable — skip update entirely, don't write phantom state
199
+ continue;
199
200
  }
200
- await saveGlobalInstalledPluginVersion(plugin.id, plugin.version);
201
+ await updatePlugin(plugin.id, "user");
202
+ // CLI wrote all plugin state; no claudeup write needed
201
203
  autoUpdatedPlugins.push({
202
204
  pluginId: plugin.id,
203
205
  oldVersion: plugin.installedVersion,
@@ -13,7 +13,6 @@ import {
13
13
  getGlobalEnabledPlugins,
14
14
  getEnabledPlugins,
15
15
  getLocalEnabledPlugins,
16
- saveGlobalInstalledPluginVersion,
17
16
  readGlobalSettings,
18
17
  writeGlobalSettings,
19
18
  } from "../services/claude-settings.js";
@@ -278,10 +277,12 @@ export async function prerunClaude(
278
277
  plugin.version
279
278
  ) {
280
279
  try {
281
- if (cliAvailable) {
282
- await updatePlugin(plugin.id, "user");
280
+ if (!cliAvailable) {
281
+ // CLI unavailable — skip update entirely, don't write phantom state
282
+ continue;
283
283
  }
284
- await saveGlobalInstalledPluginVersion(plugin.id, plugin.version);
284
+ await updatePlugin(plugin.id, "user");
285
+ // CLI wrote all plugin state; no claudeup write needed
285
286
 
286
287
  autoUpdatedPlugins.push({
287
288
  pluginId: plugin.id,
@@ -9,9 +9,8 @@ import { EmptyFilterState } from "../components/EmptyFilterState.js";
9
9
  import { fuzzyFilter } from "../../utils/fuzzy-search.js";
10
10
  import { getAllMarketplaces } from "../../data/marketplaces.js";
11
11
  import { getAvailablePlugins, refreshAllMarketplaces, clearMarketplaceCache, getLocalMarketplacesInfo, } from "../../services/plugin-manager.js";
12
- import { setMcpEnvVar, getMcpEnvVars, readSettings, saveGlobalInstalledPluginVersion, saveLocalInstalledPluginVersion, } from "../../services/claude-settings.js";
12
+ import { setMcpEnvVar, getMcpEnvVars, readSettings, } from "../../services/claude-settings.js";
13
13
  import { saveProfile } from "../../services/profiles.js";
14
- import { saveInstalledPluginVersion } from "../../services/plugin-manager.js";
15
14
  import { installPlugin as cliInstallPlugin, uninstallPlugin as cliUninstallPlugin, updatePlugin as cliUpdatePlugin, } from "../../services/claude-cli.js";
16
15
  import { getPluginEnvRequirements, getPluginSourcePath, } from "../../services/plugin-mcp-config.js";
17
16
  import { getPluginSetupFromSource, checkMissingDeps, installPluginDeps, } from "../../services/plugin-setup.js";
@@ -355,25 +354,6 @@ export function PluginsScreen() {
355
354
  console.error("Error installing plugin deps:", error);
356
355
  }
357
356
  };
358
- /**
359
- * Save installed plugin version to settings after CLI install/update.
360
- */
361
- const saveVersionAfterInstall = async (pluginId, version, scope) => {
362
- try {
363
- if (scope === "user") {
364
- await saveGlobalInstalledPluginVersion(pluginId, version);
365
- }
366
- else if (scope === "local") {
367
- await saveLocalInstalledPluginVersion(pluginId, version, state.projectPath);
368
- }
369
- else {
370
- await saveInstalledPluginVersion(pluginId, version, state.projectPath);
371
- }
372
- }
373
- catch {
374
- // Non-fatal: version display may be stale but plugin still works
375
- }
376
- };
377
357
  const handleSelect = async () => {
378
358
  const item = selectableItems[pluginsState.selectedIndex];
379
359
  if (!item)
@@ -459,11 +439,9 @@ export function PluginsScreen() {
459
439
  }
460
440
  else if (action === "update") {
461
441
  await cliUpdatePlugin(plugin.id, scope);
462
- await saveVersionAfterInstall(plugin.id, latestVersion, scope);
463
442
  }
464
443
  else {
465
444
  await cliInstallPlugin(plugin.id, scope);
466
- await saveVersionAfterInstall(plugin.id, latestVersion, scope);
467
445
  modal.hideModal();
468
446
  await collectPluginEnvVars(plugin.name, plugin.marketplace);
469
447
  await installPluginSystemDeps(plugin.name, plugin.marketplace);
@@ -488,7 +466,6 @@ export function PluginsScreen() {
488
466
  modal.loading(`Updating ${plugin.name}...`);
489
467
  try {
490
468
  await cliUpdatePlugin(plugin.id, scope);
491
- await saveVersionAfterInstall(plugin.id, plugin.version || "0.0.0", scope);
492
469
  modal.hideModal();
493
470
  fetchData();
494
471
  }
@@ -508,7 +485,6 @@ export function PluginsScreen() {
508
485
  try {
509
486
  for (const plugin of updatable) {
510
487
  await cliUpdatePlugin(plugin.id, scope);
511
- await saveVersionAfterInstall(plugin.id, plugin.version || "0.0.0", scope);
512
488
  }
513
489
  modal.hideModal();
514
490
  fetchData();
@@ -558,11 +534,9 @@ export function PluginsScreen() {
558
534
  }
559
535
  else if (action === "update") {
560
536
  await cliUpdatePlugin(plugin.id, scope);
561
- await saveVersionAfterInstall(plugin.id, latestVersion, scope);
562
537
  }
563
538
  else {
564
539
  await cliInstallPlugin(plugin.id, scope);
565
- await saveVersionAfterInstall(plugin.id, latestVersion, scope);
566
540
  modal.hideModal();
567
541
  await collectPluginEnvVars(plugin.name, plugin.marketplace);
568
542
  await installPluginSystemDeps(plugin.name, plugin.marketplace);
@@ -18,11 +18,8 @@ import {
18
18
  setMcpEnvVar,
19
19
  getMcpEnvVars,
20
20
  readSettings,
21
- saveGlobalInstalledPluginVersion,
22
- saveLocalInstalledPluginVersion,
23
21
  } from "../../services/claude-settings.js";
24
22
  import { saveProfile } from "../../services/profiles.js";
25
- import { saveInstalledPluginVersion } from "../../services/plugin-manager.js";
26
23
  import {
27
24
  installPlugin as cliInstallPlugin,
28
25
  uninstallPlugin as cliUninstallPlugin,
@@ -452,27 +449,6 @@ export function PluginsScreen() {
452
449
  }
453
450
  };
454
451
 
455
- /**
456
- * Save installed plugin version to settings after CLI install/update.
457
- */
458
- const saveVersionAfterInstall = async (
459
- pluginId: string,
460
- version: string,
461
- scope: PluginScope,
462
- ): Promise<void> => {
463
- try {
464
- if (scope === "user") {
465
- await saveGlobalInstalledPluginVersion(pluginId, version);
466
- } else if (scope === "local") {
467
- await saveLocalInstalledPluginVersion(pluginId, version, state.projectPath);
468
- } else {
469
- await saveInstalledPluginVersion(pluginId, version, state.projectPath);
470
- }
471
- } catch {
472
- // Non-fatal: version display may be stale but plugin still works
473
- }
474
- };
475
-
476
452
  const handleSelect = async () => {
477
453
  const item = selectableItems[pluginsState.selectedIndex];
478
454
  if (!item) return;
@@ -573,10 +549,8 @@ export function PluginsScreen() {
573
549
  await cliUninstallPlugin(plugin.id, scope, state.projectPath);
574
550
  } else if (action === "update") {
575
551
  await cliUpdatePlugin(plugin.id, scope);
576
- await saveVersionAfterInstall(plugin.id, latestVersion, scope);
577
552
  } else {
578
553
  await cliInstallPlugin(plugin.id, scope);
579
- await saveVersionAfterInstall(plugin.id, latestVersion, scope);
580
554
  modal.hideModal();
581
555
  await collectPluginEnvVars(plugin.name, plugin.marketplace);
582
556
  await installPluginSystemDeps(plugin.name, plugin.marketplace);
@@ -602,7 +576,6 @@ export function PluginsScreen() {
602
576
  modal.loading(`Updating ${plugin.name}...`);
603
577
  try {
604
578
  await cliUpdatePlugin(plugin.id, scope);
605
- await saveVersionAfterInstall(plugin.id, plugin.version || "0.0.0", scope);
606
579
  modal.hideModal();
607
580
  fetchData();
608
581
  } catch (error) {
@@ -623,7 +596,6 @@ export function PluginsScreen() {
623
596
  try {
624
597
  for (const plugin of updatable) {
625
598
  await cliUpdatePlugin(plugin.id, scope);
626
- await saveVersionAfterInstall(plugin.id, plugin.version || "0.0.0", scope);
627
599
  }
628
600
  modal.hideModal();
629
601
  fetchData();
@@ -679,10 +651,8 @@ export function PluginsScreen() {
679
651
  await cliUninstallPlugin(plugin.id, scope, state.projectPath);
680
652
  } else if (action === "update") {
681
653
  await cliUpdatePlugin(plugin.id, scope);
682
- await saveVersionAfterInstall(plugin.id, latestVersion, scope);
683
654
  } else {
684
655
  await cliInstallPlugin(plugin.id, scope);
685
- await saveVersionAfterInstall(plugin.id, latestVersion, scope);
686
656
  modal.hideModal();
687
657
  await collectPluginEnvVars(plugin.name, plugin.marketplace);
688
658
  await installPluginSystemDeps(plugin.name, plugin.marketplace);