claudeup 6.0.0 → 6.1.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__/cli-apply-seams.test.ts +50 -2
- package/src/__tests__/cli-tool-commands.test.ts +239 -0
- package/src/__tests__/format-command.test.ts +78 -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 +46 -1
- 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/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 +412 -0
- package/src/ui/renderers/cliToolRenderers.tsx +40 -39
- package/src/ui/screens/CliToolsScreen.tsx +56 -64
- package/src/ui/screens/PluginsScreen.tsx +119 -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.1.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.1.0",
|
|
68
|
+
"claudeup-darwin-x64": "6.1.0",
|
|
69
|
+
"claudeup-linux-x64": "6.1.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
|
}
|
|
@@ -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";
|
|
@@ -174,6 +175,53 @@ describe("flags", () => {
|
|
|
174
175
|
});
|
|
175
176
|
});
|
|
176
177
|
|
|
178
|
+
describe("installPluginInScope — the scope-targeted path", () => {
|
|
179
|
+
test("records the version READ BACK, not the one expected", async () => {
|
|
180
|
+
// This is the bug the TUI's per-scope menu carried after the CLI had
|
|
181
|
+
// fixed it: it wrote the version it *expected*, so `install --check`
|
|
182
|
+
// compared a pin against a copy of itself and reported clean forever.
|
|
183
|
+
const d = deps();
|
|
184
|
+
d.readInstalled = mock(async () => "9.9.9");
|
|
185
|
+
const saved: unknown[] = [];
|
|
186
|
+
d.saveInstalled = mock(async (id, version, scope) => {
|
|
187
|
+
saved.push({ id, version, scope });
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const actual = await installPluginInScope("dev@magus", "user", PROJECT, d);
|
|
191
|
+
|
|
192
|
+
expect(actual).toBe("9.9.9");
|
|
193
|
+
expect(saved).toEqual([
|
|
194
|
+
{ id: "dev@magus", version: "9.9.9", scope: "user" },
|
|
195
|
+
]);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("honours the caller's scope rather than fanning out", async () => {
|
|
199
|
+
// The whole reason this exists beside applyPlugins: the user picked a
|
|
200
|
+
// scope from a menu, so "every scope that is behind" would override them.
|
|
201
|
+
const d = deps();
|
|
202
|
+
const updated: Array<[string, string]> = [];
|
|
203
|
+
d.update = mock(async (id, scope) => {
|
|
204
|
+
updated.push([id, scope]);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
await installPluginInScope("dev@magus", "local", PROJECT, d);
|
|
208
|
+
|
|
209
|
+
expect(updated).toEqual([["dev@magus", "local"]]);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("an unreadable version is reported, never guessed", async () => {
|
|
213
|
+
const d = deps();
|
|
214
|
+
d.readInstalled = mock(async () => null);
|
|
215
|
+
const saved: unknown[] = [];
|
|
216
|
+
d.saveInstalled = mock(async () => {
|
|
217
|
+
saved.push(1);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
expect(await installPluginInScope("x", "user", PROJECT, d)).toBeNull();
|
|
221
|
+
expect(saved).toEqual([]);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
177
225
|
describe("checkBinaries onProbe", () => {
|
|
178
226
|
const bins: ResolvedBin[] = [
|
|
179
227
|
{ 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,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `runShellScript` has exactly one legitimate caller, and this pins it.
|
|
3
|
+
*
|
|
4
|
+
* The whole of item 3 is the split between two ways of running a child process:
|
|
5
|
+
* argv (`runCommand`), which no shell ever sees, and a script (`runShellScript`),
|
|
6
|
+
* which `/bin/sh` parses. Every installer command belongs to the first, because
|
|
7
|
+
* it is assembled from package names that arrive in `.claude/profiles.json` and
|
|
8
|
+
* plugin `requires.bin` — hand-authored, committed by teammates, fetched from
|
|
9
|
+
* marketplaces, validated by nothing. The second exists only for the two
|
|
10
|
+
* `TOOLCHAIN_BOOTSTRAP` values, which are constants in this repo copied from
|
|
11
|
+
* bun.sh and brew.sh and which genuinely contain a pipe and a command
|
|
12
|
+
* substitution.
|
|
13
|
+
*
|
|
14
|
+
* Nothing in the type system distinguishes them — both are strings — so after
|
|
15
|
+
* the commit lands, the design decision is enforced by this test or by nothing.
|
|
16
|
+
* A branded `ShellScript` type would enforce it at compile time and was
|
|
17
|
+
* considered; for one map, two entries and one call site it is more machinery
|
|
18
|
+
* than the rule is worth, and unlike a brand this is greppable.
|
|
19
|
+
*
|
|
20
|
+
* Source-scanning precedent in this suite: `uppercase-keybindings.test.ts`.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { describe, expect, test } from "bun:test";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import fs from "fs-extra";
|
|
26
|
+
|
|
27
|
+
const SRC_DIR = path.join(import.meta.dir, "..");
|
|
28
|
+
|
|
29
|
+
/** Files permitted to call `runShellScript`, relative to `src/`. */
|
|
30
|
+
const ALLOWED = ["cli/install.ts"];
|
|
31
|
+
|
|
32
|
+
/** The definition itself, plus this test, are not callers. */
|
|
33
|
+
const NOT_CALLERS = ["utils/run.ts", "__tests__/shell-script-callers.test.ts"];
|
|
34
|
+
|
|
35
|
+
async function sourceFiles(dir: string): Promise<string[]> {
|
|
36
|
+
const out: string[] = [];
|
|
37
|
+
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
|
|
38
|
+
const full = path.join(dir, entry.name);
|
|
39
|
+
if (entry.isDirectory()) out.push(...(await sourceFiles(full)));
|
|
40
|
+
else if (/\.tsx?$/.test(entry.name)) out.push(full);
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const isComment = (line: string) => {
|
|
46
|
+
const t = line.trim();
|
|
47
|
+
return t.startsWith("//") || t.startsWith("*") || t.startsWith("/*");
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
async function callersOf(pattern: RegExp): Promise<string[]> {
|
|
51
|
+
const found = new Set<string>();
|
|
52
|
+
for (const file of await sourceFiles(SRC_DIR)) {
|
|
53
|
+
const rel = path.relative(SRC_DIR, file);
|
|
54
|
+
if (NOT_CALLERS.includes(rel)) continue;
|
|
55
|
+
const text = await fs.readFile(file, "utf8");
|
|
56
|
+
for (const line of text.split("\n")) {
|
|
57
|
+
if (isComment(line)) continue;
|
|
58
|
+
if (pattern.test(line)) found.add(rel);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return [...found].sort();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe("only the toolchain bootstrap goes through a shell", () => {
|
|
65
|
+
test("runShellScript is called from exactly the allowed files", async () => {
|
|
66
|
+
expect(await callersOf(/\brunShellScript\(/)).toEqual(ALLOWED);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("nothing spawns with `shell: true` outside utils/run.ts", async () => {
|
|
70
|
+
// Catches someone reaching for `spawn`/`exec` directly rather than
|
|
71
|
+
// through the seam — a second shell path that never names
|
|
72
|
+
// `runShellScript`.
|
|
73
|
+
//
|
|
74
|
+
// Scope, stated honestly: this checks `shell: true` only. Several
|
|
75
|
+
// version-DETECTION helpers in CliToolsScreen still pass
|
|
76
|
+
// `shell: "/bin/bash"` to run pipelines like `which -a x 2>/dev/null`.
|
|
77
|
+
// Those interpolate a catalogue `name`, not an installer command, and
|
|
78
|
+
// were out of scope here — so this assertion would be false if it
|
|
79
|
+
// claimed no shell exists anywhere.
|
|
80
|
+
expect(await callersOf(/shell:\s*true/)).toEqual([]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("the scan can actually fail", async () => {
|
|
84
|
+
// Negative control. The two assertions above pass trivially if the walk
|
|
85
|
+
// finds nothing or the pattern never matches, and a check that cannot
|
|
86
|
+
// fail is not evidence. `runCommand(` is known to be called from several
|
|
87
|
+
// files, so a working scan must find them.
|
|
88
|
+
const runCommandCallers = await callersOf(/\brunCommand\(/);
|
|
89
|
+
expect(runCommandCallers.length).toBeGreaterThan(0);
|
|
90
|
+
expect(runCommandCallers).toContain("cli/install.ts");
|
|
91
|
+
});
|
|
92
|
+
});
|