claudeup 6.0.0 → 6.2.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": "6.0.0",
3
+ "version": "6.2.0",
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": "6.0.0",
68
- "claudeup-darwin-x64": "6.0.0",
69
- "claudeup-linux-x64": "6.0.0"
67
+ "claudeup-darwin-arm64": "6.2.0",
68
+ "claudeup-darwin-x64": "6.2.0",
69
+ "claudeup-linux-x64": "6.2.0"
70
70
  }
71
71
  }
@@ -19,7 +19,13 @@ function findTests(dir: string): string[] {
19
19
  for (const entry of readdirSync(dir)) {
20
20
  const p = join(dir, entry);
21
21
  if (statSync(p).isDirectory()) out.push(...findTests(p));
22
- else if (p.endsWith(".test.ts")) out.push(p);
22
+ // `.tsx` as well as `.ts`. The suffix was written when no `.tsx` test
23
+ // existed, so it silently excluded the only file testing a UI primitive
24
+ // — the class of test most likely to be `.tsx` and least likely to be
25
+ // noticed missing, because a file that is never collected reports
26
+ // nothing at all. The printed `N/N` count below is the guard: it moves
27
+ // when collection changes.
28
+ else if (/\.test\.tsx?$/.test(p)) out.push(p);
23
29
  }
24
30
  return out;
25
31
  }
@@ -152,19 +152,57 @@ describe("rate-limit cooldown — survives a relaunch", () => {
152
152
  base = Date.now();
153
153
  });
154
154
 
155
- /** Let a write-behind cooldown reach disk. */
156
- const flush = () => new Promise((r) => setTimeout(r, 80));
157
-
158
- // Only this block triggers write-behind, so only this block pays for draining
159
- // it. Held here rather than in the outer afterEach so the catalog tests — which
160
- // await every write are not each charged the delay too.
161
- afterEach(flush);
155
+ /**
156
+ * Wait until a write-behind cooldown has actually reached disk.
157
+ *
158
+ * This used to be `setTimeout(80)` a guess at how long an unawaited write
159
+ * takes, which is not a synchronisation point. `recordRateLimit` persists
160
+ * fire-and-forget by design (`github-budget.ts` explains why), so on a loaded
161
+ * runner the write had not landed when the next `hydrateGitHubBudget()` read,
162
+ * and the restored strike count came back low. That failed CI as
163
+ * `Expected: 3, Received: 2` on Linux while passing 15/15 on a quiet macOS
164
+ * laptop — the signature of a timing guess, not of a real defect.
165
+ *
166
+ * Polling the store itself removes the guess: it returns as soon as the value
167
+ * is observable, and only gives up after a deadline no healthy machine
168
+ * reaches. `expected` is the strike count the caller just recorded; passing
169
+ * nothing waits for any persisted entry at all.
170
+ */
171
+ const flush = async (
172
+ host: string,
173
+ expected?: number,
174
+ ): Promise<void> => {
175
+ const deadline = Date.now() + 5000;
176
+ for (;;) {
177
+ // `base` keeps the read on the same clock the writes used: cooldowns are
178
+ // dropped on read once expired, so reading with a later `now` could hide
179
+ // an entry that is genuinely there.
180
+ const stored = (await readStoredCooldowns(base))[host];
181
+ if (stored && (expected === undefined || stored.strikes >= expected)) {
182
+ return;
183
+ }
184
+ if (Date.now() > deadline) {
185
+ throw new Error(
186
+ `cooldown for ${host} never reached disk (wanted strikes >= ${expected ?? "any"}, got ${stored?.strikes ?? "nothing"})`,
187
+ );
188
+ }
189
+ await new Promise((r) => setTimeout(r, 10));
190
+ }
191
+ };
192
+
193
+ // Teardown hygiene, NOT a synchronisation point — which is why it is a plain
194
+ // sleep and `flush` is not. Its only job is to let an in-flight write land
195
+ // before the temp config dir is removed; a write that arrives afterwards
196
+ // recreates a file in a deleted directory and harms nothing. Polling for a
197
+ // specific host here would be wrong: several tests in this block end with
198
+ // nothing on disk on purpose.
199
+ afterEach(() => new Promise((r) => setTimeout(r, 50)));
162
200
 
163
201
  it("is still in force in the next process", async () => {
164
202
  // THE defect this store exists to fix. Without persistence, launch two fired
165
203
  // six more doomed requests at a host it had already been refused by.
166
204
  recordRateLimit("raw.githubusercontent.com", new Headers(), base);
167
- await flush();
205
+ await flush("raw.githubusercontent.com");
168
206
 
169
207
  resetGitHubBudget();
170
208
  resetCatalogCacheMemo();
@@ -185,7 +223,7 @@ describe("rate-limit cooldown — survives a relaunch", () => {
185
223
  // requests, learns it is limited once, and exits.
186
224
  const first = recordRateLimit("raw.githubusercontent.com", undefined, base);
187
225
  expect(first.strikes).toBe(1);
188
- await flush();
226
+ await flush("raw.githubusercontent.com", 1);
189
227
 
190
228
  resetGitHubBudget();
191
229
  resetCatalogCacheMemo();
@@ -194,7 +232,7 @@ describe("rate-limit cooldown — survives a relaunch", () => {
194
232
  const second = recordRateLimit("raw.githubusercontent.com", undefined, base);
195
233
  expect(second.strikes).toBe(2);
196
234
  expect(second.until).toBeGreaterThan(first.until);
197
- await flush();
235
+ await flush("raw.githubusercontent.com", 2);
198
236
 
199
237
  resetGitHubBudget();
200
238
  resetCatalogCacheMemo();
@@ -213,7 +251,7 @@ describe("rate-limit cooldown — survives a relaunch", () => {
213
251
  new Headers({ "retry-after": "600" }),
214
252
  base,
215
253
  );
216
- await flush();
254
+ await flush("api.github.com");
217
255
 
218
256
  resetGitHubBudget();
219
257
  await hydrateGitHubBudget();
@@ -15,9 +15,10 @@ import {
15
15
  type ApplyReporter,
16
16
  type PluginApplyDeps,
17
17
  applyPlugins,
18
- parseArgs,
19
18
  pluginNeedsWork,
20
- } from "../cli/update.js";
19
+ installPluginInScope,
20
+ } from "../services/update-engine.js";
21
+ import { parseArgs } from "../cli/update.js";
21
22
  import { checkBinaries } from "../services/doctor-bins.js";
22
23
  import type { PluginScope } from "../services/claude-cli.js";
23
24
  import type { PluginUpdateItem } from "../services/update-plan.js";
@@ -40,6 +41,7 @@ function item(over: Partial<PluginUpdateItem> = {}): PluginUpdateItem {
40
41
 
41
42
  function deps(): PluginApplyDeps {
42
43
  return {
44
+ install: mock(async () => {}),
43
45
  update: mock(async () => {}),
44
46
  repair: mock(async () => {}),
45
47
  readInstalled: mock(async () => "2.0.0"),
@@ -174,6 +176,53 @@ describe("flags", () => {
174
176
  });
175
177
  });
176
178
 
179
+ describe("installPluginInScope — the scope-targeted path", () => {
180
+ test("records the version READ BACK, not the one expected", async () => {
181
+ // This is the bug the TUI's per-scope menu carried after the CLI had
182
+ // fixed it: it wrote the version it *expected*, so `install --check`
183
+ // compared a pin against a copy of itself and reported clean forever.
184
+ const d = deps();
185
+ d.readInstalled = mock(async () => "9.9.9");
186
+ const saved: unknown[] = [];
187
+ d.saveInstalled = mock(async (id, version, scope) => {
188
+ saved.push({ id, version, scope });
189
+ });
190
+
191
+ const actual = await installPluginInScope("dev@magus", "user", PROJECT, d);
192
+
193
+ expect(actual).toBe("9.9.9");
194
+ expect(saved).toEqual([
195
+ { id: "dev@magus", version: "9.9.9", scope: "user" },
196
+ ]);
197
+ });
198
+
199
+ test("honours the caller's scope rather than fanning out", async () => {
200
+ // The whole reason this exists beside applyPlugins: the user picked a
201
+ // scope from a menu, so "every scope that is behind" would override them.
202
+ const d = deps();
203
+ const installed: Array<[string, string]> = [];
204
+ d.install = mock(async (id, scope) => {
205
+ installed.push([id, scope]);
206
+ });
207
+
208
+ await installPluginInScope("dev@magus", "local", PROJECT, d);
209
+
210
+ expect(installed).toEqual([["dev@magus", "local"]]);
211
+ });
212
+
213
+ test("an unreadable version is reported, never guessed", async () => {
214
+ const d = deps();
215
+ d.readInstalled = mock(async () => null);
216
+ const saved: unknown[] = [];
217
+ d.saveInstalled = mock(async () => {
218
+ saved.push(1);
219
+ });
220
+
221
+ expect(await installPluginInScope("x", "user", PROJECT, d)).toBeNull();
222
+ expect(saved).toEqual([]);
223
+ });
224
+ });
225
+
177
226
  describe("checkBinaries onProbe", () => {
178
227
  const bins: ResolvedBin[] = [
179
228
  { name: "tmux", via: "brew", sources: [] } as unknown as ResolvedBin,
@@ -0,0 +1,239 @@
1
+ /**
2
+ * The CLI-tools catalogue can no longer emit a bare `pip`, or shell syntax.
3
+ *
4
+ * Two defects lived in this screen, and both were data problems dressed as code
5
+ * problems:
6
+ *
7
+ * 1. **Bare `pip`.** `getUpdateCommand`'s `pip` and `default` arms returned the
8
+ * catalogue's `installCommand` verbatim, and `getUninstallCommand` emitted
9
+ * `pip uninstall -y`. `pip` is frequently a zsh alias rather than an
10
+ * executable, and a machine that manages Python tools with uv or pipx may
11
+ * have no `pip` at all. `services/toolchain.ts` already knew how to answer
12
+ * "how does Python work here"; this screen was not asking it.
13
+ * 2. **Shell syntax in data.** `aider`'s entry was
14
+ * `pip install aider-install && aider-install` — a `&&` chain in a catalogue
15
+ * field that got handed to `/bin/bash`.
16
+ *
17
+ * Both are now unrepresentable: the field is gone and the command is derived.
18
+ * These tests assert that over the whole catalogue rather than over an example,
19
+ * so a new entry cannot reintroduce either.
20
+ *
21
+ * Pure — no React, no network, no PATH dependency. Every Python installer is
22
+ * pinned explicitly, per the convention `toolchain.test.ts` established: the
23
+ * production default is resolved from PATH, so an unpinned assertion would
24
+ * encode whatever happens to be installed on the machine running the suite.
25
+ */
26
+
27
+ import { describe, expect, test } from "bun:test";
28
+ import { cliTools } from "../data/cli-tools.js";
29
+ import {
30
+ type InstallMethod,
31
+ cliToolInstall,
32
+ cliToolUninstall,
33
+ cliToolUpdate,
34
+ } from "../services/cli-tool-commands.js";
35
+ import {
36
+ PYTHON_CLI_INSTALLERS,
37
+ type PythonCliInstaller,
38
+ resolvePythonCliInstaller,
39
+ } from "../services/toolchain.js";
40
+ import { versionFromPypiJson } from "../ui/screens/CliToolsScreen.js";
41
+ import { type Command, formatCommand } from "../utils/command-utils.js";
42
+
43
+ const byProbe = (probe: string): PythonCliInstaller => {
44
+ const found = PYTHON_CLI_INSTALLERS.find((i) => i.probe === probe);
45
+ if (!found) throw new Error(`no python installer with probe ${probe}`);
46
+ return found;
47
+ };
48
+ const PYTHON_PROBES = PYTHON_CLI_INSTALLERS.map((i) => i.probe);
49
+
50
+ /** The methods the resolver models, i.e. the ones that reach toolchain.ts. */
51
+ const RESOLVED_METHODS: InstallMethod[] = ["npm", "bun", "brew", "pip"];
52
+
53
+ describe("the catalogue is well-formed", () => {
54
+ test("it has entries, and each names a package manager and a package", () => {
55
+ // Guard: every assertion below iterates the catalogue, so an empty or
56
+ // malformed one would make them all pass vacuously.
57
+ expect(cliTools.length).toBeGreaterThan(5);
58
+ for (const tool of cliTools) {
59
+ expect(typeof tool.packageName).toBe("string");
60
+ expect(tool.packageName.length).toBeGreaterThan(0);
61
+ expect(["bun", "npm", "pip", "brew", "go"]).toContain(
62
+ tool.packageManager,
63
+ );
64
+ }
65
+ });
66
+ });
67
+
68
+ describe("no builder ever emits a bare `pip`", () => {
69
+ test("install: cmd is the resolver's probe, never `pip`", () => {
70
+ for (const tool of cliTools) {
71
+ const cmd = cliToolInstall(tool);
72
+ expect(cmd.cmd).not.toBe("pip");
73
+ if (tool.packageManager === "pip") {
74
+ // Whatever the machine running this resolved to, it must be one of
75
+ // the three modelled installers — and `cmd` must BE that probe,
76
+ // since that is the executable the syscall looks up.
77
+ expect(PYTHON_PROBES).toContain(cmd.cmd);
78
+ expect(cmd.cmd).toBe(resolvePythonCliInstaller().probe);
79
+ }
80
+ }
81
+ });
82
+
83
+ test("update and uninstall: cmd is never `pip`, for any method", () => {
84
+ for (const tool of cliTools) {
85
+ for (const method of RESOLVED_METHODS) {
86
+ for (const cmd of [
87
+ cliToolUpdate(tool, method),
88
+ cliToolUninstall(tool, method),
89
+ ]) {
90
+ if (!cmd) continue;
91
+ expect(cmd.cmd).not.toBe("pip");
92
+ }
93
+ }
94
+ }
95
+ });
96
+
97
+ test("each pinned Python installer drives all three of its own commands", () => {
98
+ // Pinned explicitly so this holds on a runner with no uv. The `pip`
99
+ // method must route to the SAME executable for install, update and
100
+ // uninstall — installing with uv and uninstalling with python3 would look
101
+ // like it worked and leave the tool on disk.
102
+ for (const probe of PYTHON_PROBES) {
103
+ const python = byProbe(probe);
104
+ expect(python.install("x").cmd).toBe(probe);
105
+ expect(python.upgrade("x").cmd).toBe(probe);
106
+ expect(python.uninstall("x").cmd).toBe(probe);
107
+ }
108
+ });
109
+ });
110
+
111
+ describe("no builder can emit shell syntax", () => {
112
+ test("no argv element of any command contains &&, | or ;", () => {
113
+ // The catalogue's only `&&` chain was aider's installCommand. With the
114
+ // field gone there is nowhere left for shell syntax to enter, and this
115
+ // asserts that over every entry and every method rather than over that
116
+ // one case.
117
+ const all: Command[] = [];
118
+ for (const tool of cliTools) {
119
+ all.push(cliToolInstall(tool));
120
+ for (const method of [
121
+ ...RESOLVED_METHODS,
122
+ "pnpm" as const,
123
+ "yarn" as const,
124
+ ]) {
125
+ const u = cliToolUpdate(tool, method);
126
+ const r = cliToolUninstall(tool, method);
127
+ if (u) all.push(u);
128
+ if (r) all.push(r);
129
+ }
130
+ }
131
+ expect(all.length).toBeGreaterThan(0);
132
+
133
+ for (const cmd of all) {
134
+ for (const token of [cmd.cmd, ...cmd.args]) {
135
+ expect(token).not.toContain("&&");
136
+ expect(token).not.toContain("|");
137
+ expect(token).not.toContain(";");
138
+ }
139
+ }
140
+ });
141
+ });
142
+
143
+ describe("the detected method is followed, not the catalogue's", () => {
144
+ test("pnpm and yarn keep their own shapes", () => {
145
+ // A tool the catalogue calls npm can still have been installed with
146
+ // pnpm. Updating it as npm would install a second copy beside the first,
147
+ // which is the conflict this screen exists to report.
148
+ const tool = cliTools.find((t) => t.packageManager === "npm")!;
149
+ expect(cliToolUpdate(tool, "pnpm")).toEqual({
150
+ cmd: "pnpm",
151
+ args: ["install", "-g", tool.packageName],
152
+ });
153
+ expect(cliToolUpdate(tool, "yarn")).toEqual({
154
+ cmd: "yarn",
155
+ args: ["global", "add", tool.packageName],
156
+ });
157
+ expect(cliToolUninstall(tool, "pnpm")).toEqual({
158
+ cmd: "pnpm",
159
+ args: ["remove", "-g", tool.packageName],
160
+ });
161
+ expect(cliToolUninstall(tool, "yarn")).toEqual({
162
+ cmd: "yarn",
163
+ args: ["global", "remove", tool.packageName],
164
+ });
165
+ });
166
+
167
+ test("`unknown` returns null rather than guessing", () => {
168
+ // Preserves the observable behaviour: handleInstall reads
169
+ // `installed && updateCommand ? updateCommand : install…`, so null falls
170
+ // back to a plain install — which is what the old `default:` arm did by
171
+ // returning `tool.installCommand`.
172
+ const tool = cliTools[0]!;
173
+ expect(cliToolUpdate(tool, "unknown")).toBeNull();
174
+ expect(cliToolUninstall(tool, "unknown")).toBeNull();
175
+ });
176
+
177
+ test("brew and go arms are pinned, since no catalogue entry exercises them", () => {
178
+ // Every entry today is bun/npm/pip, so these two ship untouched by real
179
+ // data. `go` has no uninstaller — `go install` writes a binary and
180
+ // records nothing — so null is correct rather than an invented `rm`.
181
+ const tool = cliTools[0]!;
182
+ expect(cliToolUpdate(tool, "brew", "some-formula")).toEqual({
183
+ cmd: "brew",
184
+ args: ["upgrade", "some-formula"],
185
+ });
186
+ expect(cliToolUninstall(tool, "brew", "some-formula")).toEqual({
187
+ cmd: "brew",
188
+ args: ["uninstall", "some-formula"],
189
+ });
190
+ });
191
+ });
192
+
193
+ describe("derived commands still read as they did", () => {
194
+ test("claude renders exactly the literal the catalogue used to carry", () => {
195
+ // The entry whose command SHOULD not change. Its old
196
+ // `installCommand: "npm install -g @anthropic-ai/claude-code"` is now
197
+ // derived from packageManager + packageName, and must render identically.
198
+ const claude = cliTools.find((t) => t.name === "claude")!;
199
+ expect(formatCommand(cliToolInstall(claude))).toBe(
200
+ "npm install -g @anthropic-ai/claude-code",
201
+ );
202
+ });
203
+
204
+ test("aider now installs the package its own fields already named", () => {
205
+ // The one deliberate behaviour change. The old command installed
206
+ // `aider-install`, a bootstrapper whose version nothing here ever read,
207
+ // while `packageName`, `checkCommand` and the PyPI query all said
208
+ // `aider-chat`. The installCommand was the field that was out of step.
209
+ const aider = cliTools.find((t) => t.name === "aider")!;
210
+ expect(aider.packageName).toBe("aider-chat");
211
+ expect(cliToolInstall(aider).args).toContain("aider-chat");
212
+ expect(formatCommand(cliToolInstall(aider))).not.toContain("aider-install");
213
+ });
214
+ });
215
+
216
+ describe("versionFromPypiJson", () => {
217
+ // The shape of a real https://pypi.org/pypi/<pkg>/json body, trimmed to the
218
+ // two fields that matter. Captured rather than mocked so the parser is
219
+ // tested against what the endpoint actually returns.
220
+ const body = {
221
+ info: { name: "aider-chat", version: "0.86.1" },
222
+ releases: {},
223
+ };
224
+
225
+ test("reads info.version", () => {
226
+ expect(versionFromPypiJson(body)).toBe("0.86.1");
227
+ });
228
+
229
+ test("anything unexpected yields undefined, never a throw", () => {
230
+ // A version check is advisory: it must degrade to "no update badge",
231
+ // never take the screen down. PyPI returning an error object, a 404 body,
232
+ // or nothing at all all land here.
233
+ expect(versionFromPypiJson({})).toBeUndefined();
234
+ expect(versionFromPypiJson({ info: {} })).toBeUndefined();
235
+ expect(versionFromPypiJson({ info: { version: 42 } })).toBeUndefined();
236
+ expect(versionFromPypiJson(null)).toBeUndefined();
237
+ expect(versionFromPypiJson("not json at all")).toBeUndefined();
238
+ });
239
+ });
@@ -0,0 +1,78 @@
1
+ /**
2
+ * `formatCommand` renders a Command for a human, and must not lie.
3
+ *
4
+ * Two properties, and they pull in opposite directions:
5
+ *
6
+ * 1. **Display neutrality.** Every token claudeup actually builds is
7
+ * shell-safe, so the rendered line must be byte-identical to the string
8
+ * these builders produced before commands became argv. That is what lets
9
+ * item 3 land without touching `install`/`doctor` output.
10
+ * `doctor-bins.test.ts` is the end-to-end gate on that; these are the unit.
11
+ * 2. **A dangerous token must LOOK dangerous.** If a package name ever did
12
+ * contain shell syntax, printing it bare would show the reader a command
13
+ * that does something other than what runs. Quoting it makes the
14
+ * one-token-ness visible.
15
+ */
16
+
17
+ import { describe, expect, test } from "bun:test";
18
+ import { type Command, formatCommand } from "../utils/command-utils.js";
19
+
20
+ const c = (cmd: string, ...args: string[]): Command => ({ cmd, args });
21
+
22
+ describe("formatCommand — display neutrality", () => {
23
+ test("every token this codebase really builds renders unquoted", () => {
24
+ // Sampled from the actual builders: npm scopes, version pins in both
25
+ // syntaxes, go module paths, long flags. If quoting ever fires for one of
26
+ // these, some install/doctor output changed.
27
+ expect(formatCommand(c("bun", "install", "-g", "claudish@1.2.0"))).toBe(
28
+ "bun install -g claudish@1.2.0",
29
+ );
30
+ expect(
31
+ formatCommand(c("npm", "install", "-g", "@anthropic-ai/claude-code")),
32
+ ).toBe("npm install -g @anthropic-ai/claude-code");
33
+ expect(formatCommand(c("uv", "tool", "install", "aider==0.1"))).toBe(
34
+ "uv tool install aider==0.1",
35
+ );
36
+ expect(formatCommand(c("go", "install", "github.com/x/t@latest"))).toBe(
37
+ "go install github.com/x/t@latest",
38
+ );
39
+ expect(
40
+ formatCommand(
41
+ c("python3", "-m", "pip", "install", "--user", "--upgrade", "aider"),
42
+ ),
43
+ ).toBe("python3 -m pip install --user --upgrade aider");
44
+ expect(formatCommand(c("brew", "install", "tmux"))).toBe(
45
+ "brew install tmux",
46
+ );
47
+ });
48
+
49
+ test("a command with no arguments renders as just the executable", () => {
50
+ expect(formatCommand(c("brew"))).toBe("brew");
51
+ });
52
+ });
53
+
54
+ describe("formatCommand — a dangerous token is made visible", () => {
55
+ test("shell metacharacters are single-quoted", () => {
56
+ expect(formatCommand(c("bun", "install", "-g", "a b; rm -rf /"))).toBe(
57
+ "bun install -g 'a b; rm -rf /'",
58
+ );
59
+ expect(formatCommand(c("bun", "install", "-g", "$(id)"))).toBe(
60
+ "bun install -g '$(id)'",
61
+ );
62
+ expect(formatCommand(c("bun", "install", "-g", "a|b"))).toBe(
63
+ "bun install -g 'a|b'",
64
+ );
65
+ });
66
+
67
+ test("an embedded single quote closes, escapes and reopens", () => {
68
+ // `'\''` is the only POSIX-portable way: a single-quoted string has no
69
+ // escape character at all, so the quote must be left and re-entered.
70
+ expect(formatCommand(c("x", "it's"))).toBe(`x 'it'\\''s'`);
71
+ });
72
+
73
+ test("an empty argument is quoted so it does not vanish", () => {
74
+ // Unquoted it would render as nothing, and the reader would be shown a
75
+ // command with fewer arguments than the one that runs.
76
+ expect(formatCommand(c("x", "", "y"))).toBe("x '' y");
77
+ });
78
+ });
@@ -0,0 +1,109 @@
1
+ /**
2
+ * What `claude` subcommand does each plugin operation actually run?
3
+ *
4
+ * This file exists because the answer was wrong for a long time and nothing
5
+ * could see it. `updatePlugin` ran `claude plugin install`, on a comment that
6
+ * asserted install "handles both fresh installs and re-installs (upgrades)".
7
+ * It does not. Measured against Claude Code 2.1.252, with designer@magus
8
+ * installed at 0.6.0 and 0.6.1 published:
9
+ *
10
+ * $ claude plugin install designer@magus --scope project
11
+ * ✔ Plugin "designer@magus" is already installed (scope: project) # exit 0
12
+ *
13
+ * $ claude plugin update designer@magus --scope project
14
+ * ✔ Plugin "designer" updated from 0.6.0 to 0.6.1 for scope project # exit 0
15
+ *
16
+ * Both exit 0. The no-op is indistinguishable from success at every layer
17
+ * above, so every claudeup update path — the TUI's `U`, "update all", the
18
+ * scope menu, `claudeup update`, the prerunner's auto-update — reported
19
+ * success and changed nothing, for ever.
20
+ *
21
+ * Mocking the CLI cannot catch that: a stub answers whatever the test asks it
22
+ * to. Only the argv is checkable here, so the argv is what these assert, with a
23
+ * real `claude` shim on PATH recording what it was called with.
24
+ */
25
+
26
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
27
+ import {
28
+ chmodSync,
29
+ mkdirSync,
30
+ mkdtempSync,
31
+ readFileSync,
32
+ rmSync,
33
+ writeFileSync,
34
+ } from "node:fs";
35
+ import { tmpdir } from "node:os";
36
+ import path from "node:path";
37
+ import {
38
+ installPlugin,
39
+ repairPlugin,
40
+ updatePlugin,
41
+ } from "../services/claude-cli.js";
42
+
43
+ let root: string;
44
+ let logFile: string;
45
+ let savedPath: string | undefined;
46
+
47
+ beforeEach(() => {
48
+ root = mkdtempSync(path.join(tmpdir(), "claude-argv-"));
49
+ const bin = path.join(root, "bin");
50
+ mkdirSync(bin);
51
+ logFile = path.join(root, "argv.log");
52
+
53
+ // A real executable, so this goes through which(1), execFile and the whole
54
+ // wrapper rather than a module double.
55
+ const shim = path.join(bin, "claude");
56
+ writeFileSync(shim, `#!/bin/sh\necho "$@" >> "${logFile}"\nexit 0\n`);
57
+ chmodSync(shim, 0o755);
58
+
59
+ savedPath = process.env.PATH;
60
+ // The shim dir goes FIRST so it shadows a real claude, but the system dirs
61
+ // stay: `getClaudePath` resolves through which(1), which lives in /usr/bin.
62
+ process.env.PATH = [bin, "/usr/bin", "/bin"].join(path.delimiter);
63
+ });
64
+
65
+ afterEach(() => {
66
+ process.env.PATH = savedPath ?? "";
67
+ rmSync(root, { recursive: true, force: true });
68
+ });
69
+
70
+ /** Every argv the shim saw, one line per invocation. */
71
+ function calls(): string[] {
72
+ try {
73
+ return readFileSync(logFile, "utf8").trim().split("\n").filter(Boolean);
74
+ } catch {
75
+ return [];
76
+ }
77
+ }
78
+
79
+ describe("which claude subcommand each operation runs", () => {
80
+ test("updatePlugin runs `plugin update` — NOT `plugin install`", async () => {
81
+ await updatePlugin("designer@magus", "project");
82
+ expect(calls()).toEqual(["plugin update designer@magus --scope project"]);
83
+ });
84
+
85
+ test("installPlugin runs `plugin install`", async () => {
86
+ await installPlugin("designer@magus", "project");
87
+ expect(calls()).toEqual(["plugin install designer@magus --scope project"]);
88
+ });
89
+
90
+ test("repairPlugin uninstalls then installs, in that order", async () => {
91
+ // Content drift under an unchanged version: `update` has no version bump
92
+ // to act on and `install` answers "already installed", so only the
93
+ // uninstall+install pair re-copies the files.
94
+ await repairPlugin("designer@magus", "project", root);
95
+ expect(calls()).toEqual([
96
+ "plugin uninstall designer@magus --scope project",
97
+ "plugin install designer@magus --scope project",
98
+ ]);
99
+ });
100
+
101
+ test("the scope reaches the CLI verbatim, at every scope", async () => {
102
+ await updatePlugin("dev@magus", "user");
103
+ await updatePlugin("dev@magus", "local");
104
+ expect(calls()).toEqual([
105
+ "plugin update dev@magus --scope user",
106
+ "plugin update dev@magus --scope local",
107
+ ]);
108
+ });
109
+ });