claudeup 5.0.1 → 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.
Files changed (34) hide show
  1. package/package.json +4 -4
  2. package/scripts/test-isolated.ts +7 -1
  3. package/src/__tests__/cli-ansi.test.ts +297 -0
  4. package/src/__tests__/cli-apply-seams.test.ts +281 -0
  5. package/src/__tests__/cli-live.test.ts +384 -0
  6. package/src/__tests__/cli-tool-commands.test.ts +239 -0
  7. package/src/__tests__/cli-update-view.test.ts +286 -0
  8. package/src/__tests__/format-command.test.ts +78 -0
  9. package/src/__tests__/gitignore-fixer.test.ts +20 -1
  10. package/src/__tests__/marketplace-refresh.test.ts +63 -0
  11. package/src/__tests__/shell-script-callers.test.ts +92 -0
  12. package/src/__tests__/toolchain.test.ts +176 -31
  13. package/src/__tests__/ui-version-writers.test.ts +88 -0
  14. package/src/__tests__/update-apply.test.ts +46 -1
  15. package/src/cli/ansi.ts +512 -0
  16. package/src/cli/bootstrap.ts +0 -6
  17. package/src/cli/install.ts +15 -6
  18. package/src/cli/live.ts +374 -0
  19. package/src/cli/profile.ts +1 -1
  20. package/src/cli/prompt.ts +0 -9
  21. package/src/cli/router.ts +7 -3
  22. package/src/cli/update-view.ts +441 -0
  23. package/src/cli/update.ts +357 -298
  24. package/src/data/cli-tools.ts +20 -13
  25. package/src/services/cli-tool-commands.ts +133 -0
  26. package/src/services/doctor-bins.ts +18 -4
  27. package/src/services/marketplace-refresh.ts +39 -1
  28. package/src/services/toolchain.ts +235 -33
  29. package/src/services/update-engine.ts +412 -0
  30. package/src/ui/renderers/cliToolRenderers.tsx +40 -39
  31. package/src/ui/screens/CliToolsScreen.tsx +56 -64
  32. package/src/ui/screens/PluginsScreen.tsx +119 -64
  33. package/src/utils/command-utils.ts +102 -1
  34. package/src/utils/run.ts +67 -0
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Tests for the `claudeup update` report.
3
+ *
4
+ * These assert the things a screenshot cannot: that every row lines up at every
5
+ * data shape, that turning colour off leaves a report someone can still read,
6
+ * and that no bar lies about what it measures.
7
+ *
8
+ * The alignment tests exist because the old report built each row with string
9
+ * concatenation and `String.padEnd`, which counts escape bytes as columns. Add
10
+ * one colour to one cell and every column below it shifts — invisible to a
11
+ * character-level assertion, obvious and ugly on screen.
12
+ */
13
+
14
+ import { afterEach, describe, expect, test } from "bun:test";
15
+ import { setColorEnabled, stripAnsi, width } from "../cli/ansi.js";
16
+ import type { UpdatePlan } from "../services/update-plan.js";
17
+ import {
18
+ type ApplyState,
19
+ type Step,
20
+ applyFrame,
21
+ applyRow,
22
+ header,
23
+ planReport,
24
+ stepFrame,
25
+ stepRecord,
26
+ summaryLine,
27
+ } from "../cli/update-view.js";
28
+
29
+ afterEach(() => setColorEnabled(null));
30
+
31
+ // -- fixtures -----------------------------------------------------------------
32
+
33
+ function plan(over: Partial<UpdatePlan> = {}): UpdatePlan {
34
+ return {
35
+ profileId: "default",
36
+ plugins: [
37
+ {
38
+ pluginId: "agentdev@magus",
39
+ pinned: "latest",
40
+ installed: "1.7.0",
41
+ available: "1.7.0",
42
+ target: null,
43
+ action: "current",
44
+ scopes: ["project"],
45
+ },
46
+ {
47
+ pluginId: "dev@magus",
48
+ pinned: "latest",
49
+ installed: "4.6.1",
50
+ available: "5.0.0",
51
+ target: "5.0.0",
52
+ action: "update",
53
+ scopes: ["project"],
54
+ },
55
+ {
56
+ pluginId: "feature-dev@claude-plugins-official",
57
+ pinned: "latest",
58
+ installed: null,
59
+ available: null,
60
+ target: null,
61
+ action: "install",
62
+ scopes: [],
63
+ },
64
+ {
65
+ pluginId: "seo@magus",
66
+ pinned: "latest",
67
+ installed: "1.8.0",
68
+ available: null,
69
+ target: null,
70
+ action: "unknown",
71
+ scopes: ["project"],
72
+ note: "catalog unreachable",
73
+ },
74
+ ],
75
+ bins: [
76
+ { name: "tmux-mcp", action: "current", version: "v1.7.1", sources: [] },
77
+ { name: "tmux", action: "upgrade", sources: [] },
78
+ ],
79
+ skills: [
80
+ {
81
+ name: "release",
82
+ action: "install",
83
+ ref: { name: "release", repo: "MadAppGang/magus", path: "s/SKILL.md" },
84
+ },
85
+ ],
86
+ binSpecs: new Map([
87
+ ["tmux-mcp", { name: "tmux-mcp", via: "bun", sources: [] }],
88
+ ["tmux", { name: "tmux", via: "brew", formula: "tmux", sources: [] }],
89
+ ]),
90
+ ...over,
91
+ } as UpdatePlan;
92
+ }
93
+
94
+ function steps(): Step[] {
95
+ return [
96
+ {
97
+ label: "profile",
98
+ state: "done",
99
+ detail: "4 plugins",
100
+ startedAt: 0,
101
+ endedAt: 100,
102
+ },
103
+ {
104
+ label: "marketplaces",
105
+ state: "running",
106
+ detail: "pulling magus",
107
+ startedAt: 100,
108
+ progress: { done: 1, total: 4 },
109
+ },
110
+ { label: "catalog", state: "pending", detail: "" },
111
+ ];
112
+ }
113
+
114
+ // -- alignment ----------------------------------------------------------------
115
+
116
+ describe("alignment", () => {
117
+ test("every plugin row shares one column layout, colour or not", () => {
118
+ setColorEnabled(true);
119
+ const rows = planReport(plan())
120
+ .filter((l) => /@/.test(stripAnsi(l)))
121
+ .map((l) => stripAnsi(l));
122
+ // Every row's version column starts at the same visible offset, whatever
123
+ // mix of badges, dim text and notes the rows carry.
124
+ const offsets = rows.map((r) => r.indexOf("1.") + r.indexOf("latest"));
125
+ expect(offsets.length).toBeGreaterThan(0);
126
+ const versionCol = rows.map((r) =>
127
+ r.search(/(1\.7\.0|4\.6\.1|latest|1\.8\.0)/),
128
+ );
129
+ expect(new Set(versionCol).size).toBe(1);
130
+ });
131
+
132
+ test("the same row is the same visible width with colour on and off", () => {
133
+ setColorEnabled(false);
134
+ const plain = planReport(plan()).map((l) => width(l));
135
+ setColorEnabled(true);
136
+ const painted = planReport(plan()).map((l) => width(l));
137
+ expect(painted).toEqual(plain);
138
+ });
139
+
140
+ test("a very long plugin id widens the column for every row", () => {
141
+ setColorEnabled(false);
142
+ const long = "a-very-long-plugin-name-indeed@some-marketplace";
143
+ const p = plan();
144
+ p.plugins[0]!.pluginId = long;
145
+ const rows = planReport(p).filter((l) => l.includes("@"));
146
+ const cols = rows.map((r) => r.search(/(1\.7\.0|4\.6\.1|latest|1\.8\.0)/));
147
+ expect(new Set(cols).size).toBe(1);
148
+ expect(rows[0]).toContain(long);
149
+ });
150
+ });
151
+
152
+ // -- readability without colour ----------------------------------------------
153
+
154
+ describe("NO_COLOR", () => {
155
+ test("the report emits no escape sequence at all", () => {
156
+ setColorEnabled(false);
157
+ const out = [
158
+ ...header("default"),
159
+ ...stepFrame(steps(), 3, 500),
160
+ ...stepRecord(steps(), 500),
161
+ ...planReport(plan()),
162
+ ...applyFrame({ done: 1, total: 4, current: "dev@magus", startedAt: 0 }, 2, 900),
163
+ applyRow(true, "dev@magus", 20, "project", 400),
164
+ ...summaryLine(2, 5, 1, 0, 9000),
165
+ ].join("\n");
166
+ expect(out).not.toContain("\x1b");
167
+ });
168
+
169
+ test("every action stays distinguishable without colour", () => {
170
+ setColorEnabled(false);
171
+ const out = planReport(plan()).join("\n");
172
+ // Colour never carries meaning alone: each action has its own word AND
173
+ // its own glyph, so the report survives a pipe and colour blindness.
174
+ for (const label of ["CURRENT", "UPDATE", "INSTALL", "UNKNOWN", "UPGRADE"])
175
+ expect(out).toContain(label);
176
+ for (const glyph of ["=", "↑", "+", "?"]) expect(out).toContain(glyph);
177
+ });
178
+
179
+ test("a note rides along on the row it explains", () => {
180
+ setColorEnabled(false);
181
+ const row = planReport(plan()).find((l) => l.includes("seo@magus"))!;
182
+ expect(row).toContain("catalog unreachable");
183
+ });
184
+ });
185
+
186
+ // -- the bars tell the truth --------------------------------------------------
187
+
188
+ describe("bars", () => {
189
+ test("a running step with a denominator gets a determinate meter", () => {
190
+ setColorEnabled(false);
191
+ const frame = stepFrame(steps(), 0, 500);
192
+ // 1 of 4 → a quarter of the 18-cell bar filled.
193
+ const bar = frame[1]!.match(/[█░]+/)?.[0] ?? "";
194
+ expect(bar).toHaveLength(18);
195
+ expect(bar.split("█").length - 1).toBe(5);
196
+ });
197
+
198
+ test("a running step with no denominator gets a sweep, not a fake meter", () => {
199
+ setColorEnabled(false);
200
+ const s = steps();
201
+ s[1]!.progress = undefined;
202
+ const bar = stepFrame(s, 3, 500)[1]!.match(/[█░]+/)?.[0] ?? "";
203
+ expect(bar).toHaveLength(18);
204
+ // A sweep is partly filled at every tick; a "0%" meter would be all track.
205
+ expect(bar).toContain("█");
206
+ expect(bar).toContain("░");
207
+ });
208
+
209
+ test("only the running row carries a bar in the live frame", () => {
210
+ setColorEnabled(false);
211
+ const frame = stepFrame(steps(), 0, 500);
212
+ expect(frame[0]).not.toMatch(/[█░]/);
213
+ expect(frame[1]).toMatch(/[█░]/);
214
+ expect(frame[2]).not.toMatch(/[█░]/);
215
+ });
216
+
217
+ test("the record scales each step against the slowest, not the total", () => {
218
+ setColorEnabled(false);
219
+ const record = stepRecord(
220
+ [
221
+ { label: "fast", state: "done", detail: "", startedAt: 0, endedAt: 100 },
222
+ { label: "slow", state: "done", detail: "", startedAt: 0, endedAt: 1000 },
223
+ ],
224
+ 1000,
225
+ );
226
+ const bars = record.map((r) => r.match(/[█░]+/)![0]);
227
+ expect(bars[1]!.split("█").length - 1).toBe(18);
228
+ expect(bars[0]!.split("█").length - 1).toBe(2);
229
+ });
230
+
231
+ test("a step that never ran does not divide by zero", () => {
232
+ setColorEnabled(false);
233
+ const record = stepRecord([{ label: "x", state: "pending", detail: "" }], 0);
234
+ expect(record[0]).toContain("░");
235
+ });
236
+
237
+ test("the apply meter is full exactly when the work is done", () => {
238
+ setColorEnabled(false);
239
+ const state: ApplyState = { done: 4, total: 4, current: "", startedAt: 0 };
240
+ const bar = applyFrame(state, 0, 100)[1]!.match(/[█░]+/)![0];
241
+ expect(bar).toBe("█".repeat(36));
242
+ });
243
+
244
+ test("an empty apply reads as complete rather than as stuck at zero", () => {
245
+ setColorEnabled(false);
246
+ const state: ApplyState = { done: 0, total: 0, current: "", startedAt: 0 };
247
+ expect(applyFrame(state, 0, 100)[1]).toContain("█".repeat(36));
248
+ });
249
+
250
+ test("the summary bar survives an all-zero run", () => {
251
+ setColorEnabled(false);
252
+ const line = summaryLine(0, 0, 0, 0, 100)[1]!;
253
+ expect(line.match(/[█░]+/)![0]).toHaveLength(36);
254
+ });
255
+ });
256
+
257
+ // -- misc ---------------------------------------------------------------------
258
+
259
+ describe("sections", () => {
260
+ test("an empty section is omitted entirely, not printed as a heading", () => {
261
+ setColorEnabled(false);
262
+ const out = planReport(
263
+ plan({ bins: [], skills: [] }) as UpdatePlan,
264
+ ).join("\n");
265
+ expect(out).toContain("Plugins");
266
+ expect(out).not.toContain("CLI");
267
+ expect(out).not.toContain("Skills");
268
+ });
269
+
270
+ test("a section legend counts every action it contains", () => {
271
+ setColorEnabled(false);
272
+ const line = planReport(plan()).find((l) => l.includes("Plugins"))!;
273
+ expect(line).toContain("1 current");
274
+ expect(line).toContain("1 update");
275
+ expect(line).toContain("1 install");
276
+ expect(line).toContain("1 unknown");
277
+ });
278
+
279
+ test("a CLI upgrade shows the command that will run", () => {
280
+ setColorEnabled(false);
281
+ const row = planReport(plan()).find(
282
+ (l) => l.includes("tmux ") && l.includes("UPGRADE"),
283
+ )!;
284
+ expect(row).toContain("brew upgrade tmux");
285
+ });
286
+ });
@@ -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
+ });
@@ -10,8 +10,27 @@ import {
10
10
  } from "../services/gitignore-fixer";
11
11
  import type { Violation } from "../types/gitignore";
12
12
 
13
+ // `commit.gpgsign=false` is not tidiness — without it these tests HANG.
14
+ //
15
+ // Two tests here run `git commit` in a temp repo, which inherits the
16
+ // developer's global config. On any machine with commit signing enabled and a
17
+ // GUI signer (`gpg.format=ssh` pointing at 1Password's `op-ssh-sign` is the
18
+ // common setup here), the commit blocks waiting for an approval dialog nobody
19
+ // is watching, and both tests fail on a 5s timeout. It presents as a flake
20
+ // because the approval is cached once granted, so the suite goes green until
21
+ // the cache expires. `marketplace-refresh.test.ts` already guards this the same
22
+ // way; this file did not.
23
+ const GIT_CFG = [
24
+ "-c",
25
+ "core.excludesFile=/dev/null",
26
+ "-c",
27
+ "commit.gpgsign=false",
28
+ "-c",
29
+ "init.defaultBranch=main",
30
+ ];
31
+
13
32
  function git(cwd: string, ...args: string[]): { stdout: string; status: number } {
14
- const r = spawnSync("git", ["-c", "core.excludesFile=/dev/null", ...args], {
33
+ const r = spawnSync("git", [...GIT_CFG, ...args], {
15
34
  cwd,
16
35
  encoding: "utf8",
17
36
  });
@@ -243,6 +243,69 @@ describe("refreshRegisteredMarketplaces — selection & skips", () => {
243
243
  });
244
244
  });
245
245
 
246
+ describe("refreshRegisteredMarketplaces — progress taps", () => {
247
+ it("reports the eligible set before pulling, then each clone as it settles", async () => {
248
+ // The whole reason these exist: this call is the slowest thing on the
249
+ // update path and used to report only once every clone had settled.
250
+ installClone("magus");
251
+ installClone("other");
252
+ publishV2();
253
+ configured = { magus: gh(), other: gh("x/y") };
254
+
255
+ const started: string[][] = [];
256
+ const settled: Array<[string, string]> = [];
257
+ const r = await refreshRegisteredMarketplaces([], {
258
+ onStart: (names) => started.push([...names]),
259
+ onSettled: (name, status) => settled.push([name, status]),
260
+ });
261
+
262
+ expect(started).toHaveLength(1);
263
+ expect(started[0]!.sort()).toEqual(["magus", "other"]);
264
+ // One event per eligible clone, and the statuses agree with the result.
265
+ expect(settled.map(([n]) => n).sort()).toEqual(["magus", "other"]);
266
+ expect(settled.every(([, s]) => s === "refreshed")).toBe(true);
267
+ expect(r.refreshed.sort()).toEqual(["magus", "other"]);
268
+ });
269
+
270
+ it("excludes an autoUpdate-disabled marketplace from the denominator", async () => {
271
+ // The progress meter's total comes from onStart. Counting a marketplace
272
+ // that is never pulled would leave the bar permanently short.
273
+ installClone("magus");
274
+ installClone("other");
275
+ publishV2();
276
+ configured = { magus: gh(), other: gh("x/y") };
277
+ autoUpdate = { magus: false };
278
+
279
+ const started: string[][] = [];
280
+ await refreshRegisteredMarketplaces([], {
281
+ onStart: (names) => started.push([...names]),
282
+ });
283
+
284
+ expect(started[0]).toEqual(["other"]);
285
+ });
286
+
287
+ it("a throwing observer cannot fail the refresh", async () => {
288
+ // onSettled fires inside the Promise.all fan-out, so an unguarded throw
289
+ // would reject the batch — turning a display bug into a failed refresh
290
+ // and breaking this function's documented "never throws" contract.
291
+ installClone("magus");
292
+ publishV2();
293
+ configured = { magus: gh() };
294
+
295
+ const r = await refreshRegisteredMarketplaces([], {
296
+ onStart: () => {
297
+ throw new Error("display exploded");
298
+ },
299
+ onSettled: () => {
300
+ throw new Error("display exploded again");
301
+ },
302
+ });
303
+
304
+ expect(r.refreshed).toEqual(["magus"]);
305
+ expect(r.failed).toEqual([]);
306
+ });
307
+ });
308
+
246
309
  describe("fastForwardCloneIfPresent — single-clone helper (shared with claude-cli recovery)", () => {
247
310
  it("returns 'absent' when no clone is on disk (caller then clones via add)", async () => {
248
311
  expect(await fastForwardCloneIfPresent("magus")).toBe("absent");
@@ -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
+ });