magus-cli 7.8.0 → 7.9.1

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 (88) 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/review-followups/bun-install-path.test.ts +57 -0
  11. package/src/__tests__/blind/review-followups/d1-empty-lock.test.ts +94 -0
  12. package/src/__tests__/blind/review-followups/d2-disappearing-lock.test.ts +90 -0
  13. package/src/__tests__/blind/review-followups/d3-release-temp-cleanup.test.ts +83 -0
  14. package/src/__tests__/blind/review-followups/d4-tool-stdout.test.ts +152 -0
  15. package/src/__tests__/blind/review-followups/installer-redirect.test.ts +74 -0
  16. package/src/__tests__/blind/review-followups/ledger-permissions.test.ts +78 -0
  17. package/src/__tests__/blind/review-followups/ledger-pruning.test.ts +79 -0
  18. package/src/__tests__/blind/review-followups/path-advice.test.ts +46 -0
  19. package/src/__tests__/blind/review-followups/stats-requires.test.ts +16 -0
  20. package/src/__tests__/blind-780-cli-tools.test.ts +36 -48
  21. package/src/__tests__/blind-780-review2.test.ts +0 -25
  22. package/src/__tests__/cli-apply-seams.test.ts +2 -67
  23. package/src/__tests__/cli-tool-commands.test.ts +131 -6
  24. package/src/__tests__/cli-tools-adapter.test.ts +52 -2
  25. package/src/__tests__/cli-tools-plugin-managed.test.tsx +83 -0
  26. package/src/__tests__/deps-adapter.test.ts +191 -0
  27. package/src/__tests__/deps-check-hook.test.ts +220 -0
  28. package/src/__tests__/deps-plan.test.ts +897 -0
  29. package/src/__tests__/deps-report.test.ts +197 -0
  30. package/src/__tests__/doctor-bins.test.ts +134 -36
  31. package/src/__tests__/doctor-json.test.ts +261 -0
  32. package/src/__tests__/failure-popup-review2.test.tsx +1 -25
  33. package/src/__tests__/final-fixes-780.test.tsx +25 -9
  34. package/src/__tests__/fixtures/bins.ts +55 -0
  35. package/src/__tests__/install-plan.test.ts +15 -1
  36. package/src/__tests__/marketplace-refresh.test.ts +9 -3
  37. package/src/__tests__/no-shell-spawn.test.ts +77 -0
  38. package/src/__tests__/npm-package-name.test.ts +6 -9
  39. package/src/__tests__/plugin-requires.test.ts +138 -153
  40. package/src/__tests__/requires-probe.test.ts +858 -0
  41. package/src/__tests__/resolver.test.ts +129 -52
  42. package/src/__tests__/run.test.ts +108 -0
  43. package/src/__tests__/tool-probe.test.ts +1 -1
  44. package/src/__tests__/toolchain.test.ts +123 -52
  45. package/src/__tests__/update-plan.test.ts +1 -1
  46. package/src/cli/ansi.ts +2 -7
  47. package/src/cli/deps-view.ts +213 -0
  48. package/src/cli/doctor.ts +169 -24
  49. package/src/cli/install.ts +51 -66
  50. package/src/cli/profile.ts +20 -2
  51. package/src/cli/router.ts +6 -2
  52. package/src/cli/update.ts +28 -7
  53. package/src/data/cli-tools.ts +24 -9
  54. package/src/services/active-closure.ts +3 -3
  55. package/src/services/claude-settings.ts +4 -348
  56. package/src/services/cli-tool-commands.ts +104 -3
  57. package/src/services/deps-plan.ts +1154 -0
  58. package/src/services/deps-report.ts +265 -0
  59. package/src/services/doctor-bins.ts +115 -43
  60. package/src/services/install-plan.ts +11 -1
  61. package/src/services/local-marketplace.ts +12 -206
  62. package/src/services/marketplace-refresh.ts +4 -4
  63. package/src/services/plugin-manager.ts +4 -18
  64. package/src/services/plugin-requires.ts +93 -162
  65. package/src/services/resolver.ts +168 -58
  66. package/src/services/tool-probe.ts +18 -25
  67. package/src/services/toolchain.ts +117 -111
  68. package/src/services/update-engine.ts +19 -14
  69. package/src/services/update-plan.ts +3 -3
  70. package/src/shared/plugin/hooks/bun-missing.sh +64 -0
  71. package/src/shared/plugin/hooks/deps-check.ts +240 -0
  72. package/src/shared/plugin/hooks/lib/requires-probe.ts +1279 -0
  73. package/src/types/index.ts +53 -16
  74. package/src/ui/App.tsx +6 -40
  75. package/src/ui/adapters/cliToolsAdapter.ts +75 -37
  76. package/src/ui/adapters/depsAdapter.ts +129 -0
  77. package/src/ui/adapters/startupReports.ts +0 -22
  78. package/src/ui/renderers/cliToolRenderers.tsx +135 -2
  79. package/src/ui/screens/CliToolsScreen.tsx +141 -51
  80. package/src/ui/screens/PluginsScreen.tsx +12 -48
  81. package/src/utils/command-utils.ts +4 -5
  82. package/src/utils/run.ts +143 -52
  83. package/src/utils/string-utils.ts +9 -0
  84. package/src/__tests__/go-module.test.ts +0 -43
  85. package/src/__tests__/plugin-setup.test.ts +0 -1016
  86. package/src/__tests__/shell-script-callers.test.ts +0 -92
  87. package/src/services/plugin-setup.ts +0 -965
  88. package/src/utils/go-module.ts +0 -36
@@ -0,0 +1,1154 @@
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; only one
20
+ * apply runs at a time per user (`DepsLockBusy`); and the temp dirs a killed
21
+ * apply left in a user prefix are swept by the next one.
22
+ */
23
+
24
+ import { createHash } from "node:crypto";
25
+ import {
26
+ chmodSync,
27
+ linkSync,
28
+ lstatSync,
29
+ mkdirSync,
30
+ mkdtempSync,
31
+ readFileSync,
32
+ readdirSync,
33
+ renameSync,
34
+ rmSync,
35
+ unlinkSync,
36
+ writeFileSync,
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 Bootstrap,
44
+ type BootstrapTool,
45
+ type InstallStep,
46
+ LOCAL_PREFIX,
47
+ type Os,
48
+ type Presence,
49
+ type Requirement,
50
+ TOOLCHAIN_BOOTSTRAP,
51
+ USER_PREFIXES,
52
+ type UserPrefix,
53
+ assertNever,
54
+ describeStep,
55
+ doctorFixCommand,
56
+ expandHome,
57
+ onPath,
58
+ pathRcLine,
59
+ probe,
60
+ rcFile,
61
+ rcFileAdding,
62
+ renderInstall,
63
+ uvScriptArgv,
64
+ whyUnselectable,
65
+ } from "../shared/plugin/hooks/lib/requires-probe.js";
66
+ import type { ResolvedBin } from "../types/index.js";
67
+ import { type RunOptions, type RunResult, runArgv } from "../utils/run.js";
68
+ import { parsePluginId } from "../utils/string-utils.js";
69
+
70
+ // ─── planning ─────────────────────────────────────────────────────────────────
71
+
72
+ /** What planning and applying need to know about this machine. */
73
+ export interface PlanEnv {
74
+ os: Os;
75
+ arch: Arch;
76
+ home: string;
77
+ /** The PATH Claude Code and its MCP servers inherit. */
78
+ path: string;
79
+ /** `$SHELL`, to name the rc file for a PATH line. */
80
+ userShell: string;
81
+ }
82
+
83
+ /** A requirement a step installs, and the plugin root its check expands against. */
84
+ export interface InstallTarget {
85
+ requirement: Requirement;
86
+ pluginRoot: string;
87
+ }
88
+
89
+ export interface PlannedStep {
90
+ step: InstallStep;
91
+ /** Plugin ids (or a profile's cliTools) that need it. */
92
+ requiredBy: string[];
93
+ /** Earlier steps (by name) whose tool this step runs. */
94
+ needs: string[];
95
+ /**
96
+ * What the step installs: re-probed after it runs, because an installer that
97
+ * exits 0 has not shown the requirement is now found.
98
+ */
99
+ installs: InstallTarget[];
100
+ }
101
+
102
+ /** A PATH line the user has to add: never one an rc file already has. */
103
+ export interface PathAdvice {
104
+ prefix: UserPrefix;
105
+ rcFile: string;
106
+ rcLine: string;
107
+ }
108
+
109
+ export interface DepsPlan {
110
+ /** Toolchains, then the root batch (apt, root uv-module), then declaration order. */
111
+ steps: PlannedStep[];
112
+ /** What `--fix` cannot do; each `why` reads whole after "<name> — ". */
113
+ unfixable: { name: string; why: string }[];
114
+ pathAdvice: PathAdvice[];
115
+ }
116
+
117
+ /** The plugin ids among a requirement's sources, for display. */
118
+ export function requiredByOf(bin: ResolvedBin): string[] {
119
+ return bin.sources.map((s) =>
120
+ s.startsWith("plugin:") ? s.slice("plugin:".length) : s,
121
+ );
122
+ }
123
+
124
+ /**
125
+ * Why a profile's pin on `bin` is not applied: the plugin that owns the tool
126
+ * installs it with an option that takes no version. The one wording, for
127
+ * doctor, install, the TUI and `magus update`.
128
+ */
129
+ export function refusedPinWhy(bin: ResolvedBin, pin: string): string {
130
+ const owners = requiredByOf(bin).filter((s) => !s.startsWith("profile:"));
131
+ const option = bin.chosen;
132
+ const how = !option
133
+ ? "no option usable here"
134
+ : option.via === "release"
135
+ ? `its ${option.tag} release`
136
+ : option.via === "brew"
137
+ ? `Homebrew (${option.cask ?? option.formula})`
138
+ : option.via;
139
+ 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"`;
140
+ }
141
+
142
+ /** The tool a step needs on the machine before it can run, if any. */
143
+ function toolOf(step: InstallStep): string | null {
144
+ switch (step.kind) {
145
+ case "argv":
146
+ return step.argv[0] ?? null;
147
+ case "uv-module":
148
+ return "uv";
149
+ case "bootstrap":
150
+ case "release":
151
+ return null;
152
+ default:
153
+ return assertNever(step);
154
+ }
155
+ }
156
+
157
+ /** Where a step leaves its binary, when that is a user prefix. */
158
+ export function prefixOf(step: InstallStep): UserPrefix | null {
159
+ switch (step.kind) {
160
+ case "bootstrap":
161
+ return step.prefix;
162
+ case "release":
163
+ return step.dest;
164
+ case "argv":
165
+ if (step.argv[0] === "bun" && step.argv[1] === "add") return BUN_PREFIX;
166
+ if (step.argv[0] === "uv" && step.argv[1] === "tool") return LOCAL_PREFIX;
167
+ return null;
168
+ case "uv-module":
169
+ return null;
170
+ default:
171
+ return assertNever(step);
172
+ }
173
+ }
174
+
175
+ /** `tool` in a user prefix, when it is there but maybe not on PATH. */
176
+ function inUserPrefix(tool: string, home: string): string | null {
177
+ for (const prefix of USER_PREFIXES) {
178
+ const found = onPath(tool, expandHome(prefix.dir, home));
179
+ if (found) return found;
180
+ }
181
+ return null;
182
+ }
183
+
184
+ /** Is `prefix` on the PATH Claude Code starts with? */
185
+ export function prefixOnPath(prefix: UserPrefix, env: PlanEnv): boolean {
186
+ const dir = expandHome(prefix, env.home);
187
+ return env.path.split(delimiter).some((d) => d === dir || d === `${dir}/`);
188
+ }
189
+
190
+ /**
191
+ * The PATH line for a prefix, in the user's rc file; null when a startup file
192
+ * of the user's shell already puts the prefix on PATH. Then there is no line
193
+ * to add: Claude Code started without reading that file, and a restart is the
194
+ * fix, which an off-path step's remedy and the hook banner say.
195
+ */
196
+ export function pathAdviceFor(
197
+ prefix: UserPrefix,
198
+ env: PlanEnv,
199
+ ): PathAdvice | null {
200
+ if (rcFileAdding(prefix, env.userShell, env.home)) return null;
201
+ return {
202
+ prefix,
203
+ rcFile: rcFile(env.userShell),
204
+ rcLine: pathRcLine(prefix, env.userShell),
205
+ };
206
+ }
207
+
208
+ /**
209
+ * What to do about `dir` (a user prefix, or another `~/…` or absolute
210
+ * directory) off the PATH Claude Code starts with: add the line, or — when a
211
+ * startup file of the user's shell already puts it on PATH — only restart.
212
+ */
213
+ function pathRemedy(dir: string, env: PlanEnv): string {
214
+ const adding = rcFileAdding(dir, env.userShell, env.home);
215
+ return adding
216
+ ? `${adding} already puts ${dir} on PATH: restart Claude Code from a new terminal`
217
+ : `add ${pathRcLine(dir, env.userShell)} to ${rcFile(env.userShell)}, then restart Claude Code from a new terminal`;
218
+ }
219
+
220
+ /**
221
+ * The prefix a planned step's installer adds to the user's rc file itself
222
+ * ({@link Bootstrap.editsRc}), or null. A bootstrap step is named after its
223
+ * tool: a requirement may bootstrap only the tool it is named after.
224
+ */
225
+ function rcEditedBy(step: InstallStep): UserPrefix | null {
226
+ if (step.kind !== "bootstrap" || !(step.name in TOOLCHAIN_BOOTSTRAP)) {
227
+ return null;
228
+ }
229
+ const b: Bootstrap = TOOLCHAIN_BOOTSTRAP[step.name as BootstrapTool];
230
+ return b.editsRc ? b.prefix : null;
231
+ }
232
+
233
+ /** A root uv-module step may only come from a plugin of the magus marketplace. */
234
+ function fromMagusMarketplace(bin: ResolvedBin): boolean {
235
+ return bin.sources.some(
236
+ (s) =>
237
+ s.startsWith("plugin:") &&
238
+ parsePluginId(s.slice("plugin:".length))?.marketplace === "magus",
239
+ );
240
+ }
241
+
242
+ /**
243
+ * The steps that install every `missing` requirement, in the order they must
244
+ * run. `presence[i]` is the probe's answer for `bins[i]`. An `off-path`
245
+ * requirement is not reinstalled: it needs a PATH line, which `pathAdvice`
246
+ * carries. An `unknown` one (a check too slow to answer) is left alone.
247
+ */
248
+ export function planDeps(
249
+ bins: ResolvedBin[],
250
+ presence: Presence[],
251
+ env: PlanEnv,
252
+ ): DepsPlan {
253
+ const unfixable: DepsPlan["unfixable"] = [];
254
+ /** The prefixes off the PATH, in the order they came up. */
255
+ const offPathPrefixes = new Set<UserPrefix>();
256
+ const missing: ResolvedBin[] = [];
257
+ bins.forEach((bin, i) => {
258
+ const p = presence[i];
259
+ if (p?.status === "missing") missing.push(bin);
260
+ if (p?.status === "off-path") offPathPrefixes.add(p.prefix);
261
+ });
262
+
263
+ // A pin the owning plugin's option cannot carry: unfixable whatever the
264
+ // tool's presence, because no install here would honour it.
265
+ for (const bin of bins) {
266
+ for (const pin of bin.refusedPins ?? []) {
267
+ unfixable.push({ name: bin.name, why: refusedPinWhy(bin, pin) });
268
+ }
269
+ }
270
+
271
+ const declared = new Set(bins.map((b) => b.name));
272
+ const available = (tool: string) =>
273
+ onPath(tool, env.path) !== null || inUserPrefix(tool, env.home) !== null;
274
+ const has = (tool: string) => declared.has(tool) || available(tool);
275
+
276
+ const toolchains: PlannedStep[] = [];
277
+ const rootBatch: PlannedStep[] = [];
278
+ const rest: PlannedStep[] = [];
279
+ const aptPackages: string[] = [];
280
+ const aptNames: string[] = [];
281
+ const aptRequiredBy = new Set<string>();
282
+ const aptInstalls: InstallTarget[] = [];
283
+ const target = (bin: ResolvedBin): InstallTarget => ({
284
+ requirement: bin,
285
+ pluginRoot: bin.root ?? "",
286
+ });
287
+
288
+ for (const bin of missing) {
289
+ const option = bin.chosen;
290
+ if (!option) {
291
+ unfixable.push({
292
+ name: bin.name,
293
+ why: `cannot be installed here: ${whyUnselectable(bin, env, has)}`,
294
+ });
295
+ continue;
296
+ }
297
+ const step = renderInstall(bin, option, {
298
+ os: env.os,
299
+ arch: env.arch,
300
+ pluginRoot: bin.root ?? "",
301
+ });
302
+ if (
303
+ step.kind === "uv-module" &&
304
+ step.escalation === "sudo" &&
305
+ !fromMagusMarketplace(bin)
306
+ ) {
307
+ unfixable.push({
308
+ name: bin.name,
309
+ 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)}`,
310
+ });
311
+ continue;
312
+ }
313
+ const requiredBy = requiredByOf(bin);
314
+ if (option.via === "apt") {
315
+ aptPackages.push(...option.packages);
316
+ aptNames.push(bin.name);
317
+ aptInstalls.push(target(bin));
318
+ for (const r of requiredBy) aptRequiredBy.add(r);
319
+ continue;
320
+ }
321
+ const planned: PlannedStep = {
322
+ step,
323
+ requiredBy,
324
+ needs: [],
325
+ installs: [target(bin)],
326
+ };
327
+ if (bin.name in TOOLCHAIN_BOOTSTRAP) toolchains.push(planned);
328
+ else if (step.kind === "uv-module" && step.escalation === "sudo")
329
+ rootBatch.push(planned);
330
+ else rest.push(planned);
331
+ }
332
+ if (aptPackages.length > 0) {
333
+ const step = renderInstall(
334
+ { name: aptNames.join(", "), check: [], install: [] },
335
+ { via: "apt", packages: [...new Set(aptPackages)] },
336
+ { os: env.os, arch: env.arch, pluginRoot: "" },
337
+ );
338
+ rootBatch.unshift({
339
+ step,
340
+ requiredBy: [...aptRequiredBy],
341
+ needs: [],
342
+ installs: aptInstalls,
343
+ });
344
+ }
345
+
346
+ const ordered = [...toolchains, ...rootBatch, ...rest];
347
+ const plannedNames = new Set(ordered.map((p) => p.step.name));
348
+ const steps: PlannedStep[] = [];
349
+ for (const p of ordered) {
350
+ const tool = toolOf(p.step);
351
+ if (tool && !available(tool)) {
352
+ if (!plannedNames.has(tool)) {
353
+ unfixable.push({
354
+ name: p.step.name,
355
+ why: `needs ${tool}, which is not installed and cannot be installed here`,
356
+ });
357
+ plannedNames.delete(p.step.name);
358
+ continue;
359
+ }
360
+ p.needs.push(tool);
361
+ }
362
+ steps.push(p);
363
+ const prefix = prefixOf(p.step);
364
+ if (prefix && !prefixOnPath(prefix, env)) offPathPrefixes.add(prefix);
365
+ }
366
+ // A prefix whose installer writes the rc line itself gets no line ahead of
367
+ // it: following one and then running the installer leaves the line twice.
368
+ const rcEdited = new Set(steps.map((p) => rcEditedBy(p.step)));
369
+ const pathAdvice = [...offPathPrefixes]
370
+ .filter((prefix) => !rcEdited.has(prefix))
371
+ .map((prefix) => pathAdviceFor(prefix, env))
372
+ .filter((a): a is PathAdvice => a !== null);
373
+ return { steps, unfixable, pathAdvice };
374
+ }
375
+
376
+ /**
377
+ * One consent line per step: its paste-able form, who needs it, `[sudo]` when
378
+ * it runs as root. Shown before anything runs, by every front end.
379
+ */
380
+ export function planLines(plan: DepsPlan, os: Os): string[] {
381
+ return plan.steps.map((p) => {
382
+ const sudo =
383
+ "escalation" in p.step && p.step.escalation === "sudo" ? "[sudo] " : "";
384
+ const by = p.requiredBy.length > 0 ? ` (${p.requiredBy.join(", ")})` : "";
385
+ return `${sudo}${p.step.name}: ${describeStep(p.step, os)}${by}`;
386
+ });
387
+ }
388
+
389
+ // ─── applying ─────────────────────────────────────────────────────────────────
390
+
391
+ /**
392
+ * "installed" means the re-probe after the step found the requirement — never
393
+ * only that the installer exited 0. "off-path": it landed in `dir`, which is
394
+ * not on the PATH Claude Code starts with.
395
+ */
396
+ export type ApplyStatus =
397
+ | "installed"
398
+ | "off-path"
399
+ | "failed"
400
+ | "needs-password"
401
+ | "needs-root"
402
+ | "blocked";
403
+
404
+ export interface ApplyOutcome {
405
+ name: string;
406
+ status: ApplyStatus;
407
+ requiredBy: string[];
408
+ /** Exactly what ran last for this step (or would have). */
409
+ argv?: string[];
410
+ exitCode: number | null;
411
+ outputTail: string;
412
+ durationMs: number;
413
+ /** "off-path": the directory the binary landed in. */
414
+ dir?: string;
415
+ /** For every status except "installed": the exact command for the user. */
416
+ remedy?: string;
417
+ }
418
+
419
+ export type ApplyEvent =
420
+ | { type: "step-start"; name: string; describe: string }
421
+ | { type: "output"; name: string; chunk: string }
422
+ | { type: "step-end"; outcome: ApplyOutcome };
423
+
424
+ /** The side effects an apply makes, injectable so tests run none of them. */
425
+ export interface ApplyIO {
426
+ run(argv: string[], opts: RunOptions): Promise<RunResult>;
427
+ /** The body at `url`, or throws. */
428
+ download(url: string): Promise<Uint8Array>;
429
+ /** This process's uid; null where the platform has none. */
430
+ uid(): number | null;
431
+ /** Is a process with this pid alive? */
432
+ alive(pid: number): boolean;
433
+ /**
434
+ * The probe's answer for one requirement now, on the PATH Claude Code starts
435
+ * with — the same `probe` doctor and the hook run, at full depth.
436
+ */
437
+ verify(target: InstallTarget, env: PlanEnv): Promise<Presence>;
438
+ }
439
+
440
+ const DOWNLOAD_TIMEOUT_MS = 10 * 60_000;
441
+ /** Redirects a download follows before it gives up. */
442
+ const MAX_REDIRECTS = 10;
443
+ /** The per-check cap a post-install re-probe runs under: doctor's. */
444
+ const VERIFY_CAP_MS = 60_000;
445
+
446
+ /**
447
+ * The body at `url`, every hop of it over https. Redirects are followed here,
448
+ * one at a time, so a hop that is not https is refused before it is requested:
449
+ * a bootstrap script has no digest to catch what a plain-http hop could swap.
450
+ */
451
+ async function httpsDownload(url: string): Promise<Uint8Array> {
452
+ const signal = AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS);
453
+ let at = new URL(url);
454
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
455
+ if (at.protocol !== "https:") {
456
+ throw new Error(
457
+ hop === 0
458
+ ? `${url} is not https — refused`
459
+ : `${url} redirected off https, to ${at.href} — refused`,
460
+ );
461
+ }
462
+ const res = await fetch(at, { redirect: "manual", signal });
463
+ const location = res.headers.get("location");
464
+ if (res.status >= 300 && res.status < 400 && location) {
465
+ await res.body?.cancel();
466
+ at = new URL(location, at);
467
+ continue;
468
+ }
469
+ if (!res.ok) throw new Error(`HTTP ${res.status} for ${at.href}`);
470
+ // A fetch that followed a redirect itself reports where it ended up.
471
+ if (res.url && new URL(res.url).protocol !== "https:") {
472
+ throw new Error(`${url} redirected off https, to ${res.url} — refused`);
473
+ }
474
+ return new Uint8Array(await res.arrayBuffer());
475
+ }
476
+ throw new Error(`${url} redirected more than ${MAX_REDIRECTS} times`);
477
+ }
478
+
479
+ export const REAL_APPLY_IO: ApplyIO = {
480
+ run: (argv, opts) =>
481
+ runArgv({ cmd: argv[0] ?? "", args: argv.slice(1) }, opts),
482
+ download: httpsDownload,
483
+ uid: () => (typeof process.getuid === "function" ? process.getuid() : null),
484
+ alive: (pid) => {
485
+ try {
486
+ process.kill(pid, 0);
487
+ return true;
488
+ } catch (error) {
489
+ return (error as NodeJS.ErrnoException).code === "EPERM";
490
+ }
491
+ },
492
+ verify: async ({ requirement, pluginRoot }, env) => {
493
+ const [presence] = await probe([requirement], {
494
+ pluginRoot,
495
+ os: env.os,
496
+ arch: env.arch,
497
+ home: env.home,
498
+ path: env.path,
499
+ capMs: VERIFY_CAP_MS,
500
+ depth: "full",
501
+ userShell: env.userShell,
502
+ });
503
+ return presence ?? { name: requirement.name, status: "present" };
504
+ },
505
+ };
506
+
507
+ export interface ApplyOptions {
508
+ /** Skip the consent question. */
509
+ yes: boolean;
510
+ /** Asked once with the whole plan, unless `yes`. False installs nothing. */
511
+ confirm(plan: DepsPlan): Promise<boolean>;
512
+ onEvent?: (e: ApplyEvent) => void;
513
+ env: PlanEnv;
514
+ /** The single-apply lock. */
515
+ lockPath: string;
516
+ /** The project a remedy's `magus doctor --fix` names; null for "here". */
517
+ projectDir?: string | null;
518
+ io?: Partial<ApplyIO>;
519
+ }
520
+
521
+ /** Another apply holds the lock; `pid` is null when its holder is not yet known. */
522
+ export class DepsLockBusy extends Error {
523
+ constructor(readonly pid: number | null) {
524
+ super(
525
+ `another magus dependency install${pid === null ? "" : ` (pid ${pid})`} is running — wait for it, then re-run`,
526
+ );
527
+ this.name = "DepsLockBusy";
528
+ }
529
+ }
530
+
531
+ /** `${XDG_CACHE_HOME:-~/.cache}/magus/doctor-fix.lock` */
532
+ export function defaultLockPath(home: string, xdgCacheHome?: string): string {
533
+ return join(xdgCacheHome || join(home, ".cache"), "magus", "doctor-fix.lock");
534
+ }
535
+
536
+ /** How many times a busy lock is tried, and how long apart. */
537
+ const LOCK_ATTEMPTS = 20;
538
+ const LOCK_RETRY_MS = 50;
539
+ /**
540
+ * A lock with no pid in it this old was left by a writer that died between
541
+ * creating it and writing the pid; no live writer is that slow.
542
+ */
543
+ const EMPTY_LOCK_STALE_MS = 10_000;
544
+
545
+ const sleep = (ms: number) =>
546
+ new Promise<void>((resolve) => setTimeout(resolve, ms));
547
+
548
+ /** What the lock file says about its holder. */
549
+ type LockHolder =
550
+ /** Released between the failed take and this read. */
551
+ | { kind: "gone" }
552
+ | { kind: "pid"; pid: number }
553
+ /** Created by a writer that has not written its pid yet, or never will. */
554
+ | { kind: "no-pid"; ageMs: number }
555
+ /** Not a regular file (a dangling symlink, say): no holder ever wrote it. */
556
+ | { kind: "not-a-lock" };
557
+
558
+ function readLockHolder(path: string): LockHolder {
559
+ const gone = (error: unknown): LockHolder => {
560
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
561
+ return { kind: "gone" };
562
+ }
563
+ throw error;
564
+ };
565
+ let mtimeMs: number;
566
+ try {
567
+ const stat = lstatSync(path);
568
+ if (!stat.isFile()) return { kind: "not-a-lock" };
569
+ mtimeMs = stat.mtimeMs;
570
+ } catch (error) {
571
+ return gone(error);
572
+ }
573
+ let text: string;
574
+ try {
575
+ text = readFileSync(path, "utf8").trim();
576
+ } catch (error) {
577
+ return gone(error);
578
+ }
579
+ const pid = Number.parseInt(text, 10);
580
+ return Number.isInteger(pid) && pid > 0
581
+ ? { kind: "pid", pid }
582
+ : { kind: "no-pid", ageMs: Date.now() - mtimeMs };
583
+ }
584
+
585
+ /** Remove a stale lock; one another taker removed first is just as gone. */
586
+ function removeStaleLock(path: string): void {
587
+ try {
588
+ unlinkSync(path);
589
+ } catch (error) {
590
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
591
+ }
592
+ }
593
+
594
+ /**
595
+ * Take the lock. The pid is written to a file of our own first and the lock is
596
+ * a hard link to it, so the lock never exists without a pid in it. A live
597
+ * holder refuses with {@link DepsLockBusy}; one whose pid is gone is taken
598
+ * over. A lock that vanishes before it is read is free, and taken. A lock with
599
+ * no pid (another writer between creating and writing it) is busy: it is read
600
+ * again shortly, and taken over only once it is too old to be mid-write.
601
+ */
602
+ async function acquireLock(path: string, io: ApplyIO): Promise<() => void> {
603
+ mkdirSync(dirname(path), { recursive: true });
604
+ const mine = String(process.pid);
605
+ const staged = `${path}.${mine}`;
606
+ writeFileSync(staged, mine, { mode: 0o600 });
607
+ try {
608
+ for (let attempt = 0; attempt < LOCK_ATTEMPTS; attempt++) {
609
+ try {
610
+ linkSync(staged, path);
611
+ return () => {
612
+ try {
613
+ if (readFileSync(path, "utf8").trim() === mine) unlinkSync(path);
614
+ } catch {
615
+ // Already gone.
616
+ }
617
+ };
618
+ } catch (error) {
619
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
620
+ }
621
+ const holder = readLockHolder(path);
622
+ if (holder.kind === "gone") continue;
623
+ if (holder.kind === "not-a-lock") {
624
+ removeStaleLock(path);
625
+ continue;
626
+ }
627
+ if (holder.kind === "pid") {
628
+ if (io.alive(holder.pid)) throw new DepsLockBusy(holder.pid);
629
+ removeStaleLock(path); // its holder is gone
630
+ continue;
631
+ }
632
+ if (holder.ageMs > EMPTY_LOCK_STALE_MS) {
633
+ removeStaleLock(path);
634
+ continue;
635
+ }
636
+ await sleep(LOCK_RETRY_MS);
637
+ }
638
+ throw new DepsLockBusy(null);
639
+ } finally {
640
+ rmSync(staged, { force: true });
641
+ }
642
+ }
643
+
644
+ /** The temp dir a release step extracts into, inside its destination. */
645
+ const RELEASE_TMP_PREFIX = ".magus-tmp-";
646
+
647
+ /**
648
+ * Remove the temp dirs a release step left in a user prefix when its apply was
649
+ * killed before the step's `finally` ran. Runs holding the lock, so no apply
650
+ * owns any of them.
651
+ */
652
+ function sweepStaleTemp(home: string): void {
653
+ for (const prefix of USER_PREFIXES) {
654
+ const dir = expandHome(prefix.dir, home);
655
+ let entries: string[];
656
+ try {
657
+ entries = readdirSync(dir);
658
+ } catch {
659
+ continue; // no such prefix yet: nothing left behind
660
+ }
661
+ for (const entry of entries) {
662
+ if (!entry.startsWith(RELEASE_TMP_PREFIX)) continue;
663
+ try {
664
+ rmSync(join(dir, entry), { recursive: true, force: true });
665
+ } catch {
666
+ // One that cannot be removed leaves the rest to sweep.
667
+ }
668
+ }
669
+ }
670
+ }
671
+
672
+ const STEP_TIMEOUT_MS = 15 * 60_000;
673
+ const SUDO_KILL_GRACE_MS = 30_000;
674
+ /** A root step as uid 0: the same environment `sudo` would be given. */
675
+ const AS_ROOT = ["env", "DEBIAN_FRONTEND=noninteractive"];
676
+ /** A root step through sudo: never a prompt, and the env survives env_reset. */
677
+ const SUDO = ["sudo", "-n", ...AS_ROOT];
678
+ const APT_LOCK = ["-o", "DPkg::Lock::Timeout=120"];
679
+ const IMPORT_GUARD =
680
+ "import importlib.util,sys; sys.exit(importlib.util.find_spec(sys.argv[1]) is None)";
681
+
682
+ type RootMode = "direct" | "sudo" | "needs-root" | "needs-password";
683
+
684
+ /** The paste-able form of a root step, without its `sudo`. */
685
+ function asRootLine(step: InstallStep, os: Os): string {
686
+ if (step.kind === "argv" && step.argv[0] === "apt-get") {
687
+ const install = describeStep({ ...step, escalation: "none" }, os);
688
+ return `apt-get update && ${install}`;
689
+ }
690
+ return describeStep(
691
+ "escalation" in step ? { ...step, escalation: "none" } : step,
692
+ os,
693
+ ).replace(/^sudo /, "");
694
+ }
695
+
696
+ function rootRemedy(
697
+ mode: RootMode,
698
+ step: InstallStep,
699
+ os: Os,
700
+ projectDir: string | null,
701
+ ): string {
702
+ const asRoot = asRootLine(step, os);
703
+ if (mode === "needs-root") return `as root: ${asRoot}`;
704
+ return `run: sudo -v then: ${doctorFixCommand(projectDir)} (or run yourself: ${describeStep(step, os)}; not in sudoers? as root: ${asRoot})`;
705
+ }
706
+
707
+ /**
708
+ * Run the plan. Asks once (unless `yes`), takes the single-apply lock, decides
709
+ * root once, then runs each step in order. A step whose prerequisite step did
710
+ * not install is `blocked`. Returns one outcome per step; prints nothing.
711
+ * Throws {@link DepsLockBusy} when another apply is running.
712
+ */
713
+ export async function applyDepsPlan(
714
+ plan: DepsPlan,
715
+ opts: ApplyOptions,
716
+ ): Promise<ApplyOutcome[]> {
717
+ if (plan.steps.length === 0) return [];
718
+ if (!opts.yes && !(await opts.confirm(plan))) return [];
719
+ const io: ApplyIO = { ...REAL_APPLY_IO, ...opts.io };
720
+ const release = await acquireLock(opts.lockPath, io);
721
+ try {
722
+ sweepStaleTemp(opts.env.home);
723
+ return await applySteps(plan, opts, io);
724
+ } finally {
725
+ release();
726
+ }
727
+ }
728
+
729
+ async function applySteps(
730
+ plan: DepsPlan,
731
+ opts: ApplyOptions,
732
+ io: ApplyIO,
733
+ ): Promise<ApplyOutcome[]> {
734
+ const { env } = opts;
735
+ const userPrefixDirs = USER_PREFIXES.map((u) => expandHome(u.dir, env.home));
736
+ // Children see the user prefixes too: a tool installed a step ago is there.
737
+ const childPath = [env.path, ...userPrefixDirs]
738
+ .filter(Boolean)
739
+ .join(delimiter);
740
+ const childEnv = { ...process.env, PATH: childPath, HOME: env.home };
741
+ const resolve = (tool: string) =>
742
+ onPath(tool, env.path) ?? inUserPrefix(tool, env.home) ?? tool;
743
+
744
+ const anyRoot = plan.steps.some(
745
+ (p) => "escalation" in p.step && p.step.escalation === "sudo",
746
+ );
747
+ let mode: RootMode = "direct";
748
+ if (anyRoot && io.uid() !== 0) {
749
+ if (onPath("sudo", env.path) === null) mode = "needs-root";
750
+ else {
751
+ const probe = await io.run(["sudo", "-n", "true"], {
752
+ timeoutMs: 10_000,
753
+ env: childEnv,
754
+ });
755
+ mode = probe.exitCode === 0 ? "sudo" : "needs-password";
756
+ }
757
+ }
758
+
759
+ const outcomes: ApplyOutcome[] = [];
760
+ const done = new Set<string>();
761
+ for (const p of plan.steps) {
762
+ const { step } = p;
763
+ const describe = describeStep(step, env.os);
764
+ const base = { name: step.name, requiredBy: p.requiredBy };
765
+ const blocker = p.needs.find((n) => !done.has(n));
766
+ if (blocker) {
767
+ const outcome: ApplyOutcome = {
768
+ ...base,
769
+ status: "blocked",
770
+ exitCode: null,
771
+ outputTail: "",
772
+ durationMs: 0,
773
+ remedy: `after ${blocker} is installed: ${describe}`,
774
+ };
775
+ outcomes.push(outcome);
776
+ opts.onEvent?.({ type: "step-end", outcome });
777
+ continue;
778
+ }
779
+ const root = "escalation" in step && step.escalation === "sudo";
780
+ if (root && (mode === "needs-root" || mode === "needs-password")) {
781
+ const outcome: ApplyOutcome = {
782
+ ...base,
783
+ status: mode,
784
+ exitCode: null,
785
+ outputTail: "",
786
+ durationMs: 0,
787
+ remedy: rootRemedy(mode, step, env.os, opts.projectDir ?? null),
788
+ };
789
+ outcomes.push(outcome);
790
+ opts.onEvent?.({ type: "step-end", outcome });
791
+ continue;
792
+ }
793
+ opts.onEvent?.({ type: "step-start", name: step.name, describe });
794
+ const started = Date.now();
795
+ const run = (argv: string[], how: RunHow = {}) =>
796
+ io.run(argv, {
797
+ timeoutMs: STEP_TIMEOUT_MS,
798
+ env: how.env ? { ...childEnv, ...how.env } : childEnv,
799
+ ...(how.escalated ? { killGraceMs: SUDO_KILL_GRACE_MS } : {}),
800
+ ...(how.captureStdout ? { captureStdout: true } : {}),
801
+ onOutput: (chunk) =>
802
+ opts.onEvent?.({ type: "output", name: step.name, chunk }),
803
+ });
804
+ const escalate = (argv: string[]) =>
805
+ !root
806
+ ? argv
807
+ : mode === "sudo"
808
+ ? [...SUDO, ...argv]
809
+ : [...AS_ROOT, ...argv];
810
+ let result: StepResult;
811
+ try {
812
+ result = await runStep(step, {
813
+ env,
814
+ io,
815
+ run,
816
+ escalate,
817
+ root,
818
+ resolve,
819
+ });
820
+ } catch (error) {
821
+ result = {
822
+ ok: false,
823
+ exitCode: null,
824
+ outputTail: error instanceof Error ? error.message : String(error),
825
+ };
826
+ }
827
+ const found = result.ok
828
+ ? await verifyInstalled(p, { env, io, run, resolve })
829
+ : null;
830
+ const outcome: ApplyOutcome = {
831
+ ...base,
832
+ status: found?.status ?? "failed",
833
+ ...(result.argv ? { argv: result.argv } : {}),
834
+ exitCode: result.exitCode,
835
+ outputTail: found?.why
836
+ ? [result.outputTail, found.why].filter(Boolean).join("\n")
837
+ : result.outputTail,
838
+ durationMs: Date.now() - started,
839
+ ...(found?.dir ? { dir: found.dir } : {}),
840
+ ...(found?.status === "installed"
841
+ ? {}
842
+ : {
843
+ remedy:
844
+ found?.remedy ?? (root ? `run yourself: ${describe}` : describe),
845
+ }),
846
+ };
847
+ // Off PATH still exists on disk: a later step reaches it by full path.
848
+ if (found?.status === "installed" || found?.status === "off-path") {
849
+ done.add(step.name);
850
+ }
851
+ outcomes.push(outcome);
852
+ opts.onEvent?.({ type: "step-end", outcome });
853
+ }
854
+ return outcomes;
855
+ }
856
+
857
+ interface Verified {
858
+ status: "installed" | "off-path" | "failed";
859
+ dir?: string;
860
+ why?: string;
861
+ remedy?: string;
862
+ }
863
+
864
+ /** `~/…` for a directory under `home`, else the directory as given. */
865
+ function homeRelative(dir: string, home: string): string {
866
+ return dir === home || dir.startsWith(`${home}/`)
867
+ ? `~${dir.slice(home.length)}`
868
+ : dir;
869
+ }
870
+
871
+ /**
872
+ * Where a step put its binary when that is not a fixed user prefix: for
873
+ * `bun add -g`, bun's own global bin directory (`bun pm bin -g`), which
874
+ * follows BUN_INSTALL and XDG_CACHE_HOME. Null when the step names no such
875
+ * place.
876
+ */
877
+ async function landingDir(
878
+ step: InstallStep,
879
+ ctx: Pick<StepContext, "run" | "resolve">,
880
+ ): Promise<string | null> {
881
+ if (
882
+ step.kind === "argv" &&
883
+ step.argv[0] === "bun" &&
884
+ step.argv[1] === "add"
885
+ ) {
886
+ const r = await ctx.run([ctx.resolve("bun"), "pm", "bin", "-g"], {
887
+ captureStdout: true,
888
+ });
889
+ return r.exitCode === 0 ? lastLine(r.stdout) : null;
890
+ }
891
+ return null;
892
+ }
893
+
894
+ /**
895
+ * After a step's installer exited 0, re-probe everything it installs with the
896
+ * same probe doctor runs. Installed only when the probe now finds each one.
897
+ * Found in a directory off the PATH Claude Code starts with: off-path, naming
898
+ * the directory and the rc line. Not found at all: failed.
899
+ */
900
+ async function verifyInstalled(
901
+ p: PlannedStep,
902
+ ctx: Pick<StepContext, "env" | "io" | "run" | "resolve">,
903
+ ): Promise<Verified> {
904
+ const { env } = ctx;
905
+ const offPath: Verified[] = [];
906
+ const missing: string[] = [];
907
+ for (const target of p.installs) {
908
+ const presence = await ctx.io.verify(target, env);
909
+ if (presence.status === "present") continue;
910
+ if (presence.status === "off-path") {
911
+ offPath.push({ status: "off-path", dir: presence.prefix });
912
+ continue;
913
+ }
914
+ const tool = target.requirement.check[0] ?? target.requirement.name;
915
+ const landed = await landingDir(p.step, ctx);
916
+ if (landed && onPath(tool, landed)) {
917
+ offPath.push({ status: "off-path", dir: homeRelative(landed, env.home) });
918
+ continue;
919
+ }
920
+ missing.push(
921
+ `${target.requirement.name}: the installer exited 0, but \`${target.requirement.check.join(" ")}\` still fails`,
922
+ );
923
+ }
924
+ if (missing.length > 0) {
925
+ return { status: "failed", why: missing.join("\n") };
926
+ }
927
+ const first = offPath[0];
928
+ if (first?.dir) {
929
+ return {
930
+ status: "off-path",
931
+ dir: first.dir,
932
+ why: `installed in ${first.dir}, which is not on the PATH Claude Code starts with`,
933
+ remedy: pathRemedy(first.dir, env),
934
+ };
935
+ }
936
+ return { status: "installed" };
937
+ }
938
+
939
+ interface StepResult {
940
+ ok: boolean;
941
+ argv?: string[];
942
+ exitCode: number | null;
943
+ outputTail: string;
944
+ }
945
+
946
+ /** How one argv of a step runs. */
947
+ interface RunHow {
948
+ /** Through sudo: SIGKILL cannot be relayed, so it gets a longer grace. */
949
+ escalated?: boolean;
950
+ /** Added to the child's environment. */
951
+ env?: Readonly<Record<string, string>>;
952
+ /** A query whose answer is its stdout: `RunResult.stdout` is set. */
953
+ captureStdout?: boolean;
954
+ }
955
+
956
+ interface StepContext {
957
+ env: PlanEnv;
958
+ io: ApplyIO;
959
+ run(argv: string[], how?: RunHow): Promise<RunResult>;
960
+ escalate(argv: string[]): string[];
961
+ root: boolean;
962
+ resolve(tool: string): string;
963
+ }
964
+
965
+ const fromRun = (argv: string[], r: RunResult): StepResult => ({
966
+ ok: r.exitCode === 0 && !r.timedOut && r.spawnError === undefined,
967
+ argv,
968
+ exitCode: r.exitCode,
969
+ outputTail: r.spawnError ?? r.outputTail,
970
+ });
971
+
972
+ /** The last non-empty line of a query's stdout, or null. */
973
+ function lastLine(stdout: string | undefined): string | null {
974
+ const line = (stdout ?? "")
975
+ .split("\n")
976
+ .map((l) => l.trim())
977
+ .filter(Boolean)
978
+ .pop();
979
+ return line || null;
980
+ }
981
+
982
+ /** Run argvs in order, stopping at the first that fails. */
983
+ async function sequence(
984
+ ctx: StepContext,
985
+ argvs: string[][],
986
+ how: RunHow = {},
987
+ ): Promise<StepResult> {
988
+ let last: StepResult = { ok: true, exitCode: 0, outputTail: "" };
989
+ for (const argv of argvs) {
990
+ last = fromRun(argv, await ctx.run(argv, how));
991
+ if (!last.ok) return last;
992
+ }
993
+ return last;
994
+ }
995
+
996
+ async function runStep(
997
+ step: InstallStep,
998
+ ctx: StepContext,
999
+ ): Promise<StepResult> {
1000
+ switch (step.kind) {
1001
+ case "argv": {
1002
+ const [tool = "", ...args] = step.argv;
1003
+ if (tool === "apt-get") {
1004
+ const install = ctx.escalate(["apt-get", ...APT_LOCK, ...args]);
1005
+ return sequence(
1006
+ ctx,
1007
+ [
1008
+ ctx.escalate(["dpkg", "--configure", "-a"]),
1009
+ ctx.escalate(["apt-get", ...APT_LOCK, "update"]),
1010
+ install,
1011
+ ],
1012
+ { escalated: ctx.root },
1013
+ );
1014
+ }
1015
+ return sequence(ctx, [ctx.escalate([ctx.resolve(tool), ...args])], {
1016
+ escalated: ctx.root,
1017
+ });
1018
+ }
1019
+ case "uv-module":
1020
+ return runUvModule(step, ctx);
1021
+ case "bootstrap":
1022
+ return runBootstrap(step, ctx);
1023
+ case "release":
1024
+ return runRelease(step, ctx);
1025
+ default:
1026
+ return assertNever(step);
1027
+ }
1028
+ }
1029
+
1030
+ async function runUvModule(
1031
+ step: Extract<InstallStep, { kind: "uv-module" }>,
1032
+ ctx: StepContext,
1033
+ ): Promise<StepResult> {
1034
+ const uv = ctx.resolve("uv");
1035
+ const sync = await sequence(ctx, [uvScriptArgv(["sync"], step.script, uv)]);
1036
+ if (!sync.ok) return sync;
1037
+ const findArgv = uvScriptArgv(["python", "find"], step.script, uv);
1038
+ const found = await ctx.run(findArgv, { captureStdout: true });
1039
+ const findResult = fromRun(findArgv, found);
1040
+ if (!findResult.ok) return findResult;
1041
+ // The interpreter is what uv prints on stdout; a warning goes to stderr.
1042
+ const py = lastLine(found.stdout);
1043
+ if (!py) {
1044
+ return {
1045
+ ...findResult,
1046
+ ok: false,
1047
+ outputTail: "uv python find printed no interpreter",
1048
+ };
1049
+ }
1050
+ const guardArgv = [py, "-c", IMPORT_GUARD, step.module];
1051
+ const guard = fromRun(guardArgv, await ctx.run(guardArgv));
1052
+ if (!guard.ok) {
1053
+ return {
1054
+ ...guard,
1055
+ ok: false,
1056
+ outputTail: `interpreter ${py} cannot import ${step.module}: the script env was not built`,
1057
+ };
1058
+ }
1059
+ return sequence(
1060
+ ctx,
1061
+ [ctx.escalate([py, "-B", "-m", step.module, ...step.args])],
1062
+ { escalated: ctx.root },
1063
+ );
1064
+ }
1065
+
1066
+ async function runBootstrap(
1067
+ step: Extract<InstallStep, { kind: "bootstrap" }>,
1068
+ ctx: StepContext,
1069
+ ): Promise<StepResult> {
1070
+ const body = await ctx.io.download(step.url);
1071
+ const dir = mkdtempSync(join(tmpdir(), "magus-bootstrap-"));
1072
+ try {
1073
+ const file = join(dir, "install.sh");
1074
+ writeFileSync(file, body, { mode: 0o600 });
1075
+ return await sequence(ctx, [[ctx.resolve(step.interpreter), file]], {
1076
+ ...(step.env ? { env: step.env } : {}),
1077
+ });
1078
+ } finally {
1079
+ rmSync(dir, { recursive: true, force: true });
1080
+ }
1081
+ }
1082
+
1083
+ const sha256 = (bytes: Uint8Array): string =>
1084
+ createHash("sha256").update(bytes).digest("hex");
1085
+
1086
+ /**
1087
+ * Download, verify against the pinned digest, extract the one binary into a
1088
+ * temp dir inside the destination (so the final rename stays on one
1089
+ * filesystem), refuse anything but a regular file, then rename into place.
1090
+ * Nothing is written to the destination unless every check passed.
1091
+ */
1092
+ async function runRelease(
1093
+ step: Extract<InstallStep, { kind: "release" }>,
1094
+ ctx: StepContext,
1095
+ ): Promise<StepResult> {
1096
+ if (!step.sha256) {
1097
+ return {
1098
+ ok: false,
1099
+ exitCode: null,
1100
+ outputTail: "no pinned sha256 for this platform",
1101
+ };
1102
+ }
1103
+ const body = await ctx.io.download(step.url);
1104
+ const actual = sha256(body);
1105
+ if (actual !== step.sha256) {
1106
+ return {
1107
+ ok: false,
1108
+ exitCode: null,
1109
+ outputTail: `sha256 mismatch for ${step.url}: expected ${step.sha256}, got ${actual} — nothing was installed`,
1110
+ };
1111
+ }
1112
+ const dest = expandHome(step.dest, ctx.env.home);
1113
+ mkdirSync(dest, { recursive: true });
1114
+ const tmp = mkdtempSync(join(dest, RELEASE_TMP_PREFIX));
1115
+ try {
1116
+ if (step.format === "binary") {
1117
+ // The asset is the binary: the verified bytes are the file.
1118
+ const file = join(tmp, step.binary);
1119
+ writeFileSync(file, body, { mode: 0o755 });
1120
+ chmodSync(file, 0o755);
1121
+ renameSync(file, join(dest, step.binary));
1122
+ return { ok: true, exitCode: 0, outputTail: `verified sha256 ${actual}` };
1123
+ }
1124
+ const archive = join(tmp, step.url.slice(step.url.lastIndexOf("/") + 1));
1125
+ writeFileSync(archive, body, { mode: 0o600 });
1126
+ const out = join(tmp, "out");
1127
+ mkdirSync(out);
1128
+ const tarArgv = [
1129
+ ctx.resolve("tar"),
1130
+ "-xzf",
1131
+ archive,
1132
+ "-C",
1133
+ out,
1134
+ "--",
1135
+ step.binary,
1136
+ ];
1137
+ const extracted = fromRun(tarArgv, await ctx.run(tarArgv));
1138
+ if (!extracted.ok) return extracted;
1139
+ const file = join(out, step.binary);
1140
+ const stat = lstatSync(file);
1141
+ if (!stat.isFile()) {
1142
+ return {
1143
+ ...extracted,
1144
+ ok: false,
1145
+ outputTail: `${step.binary} in the archive is not a regular file — refused`,
1146
+ };
1147
+ }
1148
+ chmodSync(file, 0o755);
1149
+ renameSync(file, join(dest, step.binary));
1150
+ return { ...extracted, outputTail: `verified sha256 ${actual}` };
1151
+ } finally {
1152
+ rmSync(tmp, { recursive: true, force: true });
1153
+ }
1154
+ }