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,962 @@
1
+ /**
2
+ * The one installer executor: plan what is missing, then install it.
3
+ *
4
+ * `planDeps` turns the requirements a probe found missing into ordered steps,
5
+ * each rendered by requires-probe's `renderInstall` (the only renderer).
6
+ * `applyDepsPlan` runs them through `runArgv` and returns one `ApplyOutcome`
7
+ * per step. Nothing here prints: `magus doctor` (text and `--json`),
8
+ * `magus install` and the TUI each render the same outcomes their own way.
9
+ *
10
+ * Every step is an argv run, a verified download, or both. Nothing from a
11
+ * manifest reaches a shell. Root is only ever `sudo -n`, only for `apt` and a
12
+ * `root` uv-module step, and decided once before any step runs: a password
13
+ * or a missing sudo is a loud outcome carrying the exact command, never a
14
+ * prompt.
15
+ *
16
+ * Re-running is the recovery: a plan is always built from a fresh probe, so a
17
+ * step whose effect is already present is not planned again; every step is
18
+ * idempotent or writes its result last (a release renames into place); the apt
19
+ * batch starts with `dpkg --configure -a` and waits on the dpkg lock; and only
20
+ * one apply runs at a time per user (`DepsLockBusy`).
21
+ */
22
+
23
+ import { createHash } from "node:crypto";
24
+ import {
25
+ chmodSync,
26
+ closeSync,
27
+ lstatSync,
28
+ mkdirSync,
29
+ mkdtempSync,
30
+ openSync,
31
+ readFileSync,
32
+ renameSync,
33
+ rmSync,
34
+ unlinkSync,
35
+ writeFileSync,
36
+ writeSync,
37
+ } from "node:fs";
38
+ import { tmpdir } from "node:os";
39
+ import { delimiter, dirname, join } from "node:path";
40
+ import {
41
+ type Arch,
42
+ BUN_PREFIX,
43
+ type InstallStep,
44
+ LOCAL_PREFIX,
45
+ type Os,
46
+ type Presence,
47
+ type Requirement,
48
+ TOOLCHAIN_BOOTSTRAP,
49
+ USER_PREFIXES,
50
+ type UserPrefix,
51
+ assertNever,
52
+ describeStep,
53
+ doctorFixCommand,
54
+ expandHome,
55
+ onPath,
56
+ pathRcLine,
57
+ probe,
58
+ rcFile,
59
+ renderInstall,
60
+ uvScriptArgv,
61
+ whyUnselectable,
62
+ } from "../shared/plugin/hooks/lib/requires-probe.js";
63
+ import type { ResolvedBin } from "../types/index.js";
64
+ import { type RunOptions, type RunResult, runArgv } from "../utils/run.js";
65
+ import { parsePluginId } from "../utils/string-utils.js";
66
+
67
+ // ─── planning ─────────────────────────────────────────────────────────────────
68
+
69
+ /** What planning and applying need to know about this machine. */
70
+ export interface PlanEnv {
71
+ os: Os;
72
+ arch: Arch;
73
+ home: string;
74
+ /** The PATH Claude Code and its MCP servers inherit. */
75
+ path: string;
76
+ /** `$SHELL`, to name the rc file for a PATH line. */
77
+ userShell: string;
78
+ }
79
+
80
+ /** A requirement a step installs, and the plugin root its check expands against. */
81
+ export interface InstallTarget {
82
+ requirement: Requirement;
83
+ pluginRoot: string;
84
+ }
85
+
86
+ export interface PlannedStep {
87
+ step: InstallStep;
88
+ /** Plugin ids (or a profile's cliTools) that need it. */
89
+ requiredBy: string[];
90
+ /** Earlier steps (by name) whose tool this step runs. */
91
+ needs: string[];
92
+ /**
93
+ * What the step installs: re-probed after it runs, because an installer that
94
+ * exits 0 has not shown the requirement is now found.
95
+ */
96
+ installs: InstallTarget[];
97
+ }
98
+
99
+ export interface PathAdvice {
100
+ prefix: UserPrefix;
101
+ rcFile: string;
102
+ rcLine: string;
103
+ }
104
+
105
+ export interface DepsPlan {
106
+ /** Toolchains, then the root batch (apt, root uv-module), then declaration order. */
107
+ steps: PlannedStep[];
108
+ /** What `--fix` cannot do; each `why` reads whole after "<name> — ". */
109
+ unfixable: { name: string; why: string }[];
110
+ pathAdvice: PathAdvice[];
111
+ }
112
+
113
+ /** The plugin ids among a requirement's sources, for display. */
114
+ export function requiredByOf(bin: ResolvedBin): string[] {
115
+ return bin.sources.map((s) =>
116
+ s.startsWith("plugin:") ? s.slice("plugin:".length) : s,
117
+ );
118
+ }
119
+
120
+ /**
121
+ * Why a profile's pin on `bin` is not applied: the plugin that owns the tool
122
+ * installs it with an option that takes no version. The one wording, for
123
+ * doctor, install, the TUI and `magus update`.
124
+ */
125
+ export function refusedPinWhy(bin: ResolvedBin, pin: string): string {
126
+ const owners = requiredByOf(bin).filter((s) => !s.startsWith("profile:"));
127
+ const option = bin.chosen;
128
+ const how = !option
129
+ ? "no option usable here"
130
+ : option.via === "release"
131
+ ? `its ${option.tag} release`
132
+ : option.via === "brew"
133
+ ? `Homebrew (${option.cask ?? option.formula})`
134
+ : option.via;
135
+ return `the profile pins ${pin}, but ${owners.join(", ") || "its plugin"} installs it from ${how}, which takes no version — the pin is not applied; remove it or set it to "latest"`;
136
+ }
137
+
138
+ /** The tool a step needs on the machine before it can run, if any. */
139
+ function toolOf(step: InstallStep): string | null {
140
+ switch (step.kind) {
141
+ case "argv":
142
+ return step.argv[0] ?? null;
143
+ case "uv-module":
144
+ return "uv";
145
+ case "bootstrap":
146
+ case "release":
147
+ return null;
148
+ default:
149
+ return assertNever(step);
150
+ }
151
+ }
152
+
153
+ /** Where a step leaves its binary, when that is a user prefix. */
154
+ export function prefixOf(step: InstallStep): UserPrefix | null {
155
+ switch (step.kind) {
156
+ case "bootstrap":
157
+ return step.prefix;
158
+ case "release":
159
+ return step.dest;
160
+ case "argv":
161
+ if (step.argv[0] === "bun" && step.argv[1] === "add") return BUN_PREFIX;
162
+ if (step.argv[0] === "uv" && step.argv[1] === "tool") return LOCAL_PREFIX;
163
+ return null;
164
+ case "uv-module":
165
+ return null;
166
+ default:
167
+ return assertNever(step);
168
+ }
169
+ }
170
+
171
+ /** `tool` in a user prefix, when it is there but maybe not on PATH. */
172
+ function inUserPrefix(tool: string, home: string): string | null {
173
+ for (const prefix of USER_PREFIXES) {
174
+ const found = onPath(tool, expandHome(prefix.dir, home));
175
+ if (found) return found;
176
+ }
177
+ return null;
178
+ }
179
+
180
+ /** Is `prefix` on the PATH Claude Code starts with? */
181
+ export function prefixOnPath(prefix: UserPrefix, env: PlanEnv): boolean {
182
+ const dir = expandHome(prefix, env.home);
183
+ return env.path.split(delimiter).some((d) => d === dir || d === `${dir}/`);
184
+ }
185
+
186
+ /** The PATH line for a prefix, in the user's rc file. */
187
+ export function pathAdviceFor(prefix: UserPrefix, env: PlanEnv): PathAdvice {
188
+ const info = USER_PREFIXES.find((u) => u.dir === prefix);
189
+ return {
190
+ prefix,
191
+ rcFile: rcFile(env.userShell),
192
+ rcLine: info ? info.rcLine(env.userShell) : "",
193
+ };
194
+ }
195
+
196
+ /** A root uv-module step may only come from a plugin of the magus marketplace. */
197
+ function fromMagusMarketplace(bin: ResolvedBin): boolean {
198
+ return bin.sources.some(
199
+ (s) =>
200
+ s.startsWith("plugin:") &&
201
+ parsePluginId(s.slice("plugin:".length))?.marketplace === "magus",
202
+ );
203
+ }
204
+
205
+ /**
206
+ * The steps that install every `missing` requirement, in the order they must
207
+ * run. `presence[i]` is the probe's answer for `bins[i]`. An `off-path`
208
+ * requirement is not reinstalled: it needs a PATH line, which `pathAdvice`
209
+ * carries. An `unknown` one (a check too slow to answer) is left alone.
210
+ */
211
+ export function planDeps(
212
+ bins: ResolvedBin[],
213
+ presence: Presence[],
214
+ env: PlanEnv,
215
+ ): DepsPlan {
216
+ const unfixable: DepsPlan["unfixable"] = [];
217
+ const advice = new Map<UserPrefix, PathAdvice>();
218
+ const missing: ResolvedBin[] = [];
219
+ bins.forEach((bin, i) => {
220
+ const p = presence[i];
221
+ if (p?.status === "missing") missing.push(bin);
222
+ if (p?.status === "off-path") {
223
+ advice.set(p.prefix, pathAdviceFor(p.prefix, env));
224
+ }
225
+ });
226
+
227
+ // A pin the owning plugin's option cannot carry: unfixable whatever the
228
+ // tool's presence, because no install here would honour it.
229
+ for (const bin of bins) {
230
+ for (const pin of bin.refusedPins ?? []) {
231
+ unfixable.push({ name: bin.name, why: refusedPinWhy(bin, pin) });
232
+ }
233
+ }
234
+
235
+ const declared = new Set(bins.map((b) => b.name));
236
+ const available = (tool: string) =>
237
+ onPath(tool, env.path) !== null || inUserPrefix(tool, env.home) !== null;
238
+ const has = (tool: string) => declared.has(tool) || available(tool);
239
+
240
+ const toolchains: PlannedStep[] = [];
241
+ const rootBatch: PlannedStep[] = [];
242
+ const rest: PlannedStep[] = [];
243
+ const aptPackages: string[] = [];
244
+ const aptNames: string[] = [];
245
+ const aptRequiredBy = new Set<string>();
246
+ const aptInstalls: InstallTarget[] = [];
247
+ const target = (bin: ResolvedBin): InstallTarget => ({
248
+ requirement: bin,
249
+ pluginRoot: bin.root ?? "",
250
+ });
251
+
252
+ for (const bin of missing) {
253
+ const option = bin.chosen;
254
+ if (!option) {
255
+ unfixable.push({
256
+ name: bin.name,
257
+ why: `cannot be installed here: ${whyUnselectable(bin, env, has)}`,
258
+ });
259
+ continue;
260
+ }
261
+ const step = renderInstall(bin, option, {
262
+ os: env.os,
263
+ arch: env.arch,
264
+ pluginRoot: bin.root ?? "",
265
+ });
266
+ if (
267
+ step.kind === "uv-module" &&
268
+ step.escalation === "sudo" &&
269
+ !fromMagusMarketplace(bin)
270
+ ) {
271
+ unfixable.push({
272
+ name: bin.name,
273
+ why: `a root step from a plugin outside the magus marketplace is never run by magus — run it yourself if you trust it: ${describeStep(step, env.os)}`,
274
+ });
275
+ continue;
276
+ }
277
+ const requiredBy = requiredByOf(bin);
278
+ if (option.via === "apt") {
279
+ aptPackages.push(...option.packages);
280
+ aptNames.push(bin.name);
281
+ aptInstalls.push(target(bin));
282
+ for (const r of requiredBy) aptRequiredBy.add(r);
283
+ continue;
284
+ }
285
+ const planned: PlannedStep = {
286
+ step,
287
+ requiredBy,
288
+ needs: [],
289
+ installs: [target(bin)],
290
+ };
291
+ if (bin.name in TOOLCHAIN_BOOTSTRAP) toolchains.push(planned);
292
+ else if (step.kind === "uv-module" && step.escalation === "sudo")
293
+ rootBatch.push(planned);
294
+ else rest.push(planned);
295
+ }
296
+ if (aptPackages.length > 0) {
297
+ const step = renderInstall(
298
+ { name: aptNames.join(", "), check: [], install: [] },
299
+ { via: "apt", packages: [...new Set(aptPackages)] },
300
+ { os: env.os, arch: env.arch, pluginRoot: "" },
301
+ );
302
+ rootBatch.unshift({
303
+ step,
304
+ requiredBy: [...aptRequiredBy],
305
+ needs: [],
306
+ installs: aptInstalls,
307
+ });
308
+ }
309
+
310
+ const ordered = [...toolchains, ...rootBatch, ...rest];
311
+ const plannedNames = new Set(ordered.map((p) => p.step.name));
312
+ const steps: PlannedStep[] = [];
313
+ for (const p of ordered) {
314
+ const tool = toolOf(p.step);
315
+ if (tool && !available(tool)) {
316
+ if (!plannedNames.has(tool)) {
317
+ unfixable.push({
318
+ name: p.step.name,
319
+ why: `needs ${tool}, which is not installed and cannot be installed here`,
320
+ });
321
+ plannedNames.delete(p.step.name);
322
+ continue;
323
+ }
324
+ p.needs.push(tool);
325
+ }
326
+ steps.push(p);
327
+ const prefix = prefixOf(p.step);
328
+ if (prefix && !prefixOnPath(prefix, env)) {
329
+ advice.set(prefix, pathAdviceFor(prefix, env));
330
+ }
331
+ }
332
+ return { steps, unfixable, pathAdvice: [...advice.values()] };
333
+ }
334
+
335
+ /**
336
+ * One consent line per step: its paste-able form, who needs it, `[sudo]` when
337
+ * it runs as root. Shown before anything runs, by every front end.
338
+ */
339
+ export function planLines(plan: DepsPlan, os: Os): string[] {
340
+ return plan.steps.map((p) => {
341
+ const sudo =
342
+ "escalation" in p.step && p.step.escalation === "sudo" ? "[sudo] " : "";
343
+ const by = p.requiredBy.length > 0 ? ` (${p.requiredBy.join(", ")})` : "";
344
+ return `${sudo}${p.step.name}: ${describeStep(p.step, os)}${by}`;
345
+ });
346
+ }
347
+
348
+ // ─── applying ─────────────────────────────────────────────────────────────────
349
+
350
+ /**
351
+ * "installed" means the re-probe after the step found the requirement — never
352
+ * only that the installer exited 0. "off-path": it landed in `dir`, which is
353
+ * not on the PATH Claude Code starts with.
354
+ */
355
+ export type ApplyStatus =
356
+ | "installed"
357
+ | "off-path"
358
+ | "failed"
359
+ | "needs-password"
360
+ | "needs-root"
361
+ | "blocked";
362
+
363
+ export interface ApplyOutcome {
364
+ name: string;
365
+ status: ApplyStatus;
366
+ requiredBy: string[];
367
+ /** Exactly what ran last for this step (or would have). */
368
+ argv?: string[];
369
+ exitCode: number | null;
370
+ outputTail: string;
371
+ durationMs: number;
372
+ /** "off-path": the directory the binary landed in. */
373
+ dir?: string;
374
+ /** For every status except "installed": the exact command for the user. */
375
+ remedy?: string;
376
+ }
377
+
378
+ export type ApplyEvent =
379
+ | { type: "step-start"; name: string; describe: string }
380
+ | { type: "output"; name: string; chunk: string }
381
+ | { type: "step-end"; outcome: ApplyOutcome };
382
+
383
+ /** The side effects an apply makes, injectable so tests run none of them. */
384
+ export interface ApplyIO {
385
+ run(argv: string[], opts: RunOptions): Promise<RunResult>;
386
+ /** The body at `url`, or throws. */
387
+ download(url: string): Promise<Uint8Array>;
388
+ /** This process's uid; null where the platform has none. */
389
+ uid(): number | null;
390
+ /** Is a process with this pid alive? */
391
+ alive(pid: number): boolean;
392
+ /**
393
+ * The probe's answer for one requirement now, on the PATH Claude Code starts
394
+ * with — the same `probe` doctor and the hook run, at full depth.
395
+ */
396
+ verify(target: InstallTarget, env: PlanEnv): Promise<Presence>;
397
+ }
398
+
399
+ const DOWNLOAD_TIMEOUT_MS = 10 * 60_000;
400
+ /** The per-check cap a post-install re-probe runs under: doctor's. */
401
+ const VERIFY_CAP_MS = 60_000;
402
+
403
+ export const REAL_APPLY_IO: ApplyIO = {
404
+ run: (argv, opts) =>
405
+ runArgv({ cmd: argv[0] ?? "", args: argv.slice(1) }, opts),
406
+ download: async (url) => {
407
+ const res = await fetch(url, {
408
+ signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
409
+ });
410
+ if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
411
+ // Every URL here is https; a redirect off it is refused, not read.
412
+ if (new URL(res.url).protocol !== "https:") {
413
+ throw new Error(`${url} redirected off https, to ${res.url}`);
414
+ }
415
+ return new Uint8Array(await res.arrayBuffer());
416
+ },
417
+ uid: () => (typeof process.getuid === "function" ? process.getuid() : null),
418
+ alive: (pid) => {
419
+ try {
420
+ process.kill(pid, 0);
421
+ return true;
422
+ } catch (error) {
423
+ return (error as NodeJS.ErrnoException).code === "EPERM";
424
+ }
425
+ },
426
+ verify: async ({ requirement, pluginRoot }, env) => {
427
+ const [presence] = await probe([requirement], {
428
+ pluginRoot,
429
+ os: env.os,
430
+ arch: env.arch,
431
+ home: env.home,
432
+ path: env.path,
433
+ capMs: VERIFY_CAP_MS,
434
+ depth: "full",
435
+ userShell: env.userShell,
436
+ });
437
+ return presence ?? { name: requirement.name, status: "present" };
438
+ },
439
+ };
440
+
441
+ export interface ApplyOptions {
442
+ /** Skip the consent question. */
443
+ yes: boolean;
444
+ /** Asked once with the whole plan, unless `yes`. False installs nothing. */
445
+ confirm(plan: DepsPlan): Promise<boolean>;
446
+ onEvent?: (e: ApplyEvent) => void;
447
+ env: PlanEnv;
448
+ /** The single-apply lock. */
449
+ lockPath: string;
450
+ /** The project a remedy's `magus doctor --fix` names; null for "here". */
451
+ projectDir?: string | null;
452
+ io?: Partial<ApplyIO>;
453
+ }
454
+
455
+ /** Another apply holds the lock. */
456
+ export class DepsLockBusy extends Error {
457
+ constructor(readonly pid: number) {
458
+ super(
459
+ `another magus dependency install (pid ${pid}) is running — wait for it, then re-run`,
460
+ );
461
+ this.name = "DepsLockBusy";
462
+ }
463
+ }
464
+
465
+ /** `${XDG_CACHE_HOME:-~/.cache}/magus/doctor-fix.lock` */
466
+ export function defaultLockPath(home: string, xdgCacheHome?: string): string {
467
+ return join(xdgCacheHome || join(home, ".cache"), "magus", "doctor-fix.lock");
468
+ }
469
+
470
+ /** Take the lock (O_EXCL, holding our pid), taking over one whose pid is gone. */
471
+ function acquireLock(path: string, io: ApplyIO): () => void {
472
+ mkdirSync(dirname(path), { recursive: true });
473
+ for (let attempt = 0; attempt < 2; attempt++) {
474
+ try {
475
+ const fd = openSync(path, "wx", 0o600);
476
+ writeSync(fd, String(process.pid));
477
+ closeSync(fd);
478
+ return () => {
479
+ try {
480
+ if (readFileSync(path, "utf8").trim() === String(process.pid)) {
481
+ unlinkSync(path);
482
+ }
483
+ } catch {
484
+ // Already gone.
485
+ }
486
+ };
487
+ } catch (error) {
488
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
489
+ const pid = Number.parseInt(readFileSync(path, "utf8").trim(), 10);
490
+ if (Number.isInteger(pid) && pid > 0 && io.alive(pid)) {
491
+ throw new DepsLockBusy(pid);
492
+ }
493
+ unlinkSync(path); // stale: its holder is gone
494
+ }
495
+ }
496
+ throw new Error(`could not take ${path}`);
497
+ }
498
+
499
+ const STEP_TIMEOUT_MS = 15 * 60_000;
500
+ const SUDO_KILL_GRACE_MS = 30_000;
501
+ /** A root step as uid 0: the same environment `sudo` would be given. */
502
+ const AS_ROOT = ["env", "DEBIAN_FRONTEND=noninteractive"];
503
+ /** A root step through sudo: never a prompt, and the env survives env_reset. */
504
+ const SUDO = ["sudo", "-n", ...AS_ROOT];
505
+ const APT_LOCK = ["-o", "DPkg::Lock::Timeout=120"];
506
+ const IMPORT_GUARD =
507
+ "import importlib.util,sys; sys.exit(importlib.util.find_spec(sys.argv[1]) is None)";
508
+
509
+ type RootMode = "direct" | "sudo" | "needs-root" | "needs-password";
510
+
511
+ /** The paste-able form of a root step, without its `sudo`. */
512
+ function asRootLine(step: InstallStep, os: Os): string {
513
+ if (step.kind === "argv" && step.argv[0] === "apt-get") {
514
+ const install = describeStep({ ...step, escalation: "none" }, os);
515
+ return `apt-get update && ${install}`;
516
+ }
517
+ return describeStep(
518
+ "escalation" in step ? { ...step, escalation: "none" } : step,
519
+ os,
520
+ ).replace(/^sudo /, "");
521
+ }
522
+
523
+ function rootRemedy(
524
+ mode: RootMode,
525
+ step: InstallStep,
526
+ os: Os,
527
+ projectDir: string | null,
528
+ ): string {
529
+ const asRoot = asRootLine(step, os);
530
+ if (mode === "needs-root") return `as root: ${asRoot}`;
531
+ return `run: sudo -v then: ${doctorFixCommand(projectDir)} (or run yourself: ${describeStep(step, os)}; not in sudoers? as root: ${asRoot})`;
532
+ }
533
+
534
+ /**
535
+ * Run the plan. Asks once (unless `yes`), takes the single-apply lock, decides
536
+ * root once, then runs each step in order. A step whose prerequisite step did
537
+ * not install is `blocked`. Returns one outcome per step; prints nothing.
538
+ * Throws {@link DepsLockBusy} when another apply is running.
539
+ */
540
+ export async function applyDepsPlan(
541
+ plan: DepsPlan,
542
+ opts: ApplyOptions,
543
+ ): Promise<ApplyOutcome[]> {
544
+ if (plan.steps.length === 0) return [];
545
+ if (!opts.yes && !(await opts.confirm(plan))) return [];
546
+ const io: ApplyIO = { ...REAL_APPLY_IO, ...opts.io };
547
+ const release = acquireLock(opts.lockPath, io);
548
+ try {
549
+ return await applySteps(plan, opts, io);
550
+ } finally {
551
+ release();
552
+ }
553
+ }
554
+
555
+ async function applySteps(
556
+ plan: DepsPlan,
557
+ opts: ApplyOptions,
558
+ io: ApplyIO,
559
+ ): Promise<ApplyOutcome[]> {
560
+ const { env } = opts;
561
+ const userPrefixDirs = USER_PREFIXES.map((u) => expandHome(u.dir, env.home));
562
+ // Children see the user prefixes too: a tool installed a step ago is there.
563
+ const childPath = [env.path, ...userPrefixDirs]
564
+ .filter(Boolean)
565
+ .join(delimiter);
566
+ const childEnv = { ...process.env, PATH: childPath, HOME: env.home };
567
+ const resolve = (tool: string) =>
568
+ onPath(tool, env.path) ?? inUserPrefix(tool, env.home) ?? tool;
569
+
570
+ const anyRoot = plan.steps.some(
571
+ (p) => "escalation" in p.step && p.step.escalation === "sudo",
572
+ );
573
+ let mode: RootMode = "direct";
574
+ if (anyRoot && io.uid() !== 0) {
575
+ if (onPath("sudo", env.path) === null) mode = "needs-root";
576
+ else {
577
+ const probe = await io.run(["sudo", "-n", "true"], {
578
+ timeoutMs: 10_000,
579
+ env: childEnv,
580
+ });
581
+ mode = probe.exitCode === 0 ? "sudo" : "needs-password";
582
+ }
583
+ }
584
+
585
+ const outcomes: ApplyOutcome[] = [];
586
+ const done = new Set<string>();
587
+ for (const p of plan.steps) {
588
+ const { step } = p;
589
+ const describe = describeStep(step, env.os);
590
+ const base = { name: step.name, requiredBy: p.requiredBy };
591
+ const blocker = p.needs.find((n) => !done.has(n));
592
+ if (blocker) {
593
+ const outcome: ApplyOutcome = {
594
+ ...base,
595
+ status: "blocked",
596
+ exitCode: null,
597
+ outputTail: "",
598
+ durationMs: 0,
599
+ remedy: `after ${blocker} is installed: ${describe}`,
600
+ };
601
+ outcomes.push(outcome);
602
+ opts.onEvent?.({ type: "step-end", outcome });
603
+ continue;
604
+ }
605
+ const root = "escalation" in step && step.escalation === "sudo";
606
+ if (root && (mode === "needs-root" || mode === "needs-password")) {
607
+ const outcome: ApplyOutcome = {
608
+ ...base,
609
+ status: mode,
610
+ exitCode: null,
611
+ outputTail: "",
612
+ durationMs: 0,
613
+ remedy: rootRemedy(mode, step, env.os, opts.projectDir ?? null),
614
+ };
615
+ outcomes.push(outcome);
616
+ opts.onEvent?.({ type: "step-end", outcome });
617
+ continue;
618
+ }
619
+ opts.onEvent?.({ type: "step-start", name: step.name, describe });
620
+ const started = Date.now();
621
+ const run = (argv: string[], escalated = false) =>
622
+ io.run(argv, {
623
+ timeoutMs: STEP_TIMEOUT_MS,
624
+ env: childEnv,
625
+ ...(escalated ? { killGraceMs: SUDO_KILL_GRACE_MS } : {}),
626
+ onOutput: (chunk) =>
627
+ opts.onEvent?.({ type: "output", name: step.name, chunk }),
628
+ });
629
+ const escalate = (argv: string[]) =>
630
+ !root
631
+ ? argv
632
+ : mode === "sudo"
633
+ ? [...SUDO, ...argv]
634
+ : [...AS_ROOT, ...argv];
635
+ let result: StepResult;
636
+ try {
637
+ result = await runStep(step, {
638
+ env,
639
+ io,
640
+ run,
641
+ escalate,
642
+ root,
643
+ resolve,
644
+ });
645
+ } catch (error) {
646
+ result = {
647
+ ok: false,
648
+ exitCode: null,
649
+ outputTail: error instanceof Error ? error.message : String(error),
650
+ };
651
+ }
652
+ const found = result.ok
653
+ ? await verifyInstalled(p, { env, io, run, resolve })
654
+ : null;
655
+ const outcome: ApplyOutcome = {
656
+ ...base,
657
+ status: found?.status ?? "failed",
658
+ ...(result.argv ? { argv: result.argv } : {}),
659
+ exitCode: result.exitCode,
660
+ outputTail: found?.why
661
+ ? [result.outputTail, found.why].filter(Boolean).join("\n")
662
+ : result.outputTail,
663
+ durationMs: Date.now() - started,
664
+ ...(found?.dir ? { dir: found.dir } : {}),
665
+ ...(found?.status === "installed"
666
+ ? {}
667
+ : {
668
+ remedy:
669
+ found?.remedy ?? (root ? `run yourself: ${describe}` : describe),
670
+ }),
671
+ };
672
+ // Off PATH still exists on disk: a later step reaches it by full path.
673
+ if (found?.status === "installed" || found?.status === "off-path") {
674
+ done.add(step.name);
675
+ }
676
+ outcomes.push(outcome);
677
+ opts.onEvent?.({ type: "step-end", outcome });
678
+ }
679
+ return outcomes;
680
+ }
681
+
682
+ interface Verified {
683
+ status: "installed" | "off-path" | "failed";
684
+ dir?: string;
685
+ why?: string;
686
+ remedy?: string;
687
+ }
688
+
689
+ /** `~/…` for a directory under `home`, else the directory as given. */
690
+ function homeRelative(dir: string, home: string): string {
691
+ return dir === home || dir.startsWith(`${home}/`)
692
+ ? `~${dir.slice(home.length)}`
693
+ : dir;
694
+ }
695
+
696
+ /**
697
+ * Where a step put its binary when that is not a fixed user prefix: for
698
+ * `bun add -g`, bun's own global bin directory (`bun pm bin -g`), which
699
+ * follows BUN_INSTALL and XDG_CACHE_HOME. Null when the step names no such
700
+ * place.
701
+ */
702
+ async function landingDir(
703
+ step: InstallStep,
704
+ ctx: Pick<StepContext, "run" | "resolve">,
705
+ ): Promise<string | null> {
706
+ if (
707
+ step.kind === "argv" &&
708
+ step.argv[0] === "bun" &&
709
+ step.argv[1] === "add"
710
+ ) {
711
+ const r = await ctx.run([ctx.resolve("bun"), "pm", "bin", "-g"]);
712
+ const dir = r.exitCode === 0 ? r.outputTail.trim().split("\n").pop() : "";
713
+ return dir?.trim() || null;
714
+ }
715
+ return null;
716
+ }
717
+
718
+ /**
719
+ * After a step's installer exited 0, re-probe everything it installs with the
720
+ * same probe doctor runs. Installed only when the probe now finds each one.
721
+ * Found in a directory off the PATH Claude Code starts with: off-path, naming
722
+ * the directory and the rc line. Not found at all: failed.
723
+ */
724
+ async function verifyInstalled(
725
+ p: PlannedStep,
726
+ ctx: Pick<StepContext, "env" | "io" | "run" | "resolve">,
727
+ ): Promise<Verified> {
728
+ const { env } = ctx;
729
+ const offPath: Verified[] = [];
730
+ const missing: string[] = [];
731
+ for (const target of p.installs) {
732
+ const presence = await ctx.io.verify(target, env);
733
+ if (presence.status === "present") continue;
734
+ if (presence.status === "off-path") {
735
+ offPath.push({ status: "off-path", dir: presence.prefix });
736
+ continue;
737
+ }
738
+ const tool = target.requirement.check[0] ?? target.requirement.name;
739
+ const landed = await landingDir(p.step, ctx);
740
+ if (landed && onPath(tool, landed)) {
741
+ offPath.push({ status: "off-path", dir: homeRelative(landed, env.home) });
742
+ continue;
743
+ }
744
+ missing.push(
745
+ `${target.requirement.name}: the installer exited 0, but \`${target.requirement.check.join(" ")}\` still fails`,
746
+ );
747
+ }
748
+ if (missing.length > 0) {
749
+ return { status: "failed", why: missing.join("\n") };
750
+ }
751
+ const first = offPath[0];
752
+ if (first?.dir) {
753
+ const line = pathRcLine(first.dir, env.userShell);
754
+ return {
755
+ status: "off-path",
756
+ dir: first.dir,
757
+ why: `installed in ${first.dir}, which is not on the PATH Claude Code starts with`,
758
+ remedy: `add ${line} to ${rcFile(env.userShell)}, then restart Claude Code from a new terminal`,
759
+ };
760
+ }
761
+ return { status: "installed" };
762
+ }
763
+
764
+ interface StepResult {
765
+ ok: boolean;
766
+ argv?: string[];
767
+ exitCode: number | null;
768
+ outputTail: string;
769
+ }
770
+
771
+ interface StepContext {
772
+ env: PlanEnv;
773
+ io: ApplyIO;
774
+ run(argv: string[], escalated?: boolean): Promise<RunResult>;
775
+ escalate(argv: string[]): string[];
776
+ root: boolean;
777
+ resolve(tool: string): string;
778
+ }
779
+
780
+ const fromRun = (argv: string[], r: RunResult): StepResult => ({
781
+ ok: r.exitCode === 0 && !r.timedOut && r.spawnError === undefined,
782
+ argv,
783
+ exitCode: r.exitCode,
784
+ outputTail: r.spawnError ?? r.outputTail,
785
+ });
786
+
787
+ /** Run argvs in order, stopping at the first that fails. */
788
+ async function sequence(
789
+ ctx: StepContext,
790
+ argvs: string[][],
791
+ escalated: boolean,
792
+ ): Promise<StepResult> {
793
+ let last: StepResult = { ok: true, exitCode: 0, outputTail: "" };
794
+ for (const argv of argvs) {
795
+ last = fromRun(argv, await ctx.run(argv, escalated));
796
+ if (!last.ok) return last;
797
+ }
798
+ return last;
799
+ }
800
+
801
+ async function runStep(
802
+ step: InstallStep,
803
+ ctx: StepContext,
804
+ ): Promise<StepResult> {
805
+ switch (step.kind) {
806
+ case "argv": {
807
+ const [tool = "", ...args] = step.argv;
808
+ if (tool === "apt-get") {
809
+ const install = ctx.escalate(["apt-get", ...APT_LOCK, ...args]);
810
+ return sequence(
811
+ ctx,
812
+ [
813
+ ctx.escalate(["dpkg", "--configure", "-a"]),
814
+ ctx.escalate(["apt-get", ...APT_LOCK, "update"]),
815
+ install,
816
+ ],
817
+ ctx.root,
818
+ );
819
+ }
820
+ return sequence(
821
+ ctx,
822
+ [ctx.escalate([ctx.resolve(tool), ...args])],
823
+ ctx.root,
824
+ );
825
+ }
826
+ case "uv-module":
827
+ return runUvModule(step, ctx);
828
+ case "bootstrap":
829
+ return runBootstrap(step, ctx);
830
+ case "release":
831
+ return runRelease(step, ctx);
832
+ default:
833
+ return assertNever(step);
834
+ }
835
+ }
836
+
837
+ async function runUvModule(
838
+ step: Extract<InstallStep, { kind: "uv-module" }>,
839
+ ctx: StepContext,
840
+ ): Promise<StepResult> {
841
+ const uv = ctx.resolve("uv");
842
+ const sync = await sequence(
843
+ ctx,
844
+ [uvScriptArgv(["sync"], step.script, uv)],
845
+ false,
846
+ );
847
+ if (!sync.ok) return sync;
848
+ const findArgv = uvScriptArgv(["python", "find"], step.script, uv);
849
+ const found = await ctx.run(findArgv);
850
+ const findResult = fromRun(findArgv, found);
851
+ if (!findResult.ok) return findResult;
852
+ const py = found.outputTail.trim().split("\n").pop()?.trim() ?? "";
853
+ if (!py) {
854
+ return {
855
+ ...findResult,
856
+ ok: false,
857
+ outputTail: "uv python find printed no interpreter",
858
+ };
859
+ }
860
+ const guardArgv = [py, "-c", IMPORT_GUARD, step.module];
861
+ const guard = fromRun(guardArgv, await ctx.run(guardArgv));
862
+ if (!guard.ok) {
863
+ return {
864
+ ...guard,
865
+ ok: false,
866
+ outputTail: `interpreter ${py} cannot import ${step.module}: the script env was not built`,
867
+ };
868
+ }
869
+ return sequence(
870
+ ctx,
871
+ [ctx.escalate([py, "-B", "-m", step.module, ...step.args])],
872
+ ctx.root,
873
+ );
874
+ }
875
+
876
+ async function runBootstrap(
877
+ step: Extract<InstallStep, { kind: "bootstrap" }>,
878
+ ctx: StepContext,
879
+ ): Promise<StepResult> {
880
+ const body = await ctx.io.download(step.url);
881
+ const dir = mkdtempSync(join(tmpdir(), "magus-bootstrap-"));
882
+ try {
883
+ const file = join(dir, "install.sh");
884
+ writeFileSync(file, body, { mode: 0o600 });
885
+ return await sequence(ctx, [[ctx.resolve(step.interpreter), file]], false);
886
+ } finally {
887
+ rmSync(dir, { recursive: true, force: true });
888
+ }
889
+ }
890
+
891
+ const sha256 = (bytes: Uint8Array): string =>
892
+ createHash("sha256").update(bytes).digest("hex");
893
+
894
+ /**
895
+ * Download, verify against the pinned digest, extract the one binary into a
896
+ * temp dir inside the destination (so the final rename stays on one
897
+ * filesystem), refuse anything but a regular file, then rename into place.
898
+ * Nothing is written to the destination unless every check passed.
899
+ */
900
+ async function runRelease(
901
+ step: Extract<InstallStep, { kind: "release" }>,
902
+ ctx: StepContext,
903
+ ): Promise<StepResult> {
904
+ if (!step.sha256) {
905
+ return {
906
+ ok: false,
907
+ exitCode: null,
908
+ outputTail: "no pinned sha256 for this platform",
909
+ };
910
+ }
911
+ const body = await ctx.io.download(step.url);
912
+ const actual = sha256(body);
913
+ if (actual !== step.sha256) {
914
+ return {
915
+ ok: false,
916
+ exitCode: null,
917
+ outputTail: `sha256 mismatch for ${step.url}: expected ${step.sha256}, got ${actual} — nothing was installed`,
918
+ };
919
+ }
920
+ const dest = expandHome(step.dest, ctx.env.home);
921
+ mkdirSync(dest, { recursive: true });
922
+ const tmp = mkdtempSync(join(dest, ".magus-tmp-"));
923
+ try {
924
+ if (step.format === "binary") {
925
+ // The asset is the binary: the verified bytes are the file.
926
+ const file = join(tmp, step.binary);
927
+ writeFileSync(file, body, { mode: 0o755 });
928
+ chmodSync(file, 0o755);
929
+ renameSync(file, join(dest, step.binary));
930
+ return { ok: true, exitCode: 0, outputTail: `verified sha256 ${actual}` };
931
+ }
932
+ const archive = join(tmp, step.url.slice(step.url.lastIndexOf("/") + 1));
933
+ writeFileSync(archive, body, { mode: 0o600 });
934
+ const out = join(tmp, "out");
935
+ mkdirSync(out);
936
+ const tarArgv = [
937
+ ctx.resolve("tar"),
938
+ "-xzf",
939
+ archive,
940
+ "-C",
941
+ out,
942
+ "--",
943
+ step.binary,
944
+ ];
945
+ const extracted = fromRun(tarArgv, await ctx.run(tarArgv));
946
+ if (!extracted.ok) return extracted;
947
+ const file = join(out, step.binary);
948
+ const stat = lstatSync(file);
949
+ if (!stat.isFile()) {
950
+ return {
951
+ ...extracted,
952
+ ok: false,
953
+ outputTail: `${step.binary} in the archive is not a regular file — refused`,
954
+ };
955
+ }
956
+ chmodSync(file, 0o755);
957
+ renameSync(file, join(dest, step.binary));
958
+ return { ...extracted, outputTail: `verified sha256 ${actual}` };
959
+ } finally {
960
+ rmSync(tmp, { recursive: true, force: true });
961
+ }
962
+ }