magus-cli 7.8.0 → 7.9.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 (78) hide show
  1. package/README.md +1 -1
  2. package/package.json +4 -4
  3. package/src/__tests__/blackbox-parity.test.ts +16 -11
  4. package/src/__tests__/blind/plugin-deps/doctor.test.ts +622 -0
  5. package/src/__tests__/blind/plugin-deps/helpers.ts +340 -0
  6. package/src/__tests__/blind/plugin-deps/hook.test.ts +328 -0
  7. package/src/__tests__/blind/plugin-deps/probe.test.ts +694 -0
  8. package/src/__tests__/blind/plugin-deps/read-only.test.ts +96 -0
  9. package/src/__tests__/blind/plugin-deps/schema.test.ts +390 -0
  10. package/src/__tests__/blind-780-cli-tools.test.ts +36 -48
  11. package/src/__tests__/blind-780-review2.test.ts +0 -25
  12. package/src/__tests__/cli-apply-seams.test.ts +2 -67
  13. package/src/__tests__/cli-tool-commands.test.ts +131 -6
  14. package/src/__tests__/cli-tools-adapter.test.ts +52 -2
  15. package/src/__tests__/cli-tools-plugin-managed.test.tsx +83 -0
  16. package/src/__tests__/deps-adapter.test.ts +191 -0
  17. package/src/__tests__/deps-check-hook.test.ts +220 -0
  18. package/src/__tests__/deps-plan.test.ts +896 -0
  19. package/src/__tests__/deps-report.test.ts +197 -0
  20. package/src/__tests__/doctor-bins.test.ts +134 -36
  21. package/src/__tests__/doctor-json.test.ts +261 -0
  22. package/src/__tests__/failure-popup-review2.test.tsx +1 -25
  23. package/src/__tests__/final-fixes-780.test.tsx +25 -9
  24. package/src/__tests__/fixtures/bins.ts +55 -0
  25. package/src/__tests__/install-plan.test.ts +15 -1
  26. package/src/__tests__/marketplace-refresh.test.ts +9 -3
  27. package/src/__tests__/no-shell-spawn.test.ts +77 -0
  28. package/src/__tests__/npm-package-name.test.ts +6 -9
  29. package/src/__tests__/plugin-requires.test.ts +138 -153
  30. package/src/__tests__/requires-probe.test.ts +858 -0
  31. package/src/__tests__/resolver.test.ts +129 -52
  32. package/src/__tests__/run.test.ts +108 -0
  33. package/src/__tests__/tool-probe.test.ts +1 -1
  34. package/src/__tests__/toolchain.test.ts +123 -52
  35. package/src/__tests__/update-plan.test.ts +1 -1
  36. package/src/cli/ansi.ts +2 -7
  37. package/src/cli/deps-view.ts +213 -0
  38. package/src/cli/doctor.ts +169 -24
  39. package/src/cli/install.ts +51 -66
  40. package/src/cli/profile.ts +20 -2
  41. package/src/cli/router.ts +6 -2
  42. package/src/cli/update.ts +28 -7
  43. package/src/data/cli-tools.ts +24 -9
  44. package/src/services/active-closure.ts +3 -3
  45. package/src/services/claude-settings.ts +4 -348
  46. package/src/services/cli-tool-commands.ts +104 -3
  47. package/src/services/deps-plan.ts +962 -0
  48. package/src/services/deps-report.ts +265 -0
  49. package/src/services/doctor-bins.ts +115 -43
  50. package/src/services/install-plan.ts +11 -1
  51. package/src/services/local-marketplace.ts +12 -206
  52. package/src/services/marketplace-refresh.ts +4 -4
  53. package/src/services/plugin-manager.ts +4 -18
  54. package/src/services/plugin-requires.ts +93 -162
  55. package/src/services/resolver.ts +168 -58
  56. package/src/services/tool-probe.ts +18 -25
  57. package/src/services/toolchain.ts +117 -111
  58. package/src/services/update-engine.ts +19 -14
  59. package/src/services/update-plan.ts +3 -3
  60. package/src/shared/plugin/hooks/bun-missing.sh +39 -0
  61. package/src/shared/plugin/hooks/deps-check.ts +224 -0
  62. package/src/shared/plugin/hooks/lib/requires-probe.ts +1183 -0
  63. package/src/types/index.ts +53 -16
  64. package/src/ui/App.tsx +6 -40
  65. package/src/ui/adapters/cliToolsAdapter.ts +75 -37
  66. package/src/ui/adapters/depsAdapter.ts +129 -0
  67. package/src/ui/adapters/startupReports.ts +0 -22
  68. package/src/ui/renderers/cliToolRenderers.tsx +135 -2
  69. package/src/ui/screens/CliToolsScreen.tsx +141 -51
  70. package/src/ui/screens/PluginsScreen.tsx +12 -48
  71. package/src/utils/command-utils.ts +4 -5
  72. package/src/utils/run.ts +123 -52
  73. package/src/utils/string-utils.ts +9 -0
  74. package/src/__tests__/go-module.test.ts +0 -43
  75. package/src/__tests__/plugin-setup.test.ts +0 -1016
  76. package/src/__tests__/shell-script-callers.test.ts +0 -92
  77. package/src/services/plugin-setup.ts +0 -965
  78. package/src/utils/go-module.ts +0 -36
@@ -0,0 +1,896 @@
1
+ /**
2
+ * The deps executor: what `planDeps` plans and in what order, and what
3
+ * `applyDepsPlan` runs — root decided once, never a prompt, releases verified
4
+ * against the pinned digest, the uv-module guard, blocked steps, consent and
5
+ * the single-apply lock. Every side effect goes through an injected `ApplyIO`
6
+ * except where a real `tar` is the point; HOME and PATH are temp dirs.
7
+ */
8
+
9
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
10
+ import { createHash } from "node:crypto";
11
+ import {
12
+ chmodSync,
13
+ existsSync,
14
+ mkdirSync,
15
+ mkdtempSync,
16
+ readFileSync,
17
+ readdirSync,
18
+ rmSync,
19
+ statSync,
20
+ symlinkSync,
21
+ writeFileSync,
22
+ } from "node:fs";
23
+ import { tmpdir } from "node:os";
24
+ import { join } from "node:path";
25
+ import { renderApplyText } from "../cli/deps-view.js";
26
+ import {
27
+ type ApplyIO,
28
+ type ApplyOptions,
29
+ DepsLockBusy,
30
+ type DepsPlan,
31
+ type PlanEnv,
32
+ REAL_APPLY_IO,
33
+ applyDepsPlan,
34
+ planDeps,
35
+ planLines,
36
+ } from "../services/deps-plan.js";
37
+ import { cliToolToBin } from "../services/resolver.js";
38
+ import {
39
+ type InstallOption,
40
+ type Presence,
41
+ parseRequires,
42
+ } from "../shared/plugin/hooks/lib/requires-probe.js";
43
+ import type { ResolvedBin } from "../types/index.js";
44
+ import type { RunOptions, RunResult } from "../utils/run.js";
45
+
46
+ let root: string;
47
+ let bin: string;
48
+ let env: PlanEnv;
49
+ beforeEach(() => {
50
+ root = mkdtempSync(join(tmpdir(), "deps-plan-"));
51
+ bin = join(root, "bin");
52
+ mkdirSync(bin);
53
+ mkdirSync(join(root, "home"));
54
+ env = {
55
+ os: "linux",
56
+ arch: "amd64",
57
+ home: join(root, "home"),
58
+ path: bin,
59
+ userShell: "/bin/zsh",
60
+ };
61
+ });
62
+ afterEach(() => rmSync(root, { recursive: true, force: true }));
63
+
64
+ function tool(dir: string, name: string): void {
65
+ mkdirSync(dir, { recursive: true });
66
+ writeFileSync(join(dir, name), "#!/bin/sh\nexit 0\n");
67
+ chmodSync(join(dir, name), 0o755);
68
+ }
69
+
70
+ function dep(
71
+ name: string,
72
+ option: InstallOption,
73
+ plugin = "terminal@magus",
74
+ extra: Partial<ResolvedBin> = {},
75
+ ): ResolvedBin {
76
+ return {
77
+ name,
78
+ check: [name, "--version"],
79
+ install: [option],
80
+ chosen: option,
81
+ sources: [`plugin:${plugin}`],
82
+ ...extra,
83
+ };
84
+ }
85
+
86
+ const missing = (name: string): Presence => ({ name, status: "missing" });
87
+ const present = (name: string): Presence => ({ name, status: "present" });
88
+
89
+ const UV = dep("uv", { via: "bootstrap", tool: "uv" }, "browser-use@magus");
90
+ const LIBS = dep(
91
+ "chromium-libs",
92
+ {
93
+ via: "uv-module",
94
+ script: "scripts/mcp-server.py",
95
+ module: "playwright",
96
+ args: ["install-deps", "chromium"],
97
+ root: true,
98
+ },
99
+ "browser-use@magus",
100
+ { root: "/plugins/browser-use" },
101
+ );
102
+ const CHROMIUM = dep(
103
+ "chromium",
104
+ {
105
+ via: "uv-module",
106
+ script: "scripts/mcp-server.py",
107
+ module: "playwright",
108
+ args: ["install", "chromium"],
109
+ },
110
+ "browser-use@magus",
111
+ { root: "/plugins/browser-use" },
112
+ );
113
+ const TMUX = dep("tmux", { via: "apt", packages: ["tmux"] });
114
+ const ZSH = dep("zsh", { via: "apt", packages: ["zsh"] });
115
+ const CLAUDISH = dep(
116
+ "claudish",
117
+ { via: "bun", package: "claudish" },
118
+ "claudish@magus",
119
+ );
120
+ const BUN = dep("bun", { via: "bootstrap", tool: "bun" }, "claudish@magus");
121
+
122
+ const names = (plan: DepsPlan) => plan.steps.map((p) => p.step.name);
123
+
124
+ // ─── planning ─────────────────────────────────────────────────────────────────
125
+
126
+ describe("planDeps", () => {
127
+ test("toolchains first, then the root batch (apt merged, then root uv-module), then declaration order", () => {
128
+ for (const t of ["curl", "unzip", "apt-get"]) tool(bin, t);
129
+ const bins = [CLAUDISH, CHROMIUM, TMUX, LIBS, ZSH, BUN, UV];
130
+ const plan = planDeps(
131
+ bins,
132
+ bins.map((b) => missing(b.name)),
133
+ env,
134
+ );
135
+ expect(names(plan)).toEqual([
136
+ "bun",
137
+ "uv",
138
+ "tmux, zsh",
139
+ "chromium-libs",
140
+ "claudish",
141
+ "chromium",
142
+ ]);
143
+ const apt = plan.steps[2]?.step;
144
+ expect(apt).toEqual({
145
+ kind: "argv",
146
+ name: "tmux, zsh",
147
+ escalation: "sudo",
148
+ argv: [
149
+ "apt-get",
150
+ "install",
151
+ "-y",
152
+ "--no-install-recommends",
153
+ "tmux",
154
+ "zsh",
155
+ ],
156
+ });
157
+ // Each step that runs a tool installed earlier in the plan names it.
158
+ const needs = Object.fromEntries(
159
+ plan.steps.map((p) => [p.step.name, p.needs]),
160
+ );
161
+ expect(needs.claudish).toEqual(["bun"]);
162
+ expect(needs.chromium).toEqual(["uv"]);
163
+ expect(needs["chromium-libs"]).toEqual(["uv"]);
164
+ expect(plan.unfixable).toEqual([]);
165
+ });
166
+
167
+ test("consent lines mark root steps and name who needs each", () => {
168
+ for (const t of ["apt-get", "uv"]) tool(bin, t);
169
+ const plan = planDeps(
170
+ [TMUX, LIBS],
171
+ [missing("tmux"), missing("chromium-libs")],
172
+ env,
173
+ );
174
+ const lines = planLines(plan, "linux");
175
+ expect(lines[0]).toBe(
176
+ "[sudo] tmux: sudo apt-get install -y --no-install-recommends tmux (terminal@magus)",
177
+ );
178
+ expect(lines[1]).toStartWith(
179
+ "[sudo] chromium-libs: uv sync --no-config --script",
180
+ );
181
+ expect(lines[1]).toContain("(browser-use@magus)");
182
+ });
183
+
184
+ test("resumable: only what is still missing is planned", () => {
185
+ for (const t of ["apt-get", "bun"]) tool(bin, t);
186
+ const plan = planDeps(
187
+ [TMUX, ZSH, CLAUDISH],
188
+ [present("tmux"), missing("zsh"), present("claudish")],
189
+ env,
190
+ );
191
+ expect(names(plan)).toEqual(["zsh"]);
192
+ });
193
+
194
+ test("off PATH is not reinstalled: it gets the rc line", () => {
195
+ const plan = planDeps(
196
+ [CLAUDISH],
197
+ [{ name: "claudish", status: "off-path", prefix: "~/.bun/bin" }],
198
+ env,
199
+ );
200
+ expect(plan.steps).toEqual([]);
201
+ expect(plan.pathAdvice).toEqual([
202
+ {
203
+ prefix: "~/.bun/bin",
204
+ rcFile: "~/.zshrc",
205
+ rcLine: 'export PATH="$HOME/.bun/bin:$PATH"',
206
+ },
207
+ ]);
208
+ });
209
+
210
+ test("an install into a prefix that is not on PATH says so before it runs", () => {
211
+ const release = dep("tmux-mcp", {
212
+ via: "release",
213
+ repo: "MadAppGang/tmux-mcp",
214
+ tag: "v2.0.0",
215
+ asset: "tmux-mcp_{os}_{arch}.tar.gz",
216
+ binary: "tmux-mcp",
217
+ sha256: { linux_amd64: "a".repeat(64) },
218
+ });
219
+ const plan = planDeps([release], [missing("tmux-mcp")], env);
220
+ expect(plan.pathAdvice.map((a) => a.prefix)).toEqual(["~/.local/bin"]);
221
+ const onPath = planDeps([release], [missing("tmux-mcp")], {
222
+ ...env,
223
+ path: `${bin}:${join(env.home, ".local", "bin")}`,
224
+ });
225
+ expect(onPath.pathAdvice).toEqual([]);
226
+ });
227
+
228
+ test("no usable option is unfixable, with the reason", () => {
229
+ const brewOnly = dep(
230
+ "tmux",
231
+ { via: "brew", formula: "tmux" },
232
+ "terminal@magus",
233
+ {
234
+ chosen: null,
235
+ },
236
+ );
237
+ const plan = planDeps([brewOnly], [missing("tmux")], {
238
+ ...env,
239
+ os: "darwin",
240
+ });
241
+ expect(plan.steps).toEqual([]);
242
+ expect(plan.unfixable).toEqual([
243
+ {
244
+ name: "tmux",
245
+ why: "cannot be installed here: Homebrew is missing — install it from https://brew.sh first",
246
+ },
247
+ ]);
248
+ });
249
+
250
+ test("a root uv-module from outside the magus marketplace is never planned", () => {
251
+ tool(bin, "uv");
252
+ const foreign = { ...LIBS, sources: ["plugin:browser-use@someone-else"] };
253
+ const plan = planDeps([foreign], [missing("chromium-libs")], env);
254
+ expect(plan.steps).toEqual([]);
255
+ expect(plan.unfixable[0]?.why).toContain("outside the magus marketplace");
256
+ expect(plan.unfixable[0]?.why).toContain(
257
+ "-B -m playwright install-deps chromium",
258
+ );
259
+ });
260
+
261
+ test("profile-only kinds: refused in a manifest, planned from a profile's cliTools", () => {
262
+ for (const via of ["npm", "pip", "go"]) {
263
+ const parsed = parseRequires({
264
+ requires: {
265
+ schema: 1,
266
+ bin: [{ name: "x", check: ["x"], install: [{ via, package: "x" }] }],
267
+ },
268
+ });
269
+ expect(parsed.ok).toBe(false);
270
+ expect(parsed.ok ? [] : parsed.errors).toContain(
271
+ `requires.bin[0] (x).install[0]: via ${via} is not allowed in plugin.json`,
272
+ );
273
+ }
274
+ tool(bin, "npm");
275
+ const codex = cliToolToBin("codex", "0.41.0");
276
+ // Selected against this process's PATH; plan against the pinned one.
277
+ const pinned = {
278
+ ...codex,
279
+ chosen: codex.install[0] ?? null,
280
+ sources: ["profile:p.cliTools"],
281
+ };
282
+ const plan = planDeps([pinned], [missing("codex")], env);
283
+ expect(plan.steps.map((p) => p.step)).toEqual([
284
+ {
285
+ kind: "argv",
286
+ name: "codex",
287
+ escalation: "none",
288
+ argv: ["npm", "install", "-g", "@openai/codex@0.41.0"],
289
+ },
290
+ ]);
291
+ });
292
+
293
+ test("a profile pin the owning plugin's option cannot carry is unfixable, present or not", () => {
294
+ const owned: ResolvedBin = {
295
+ ...CLAUDISH,
296
+ chosen: { via: "brew", formula: "madappgang/tap/claudish" },
297
+ sources: ["profile:p.cliTools", "plugin:claudish@magus"],
298
+ refusedPins: ["6.1.0"],
299
+ };
300
+ for (const presence of [missing("claudish"), present("claudish")]) {
301
+ const plan = planDeps([owned], [presence], { ...env, os: "darwin" });
302
+ expect(plan.unfixable).toContainEqual({
303
+ name: "claudish",
304
+ why: 'the profile pins 6.1.0, but claudish@magus installs it from Homebrew (madappgang/tap/claudish), which takes no version — the pin is not applied; remove it or set it to "latest"',
305
+ });
306
+ }
307
+ });
308
+
309
+ test("a step whose tool is neither here nor planned is unfixable", () => {
310
+ const plan = planDeps([CLAUDISH], [missing("claudish")], env);
311
+ expect(plan.steps).toEqual([]);
312
+ expect(plan.unfixable[0]).toMatchObject({ name: "claudish" });
313
+ expect(plan.unfixable[0]?.why).toContain("needs bun");
314
+ });
315
+ });
316
+
317
+ // ─── applying ─────────────────────────────────────────────────────────────────
318
+
319
+ interface Call {
320
+ argv: string[];
321
+ opts: RunOptions;
322
+ }
323
+
324
+ /** A runner that records every argv and answers by the first matching rule. */
325
+ function fakeRun(
326
+ rules: Array<[(argv: string[]) => boolean, Partial<RunResult>]> = [],
327
+ ) {
328
+ const calls: Call[] = [];
329
+ const run = async (argv: string[], opts: RunOptions): Promise<RunResult> => {
330
+ calls.push({ argv, opts });
331
+ const hit = rules.find(([match]) => match(argv));
332
+ return {
333
+ exitCode: 0,
334
+ signal: null,
335
+ timedOut: false,
336
+ outputTail: "",
337
+ durationMs: 1,
338
+ ...(hit?.[1] ?? {}),
339
+ };
340
+ };
341
+ return { calls, run };
342
+ }
343
+
344
+ function options(
345
+ io: Partial<ApplyIO>,
346
+ over: Partial<ApplyOptions> = {},
347
+ ): ApplyOptions {
348
+ return {
349
+ yes: true,
350
+ confirm: async () => {
351
+ throw new Error("asked under --yes");
352
+ },
353
+ env,
354
+ lockPath: join(root, "cache", "doctor-fix.lock"),
355
+ // The re-probe answers "present" unless a test is about it: these tests
356
+ // fake the installers, so nothing they "install" exists on disk.
357
+ io: {
358
+ uid: () => 1000,
359
+ alive: () => false,
360
+ verify: async ({ requirement }) => present(requirement.name),
361
+ ...io,
362
+ },
363
+ ...over,
364
+ };
365
+ }
366
+
367
+ describe("applyDepsPlan — root", () => {
368
+ const aptPlan = () => {
369
+ tool(bin, "apt-get");
370
+ return planDeps([TMUX, ZSH], [missing("tmux"), missing("zsh")], env);
371
+ };
372
+
373
+ test("sudo -n works: the exact argv, asked once before any step", async () => {
374
+ tool(bin, "sudo");
375
+ const { calls, run } = fakeRun();
376
+ const outcomes = await applyDepsPlan(aptPlan(), options({ run }));
377
+ const SUDO = ["sudo", "-n", "env", "DEBIAN_FRONTEND=noninteractive"];
378
+ expect(calls.map((c) => c.argv)).toEqual([
379
+ ["sudo", "-n", "true"],
380
+ [...SUDO, "dpkg", "--configure", "-a"],
381
+ [...SUDO, "apt-get", "-o", "DPkg::Lock::Timeout=120", "update"],
382
+ [
383
+ ...SUDO,
384
+ "apt-get",
385
+ "-o",
386
+ "DPkg::Lock::Timeout=120",
387
+ "install",
388
+ "-y",
389
+ "--no-install-recommends",
390
+ "tmux",
391
+ "zsh",
392
+ ],
393
+ ]);
394
+ // SIGKILL cannot be relayed through sudo: an escalated step gets longer.
395
+ expect(calls[1]?.opts.killGraceMs).toBe(30_000);
396
+ expect(outcomes).toMatchObject([
397
+ { name: "tmux, zsh", status: "installed" },
398
+ ]);
399
+ });
400
+
401
+ test("uid 0 runs the argv directly, keeping the non-interactive env", async () => {
402
+ const { calls, run } = fakeRun();
403
+ await applyDepsPlan(aptPlan(), options({ run, uid: () => 0 }));
404
+ expect(calls[0]?.argv).toEqual([
405
+ "env",
406
+ "DEBIAN_FRONTEND=noninteractive",
407
+ "dpkg",
408
+ "--configure",
409
+ "-a",
410
+ ]);
411
+ });
412
+
413
+ test("no sudo: needs-root, nothing runs, the as-root command is given", async () => {
414
+ const { calls, run } = fakeRun();
415
+ const [outcome] = await applyDepsPlan(aptPlan(), options({ run }));
416
+ expect(calls).toEqual([]);
417
+ expect(outcome).toMatchObject({
418
+ status: "needs-root",
419
+ remedy:
420
+ "as root: apt-get update && apt-get install -y --no-install-recommends tmux zsh",
421
+ });
422
+ });
423
+
424
+ test("sudo needs a password: needs-password for every root step, and none ran", async () => {
425
+ for (const t of ["sudo", "uv", "apt-get"]) tool(bin, t);
426
+ const plan = planDeps(
427
+ [TMUX, LIBS],
428
+ [missing("tmux"), missing("chromium-libs")],
429
+ env,
430
+ );
431
+ const { calls, run } = fakeRun([
432
+ [(a) => a.join(" ") === "sudo -n true", { exitCode: 1 }],
433
+ ]);
434
+ const outcomes = await applyDepsPlan(plan, options({ run }));
435
+ expect(calls.map((c) => c.argv)).toEqual([["sudo", "-n", "true"]]);
436
+ expect(outcomes.map((o) => o.status)).toEqual([
437
+ "needs-password",
438
+ "needs-password",
439
+ ]);
440
+ expect(outcomes[0]?.remedy).toStartWith(
441
+ "run: sudo -v then: magus doctor --fix",
442
+ );
443
+ expect(outcomes[0]?.remedy).toContain(
444
+ "or run yourself: sudo apt-get install -y --no-install-recommends tmux",
445
+ );
446
+ expect(outcomes[0]?.remedy).toContain(
447
+ "not in sudoers? as root: apt-get update &&",
448
+ );
449
+ // Run for another directory, the remedy's re-run names it.
450
+ const [elsewhere] = await applyDepsPlan(
451
+ planDeps([TMUX], [missing("tmux")], env),
452
+ options({ run }, { projectDir: "/srv/app" }),
453
+ );
454
+ expect(elsewhere?.remedy).toStartWith(
455
+ "run: sudo -v then: magus doctor --fix --project /srv/app ",
456
+ );
457
+ });
458
+ });
459
+
460
+ describe("applyDepsPlan — uv-module", () => {
461
+ const PY = "/envs/mcp-server-abc/bin/python3";
462
+
463
+ test("the script env's interpreter runs the module; root gets the sudo argv after the apt batch", async () => {
464
+ for (const t of ["sudo", "uv", "apt-get"]) tool(bin, t);
465
+ const plan = planDeps(
466
+ [TMUX, LIBS, CHROMIUM],
467
+ [missing("tmux"), missing("chromium-libs"), missing("chromium")],
468
+ env,
469
+ );
470
+ const { calls, run } = fakeRun([
471
+ [(a) => a[1] === "python" && a[2] === "find", { outputTail: `${PY}\n` }],
472
+ ]);
473
+ const outcomes = await applyDepsPlan(plan, options({ run }));
474
+ expect(outcomes.map((o) => [o.name, o.status])).toEqual([
475
+ ["tmux", "installed"],
476
+ ["chromium-libs", "installed"],
477
+ ["chromium", "installed"],
478
+ ]);
479
+ const uv = join(bin, "uv");
480
+ const script = "/plugins/browser-use/scripts/mcp-server.py";
481
+ const argvs = calls.map((c) => c.argv);
482
+ const libs = argvs.findIndex((a) => a.includes("install-deps"));
483
+ expect(argvs.slice(libs - 3, libs + 1)).toEqual([
484
+ [uv, "sync", "--no-config", "--script", script],
485
+ [uv, "python", "find", "--no-config", "--script", script],
486
+ [
487
+ PY,
488
+ "-c",
489
+ "import importlib.util,sys; sys.exit(importlib.util.find_spec(sys.argv[1]) is None)",
490
+ "playwright",
491
+ ],
492
+ [
493
+ "sudo",
494
+ "-n",
495
+ "env",
496
+ "DEBIAN_FRONTEND=noninteractive",
497
+ PY,
498
+ "-B",
499
+ "-m",
500
+ "playwright",
501
+ "install-deps",
502
+ "chromium",
503
+ ],
504
+ ]);
505
+ // The apt batch ran before it; the browser download, as the user, after.
506
+ expect(argvs.findIndex((a) => a.includes("dpkg"))).toBeLessThan(libs);
507
+ expect(argvs.at(-1)).toEqual([
508
+ PY,
509
+ "-B",
510
+ "-m",
511
+ "playwright",
512
+ "install",
513
+ "chromium",
514
+ ]);
515
+ });
516
+
517
+ test("find falling back to the base interpreter fails the guard; nothing runs as root", async () => {
518
+ for (const t of ["sudo", "uv"]) tool(bin, t);
519
+ const plan = planDeps([LIBS], [missing("chromium-libs")], env);
520
+ const { calls, run } = fakeRun([
521
+ [(a) => a[1] === "python", { outputTail: "/usr/bin/python3.12\n" }],
522
+ [(a) => a[1] === "-c", { exitCode: 1 }],
523
+ ]);
524
+ const [outcome] = await applyDepsPlan(plan, options({ run }));
525
+ expect(outcome?.status).toBe("failed");
526
+ expect(outcome?.outputTail).toBe(
527
+ "interpreter /usr/bin/python3.12 cannot import playwright: the script env was not built",
528
+ );
529
+ expect(calls.some((c) => c.argv.includes("-m"))).toBe(false);
530
+ });
531
+ });
532
+
533
+ describe("applyDepsPlan — release", () => {
534
+ /** A real .tar.gz holding `entry` as `name` (a file, or a symlink to /bin/sh). */
535
+ function archive(name: string, entry: "file" | "symlink"): Uint8Array {
536
+ const src = join(root, "src");
537
+ mkdirSync(src, { recursive: true });
538
+ if (entry === "file") {
539
+ writeFileSync(join(src, name), "#!/bin/sh\necho 2.0.0\n");
540
+ } else {
541
+ symlinkSync("/bin/sh", join(src, name));
542
+ }
543
+ const out = join(root, "a.tar.gz");
544
+ const r = Bun.spawnSync(["tar", "-czf", out, "-C", src, name]);
545
+ if (r.exitCode !== 0) throw new Error("tar failed");
546
+ return readFileSync(out);
547
+ }
548
+ const sha = (b: Uint8Array) => createHash("sha256").update(b).digest("hex");
549
+ const release = (digest: string) =>
550
+ dep("tmux-mcp", {
551
+ via: "release",
552
+ repo: "MadAppGang/tmux-mcp",
553
+ tag: "v2.0.0",
554
+ asset: "tmux-mcp_{os}_{arch}.tar.gz",
555
+ binary: "tmux-mcp",
556
+ sha256: { linux_amd64: digest },
557
+ });
558
+ const dest = () => join(env.home, ".local", "bin");
559
+ // The real tar runs: the extraction is part of what is being tested.
560
+ const realRun = REAL_APPLY_IO.run;
561
+ const withTar = (): PlanEnv => ({ ...env, path: process.env.PATH ?? "" });
562
+
563
+ test("a verified archive installs one executable, by rename, into ~/.local/bin", async () => {
564
+ const body = archive("tmux-mcp", "file");
565
+ const plan = planDeps(
566
+ [release(sha(body))],
567
+ [missing("tmux-mcp")],
568
+ withTar(),
569
+ );
570
+ const urls: string[] = [];
571
+ const [outcome] = await applyDepsPlan(
572
+ plan,
573
+ options(
574
+ {
575
+ run: realRun,
576
+ download: async (u) => {
577
+ urls.push(u);
578
+ return body;
579
+ },
580
+ },
581
+ { env: withTar() },
582
+ ),
583
+ );
584
+ expect(outcome?.status).toBe("installed");
585
+ expect(urls).toEqual([
586
+ "https://github.com/MadAppGang/tmux-mcp/releases/download/v2.0.0/tmux-mcp_linux_amd64.tar.gz",
587
+ ]);
588
+ expect(readdirSync(dest())).toEqual(["tmux-mcp"]);
589
+ expect(statSync(join(dest(), "tmux-mcp")).mode & 0o777).toBe(0o755);
590
+ });
591
+
592
+ test("a digest mismatch fails closed and writes nothing", async () => {
593
+ const body = archive("tmux-mcp", "file");
594
+ const plan = planDeps(
595
+ [release("b".repeat(64))],
596
+ [missing("tmux-mcp")],
597
+ withTar(),
598
+ );
599
+ const [outcome] = await applyDepsPlan(
600
+ plan,
601
+ options({ run: realRun, download: async () => body }, { env: withTar() }),
602
+ );
603
+ expect(outcome?.status).toBe("failed");
604
+ expect(outcome?.outputTail).toContain("sha256 mismatch");
605
+ expect(existsSync(dest())).toBe(false);
606
+ });
607
+
608
+ test("a symlink in place of the binary is refused, and nothing is left behind", async () => {
609
+ const body = archive("tmux-mcp", "symlink");
610
+ const plan = planDeps(
611
+ [release(sha(body))],
612
+ [missing("tmux-mcp")],
613
+ withTar(),
614
+ );
615
+ const [outcome] = await applyDepsPlan(
616
+ plan,
617
+ options({ run: realRun, download: async () => body }, { env: withTar() }),
618
+ );
619
+ expect(outcome?.status).toBe("failed");
620
+ expect(outcome?.outputTail).toContain("not a regular file");
621
+ expect(readdirSync(dest())).toEqual([]);
622
+ });
623
+
624
+ test("a step with no pinned digest for this platform is refused, unrun", async () => {
625
+ // Selection never picks such an option; a plan carried to another
626
+ // platform renders it without a digest, and the executor refuses it.
627
+ const arm = { ...withTar(), arch: "arm64" as const };
628
+ const plan = planDeps(
629
+ [release("c".repeat(64))],
630
+ [missing("tmux-mcp")],
631
+ arm,
632
+ );
633
+ expect(plan.steps[0]?.step).toMatchObject({ kind: "release", sha256: "" });
634
+ const downloads: string[] = [];
635
+ const [outcome] = await applyDepsPlan(
636
+ plan,
637
+ options(
638
+ {
639
+ run: realRun,
640
+ download: async (u) => {
641
+ downloads.push(u);
642
+ return new Uint8Array();
643
+ },
644
+ },
645
+ { env: arm },
646
+ ),
647
+ );
648
+ expect(outcome?.status).toBe("failed");
649
+ expect(outcome?.outputTail).toBe("no pinned sha256 for this platform");
650
+ expect(downloads).toEqual([]);
651
+ });
652
+ });
653
+
654
+ describe("applyDepsPlan — order, consent, lock", () => {
655
+ test("a step whose prerequisite did not install is blocked, with the command", async () => {
656
+ for (const t of ["curl", "unzip", "bash"]) tool(bin, t);
657
+ const plan = planDeps(
658
+ [BUN, CLAUDISH],
659
+ [missing("bun"), missing("claudish")],
660
+ env,
661
+ );
662
+ const outcomes = await applyDepsPlan(
663
+ plan,
664
+ options({
665
+ run: fakeRun([[() => true, { exitCode: 1, outputTail: "boom" }]]).run,
666
+ download: async () => new TextEncoder().encode("exit 1\n"),
667
+ }),
668
+ );
669
+ expect(outcomes.map((o) => [o.name, o.status])).toEqual([
670
+ ["bun", "failed"],
671
+ ["claudish", "blocked"],
672
+ ]);
673
+ expect(outcomes[1]?.remedy).toBe(
674
+ "after bun is installed: bun add -g claudish",
675
+ );
676
+ });
677
+
678
+ test("without --yes the whole plan is asked once; no means nothing runs", async () => {
679
+ tool(bin, "bun");
680
+ const plan = planDeps([CLAUDISH], [missing("claudish")], env);
681
+ const asked: DepsPlan[] = [];
682
+ const { calls, run } = fakeRun();
683
+ const outcomes = await applyDepsPlan(
684
+ plan,
685
+ options(
686
+ { run },
687
+ {
688
+ yes: false,
689
+ confirm: async (p) => {
690
+ asked.push(p);
691
+ return false;
692
+ },
693
+ },
694
+ ),
695
+ );
696
+ expect(asked).toEqual([plan]);
697
+ expect(outcomes).toEqual([]);
698
+ expect(calls).toEqual([]);
699
+ });
700
+
701
+ test("a held lock refuses a second apply; a stale one is taken over and released", async () => {
702
+ tool(bin, "bun");
703
+ const plan = planDeps([CLAUDISH], [missing("claudish")], env);
704
+ const lock = join(root, "cache", "doctor-fix.lock");
705
+ mkdirSync(join(root, "cache"));
706
+ writeFileSync(lock, "4242");
707
+ await expect(
708
+ applyDepsPlan(
709
+ plan,
710
+ options({ run: fakeRun().run, alive: (pid) => pid === 4242 }),
711
+ ),
712
+ ).rejects.toBeInstanceOf(DepsLockBusy);
713
+ expect(readFileSync(lock, "utf8")).toBe("4242");
714
+
715
+ const outcomes = await applyDepsPlan(
716
+ plan,
717
+ options({ run: fakeRun().run, alive: () => false }),
718
+ );
719
+ expect(outcomes[0]?.status).toBe("installed");
720
+ expect(existsSync(lock)).toBe(false);
721
+ });
722
+
723
+ test("events: start, output, end — and the runner's output never reaches the terminal", async () => {
724
+ tool(bin, "bun");
725
+ const plan = planDeps([CLAUDISH], [missing("claudish")], env);
726
+ const events: string[] = [];
727
+ await applyDepsPlan(
728
+ plan,
729
+ options(
730
+ {
731
+ run: async (_argv, opts) => {
732
+ opts.onOutput?.("installing…\n");
733
+ return {
734
+ exitCode: 0,
735
+ signal: null,
736
+ timedOut: false,
737
+ outputTail: "installing…",
738
+ durationMs: 1,
739
+ };
740
+ },
741
+ },
742
+ { onEvent: (e) => events.push(e.type) },
743
+ ),
744
+ );
745
+ expect(events).toEqual(["step-start", "output", "step-end"]);
746
+ });
747
+ });
748
+
749
+ describe("applyDepsPlan — installed means the re-probe found it", () => {
750
+ const realVerify = REAL_APPLY_IO.verify;
751
+ const dest = () => join(env.home, ".local", "bin");
752
+ const body = new TextEncoder().encode("#!/bin/sh\necho 10.3.0\n");
753
+ const sha = (b: Uint8Array) => createHash("sha256").update(b).digest("hex");
754
+ /** claudish's shape: a bare binary per platform, x64 spelled its own way. */
755
+ const bare = dep(
756
+ "claudish",
757
+ {
758
+ via: "release",
759
+ repo: "MadAppGang/claudish",
760
+ tag: "v10.3.0",
761
+ asset: "claudish-{os}-{arch}",
762
+ binary: "claudish",
763
+ arch: { amd64: "x64" },
764
+ sha256: { linux_amd64: sha(body) },
765
+ },
766
+ "claudish@magus",
767
+ );
768
+
769
+ test("a bare-binary release lands as the binary itself, and the probe then finds it", async () => {
770
+ const onPathEnv: PlanEnv = { ...env, path: `${dest()}:${bin}` };
771
+ const plan = planDeps([bare], [missing("claudish")], onPathEnv);
772
+ expect(plan.steps[0]?.step).toMatchObject({
773
+ kind: "release",
774
+ format: "binary",
775
+ });
776
+ const urls: string[] = [];
777
+ const [outcome] = await applyDepsPlan(
778
+ plan,
779
+ options(
780
+ {
781
+ verify: realVerify,
782
+ download: async (u) => {
783
+ urls.push(u);
784
+ return body;
785
+ },
786
+ },
787
+ { env: onPathEnv },
788
+ ),
789
+ );
790
+ expect(urls).toEqual([
791
+ "https://github.com/MadAppGang/claudish/releases/download/v10.3.0/claudish-linux-x64",
792
+ ]);
793
+ expect(outcome?.status).toBe("installed");
794
+ expect(readdirSync(dest())).toEqual(["claudish"]);
795
+ expect(readFileSync(join(dest(), "claudish"), "utf8")).toBe(
796
+ new TextDecoder().decode(body),
797
+ );
798
+ expect(statSync(join(dest(), "claudish")).mode & 0o777).toBe(0o755);
799
+ });
800
+
801
+ test("landed in ~/.local/bin, which is off PATH: off-path, with the directory and the rc line — never installed", async () => {
802
+ const plan = planDeps([bare], [missing("claudish")], env);
803
+ const outcomes = await applyDepsPlan(
804
+ plan,
805
+ options({ verify: realVerify, download: async () => body }),
806
+ );
807
+ expect(outcomes[0]).toMatchObject({
808
+ name: "claudish",
809
+ status: "off-path",
810
+ dir: "~/.local/bin",
811
+ remedy:
812
+ 'add export PATH="$HOME/.local/bin:$PATH" to ~/.zshrc, then restart Claude Code from a new terminal',
813
+ });
814
+ const text = renderApplyText(outcomes).join("\n");
815
+ expect(text).not.toContain("✓");
816
+ expect(text).toContain("⚠ claudish");
817
+ expect(text).toContain("NEEDS YOU");
818
+ });
819
+
820
+ test("bun add -g that followed XDG_CACHE_HOME off PATH: off-path names bun's own global bin dir", async () => {
821
+ tool(bin, "bun");
822
+ const xdgBin = join(root, "xdg-cache", ".bun", "bin");
823
+ const viaBun = dep(
824
+ "claudish",
825
+ { via: "bun", package: "claudish" },
826
+ "claudish@magus",
827
+ );
828
+ const plan = planDeps([viaBun], [missing("claudish")], env);
829
+ const calls: string[][] = [];
830
+ const run = async (argv: string[]): Promise<RunResult> => {
831
+ calls.push(argv.slice(1));
832
+ if (argv[1] === "add") tool(xdgBin, "claudish");
833
+ return {
834
+ exitCode: 0,
835
+ signal: null,
836
+ timedOut: false,
837
+ outputTail: argv[1] === "pm" ? `${xdgBin}\n` : "installed claudish",
838
+ durationMs: 1,
839
+ };
840
+ };
841
+ const [outcome] = await applyDepsPlan(
842
+ plan,
843
+ options({ run, verify: realVerify }),
844
+ );
845
+ expect(calls).toEqual([
846
+ ["add", "-g", "claudish"],
847
+ ["pm", "bin", "-g"],
848
+ ]);
849
+ expect(outcome).toMatchObject({
850
+ status: "off-path",
851
+ dir: xdgBin,
852
+ remedy: `add export PATH="${xdgBin}:$PATH" to ~/.zshrc, then restart Claude Code from a new terminal`,
853
+ });
854
+ });
855
+
856
+ test("an installer that exits 0 and leaves nothing behind is failed, saying so", async () => {
857
+ tool(bin, "bun");
858
+ const viaBun = dep(
859
+ "claudish",
860
+ { via: "bun", package: "claudish" },
861
+ "claudish@magus",
862
+ );
863
+ const plan = planDeps([viaBun], [missing("claudish")], env);
864
+ const [outcome] = await applyDepsPlan(
865
+ plan,
866
+ options({ run: fakeRun().run, verify: realVerify }),
867
+ );
868
+ expect(outcome?.status).toBe("failed");
869
+ expect(outcome?.outputTail).toContain(
870
+ "claudish: the installer exited 0, but `claudish --version` still fails",
871
+ );
872
+ expect(outcome?.remedy).toBe("bun add -g claudish");
873
+ });
874
+
875
+ test("the merged apt step re-probes every requirement it installs", async () => {
876
+ tool(bin, "apt-get");
877
+ const plan = planDeps([TMUX, ZSH], [missing("tmux"), missing("zsh")], env);
878
+ const probed: string[] = [];
879
+ const [outcome] = await applyDepsPlan(
880
+ plan,
881
+ options({
882
+ run: fakeRun().run,
883
+ uid: () => 0,
884
+ verify: async ({ requirement }) => {
885
+ probed.push(requirement.name);
886
+ return requirement.name === "zsh"
887
+ ? missing("zsh")
888
+ : present(requirement.name);
889
+ },
890
+ }),
891
+ );
892
+ expect(probed).toEqual(["tmux", "zsh"]);
893
+ expect(outcome?.status).toBe("failed");
894
+ expect(outcome?.outputTail).toContain("zsh: the installer exited 0");
895
+ });
896
+ });