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 +4 -4
- package/scripts/test-isolated.ts +7 -1
- package/src/__tests__/catalog-cache-store.test.ts +49 -11
- package/src/__tests__/cli-apply-seams.test.ts +51 -2
- package/src/__tests__/cli-tool-commands.test.ts +239 -0
- package/src/__tests__/format-command.test.ts +78 -0
- package/src/__tests__/plugin-cli-argv.test.ts +109 -0
- package/src/__tests__/shell-script-callers.test.ts +92 -0
- package/src/__tests__/toolchain.test.ts +176 -31
- package/src/__tests__/ui-version-writers.test.ts +88 -0
- package/src/__tests__/update-apply.test.ts +137 -3
- package/src/cli/install.ts +11 -5
- package/src/cli/prompt.ts +0 -9
- package/src/cli/update-view.ts +4 -1
- package/src/cli/update.ts +18 -287
- package/src/data/cli-tools.ts +20 -13
- package/src/services/claude-cli.ts +67 -30
- package/src/services/cli-tool-commands.ts +133 -0
- package/src/services/doctor-bins.ts +9 -3
- package/src/services/toolchain.ts +235 -33
- package/src/services/update-engine.ts +503 -0
- package/src/ui/renderers/cliToolRenderers.tsx +40 -39
- package/src/ui/screens/CliToolsScreen.tsx +56 -64
- package/src/ui/screens/PluginsScreen.tsx +120 -64
- package/src/utils/command-utils.ts +102 -1
- package/src/utils/run.ts +67 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claudeup",
|
|
3
|
-
"version": "6.
|
|
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.
|
|
68
|
-
"claudeup-darwin-x64": "6.
|
|
69
|
-
"claudeup-linux-x64": "6.
|
|
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
|
}
|
package/scripts/test-isolated.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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
|
+
});
|