magus-cli 7.9.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.
@@ -16,30 +16,32 @@
16
16
  * Re-running is the recovery: a plan is always built from a fresh probe, so a
17
17
  * step whose effect is already present is not planned again; every step is
18
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`).
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.
21
22
  */
22
23
 
23
24
  import { createHash } from "node:crypto";
24
25
  import {
25
26
  chmodSync,
26
- closeSync,
27
+ linkSync,
27
28
  lstatSync,
28
29
  mkdirSync,
29
30
  mkdtempSync,
30
- openSync,
31
31
  readFileSync,
32
+ readdirSync,
32
33
  renameSync,
33
34
  rmSync,
34
35
  unlinkSync,
35
36
  writeFileSync,
36
- writeSync,
37
37
  } from "node:fs";
38
38
  import { tmpdir } from "node:os";
39
39
  import { delimiter, dirname, join } from "node:path";
40
40
  import {
41
41
  type Arch,
42
42
  BUN_PREFIX,
43
+ type Bootstrap,
44
+ type BootstrapTool,
43
45
  type InstallStep,
44
46
  LOCAL_PREFIX,
45
47
  type Os,
@@ -56,6 +58,7 @@ import {
56
58
  pathRcLine,
57
59
  probe,
58
60
  rcFile,
61
+ rcFileAdding,
59
62
  renderInstall,
60
63
  uvScriptArgv,
61
64
  whyUnselectable,
@@ -96,6 +99,7 @@ export interface PlannedStep {
96
99
  installs: InstallTarget[];
97
100
  }
98
101
 
102
+ /** A PATH line the user has to add: never one an rc file already has. */
99
103
  export interface PathAdvice {
100
104
  prefix: UserPrefix;
101
105
  rcFile: string;
@@ -183,16 +187,49 @@ export function prefixOnPath(prefix: UserPrefix, env: PlanEnv): boolean {
183
187
  return env.path.split(delimiter).some((d) => d === dir || d === `${dir}/`);
184
188
  }
185
189
 
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);
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;
189
201
  return {
190
202
  prefix,
191
203
  rcFile: rcFile(env.userShell),
192
- rcLine: info ? info.rcLine(env.userShell) : "",
204
+ rcLine: pathRcLine(prefix, env.userShell),
193
205
  };
194
206
  }
195
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
+
196
233
  /** A root uv-module step may only come from a plugin of the magus marketplace. */
197
234
  function fromMagusMarketplace(bin: ResolvedBin): boolean {
198
235
  return bin.sources.some(
@@ -214,14 +251,13 @@ export function planDeps(
214
251
  env: PlanEnv,
215
252
  ): DepsPlan {
216
253
  const unfixable: DepsPlan["unfixable"] = [];
217
- const advice = new Map<UserPrefix, PathAdvice>();
254
+ /** The prefixes off the PATH, in the order they came up. */
255
+ const offPathPrefixes = new Set<UserPrefix>();
218
256
  const missing: ResolvedBin[] = [];
219
257
  bins.forEach((bin, i) => {
220
258
  const p = presence[i];
221
259
  if (p?.status === "missing") missing.push(bin);
222
- if (p?.status === "off-path") {
223
- advice.set(p.prefix, pathAdviceFor(p.prefix, env));
224
- }
260
+ if (p?.status === "off-path") offPathPrefixes.add(p.prefix);
225
261
  });
226
262
 
227
263
  // A pin the owning plugin's option cannot carry: unfixable whatever the
@@ -325,11 +361,16 @@ export function planDeps(
325
361
  }
326
362
  steps.push(p);
327
363
  const prefix = prefixOf(p.step);
328
- if (prefix && !prefixOnPath(prefix, env)) {
329
- advice.set(prefix, pathAdviceFor(prefix, env));
330
- }
364
+ if (prefix && !prefixOnPath(prefix, env)) offPathPrefixes.add(prefix);
331
365
  }
332
- return { steps, unfixable, pathAdvice: [...advice.values()] };
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 };
333
374
  }
334
375
 
335
376
  /**
@@ -397,23 +438,48 @@ export interface ApplyIO {
397
438
  }
398
439
 
399
440
  const DOWNLOAD_TIMEOUT_MS = 10 * 60_000;
441
+ /** Redirects a download follows before it gives up. */
442
+ const MAX_REDIRECTS = 10;
400
443
  /** The per-check cap a post-install re-probe runs under: doctor's. */
401
444
  const VERIFY_CAP_MS = 60_000;
402
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
+
403
479
  export const REAL_APPLY_IO: ApplyIO = {
404
480
  run: (argv, opts) =>
405
481
  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
- },
482
+ download: httpsDownload,
417
483
  uid: () => (typeof process.getuid === "function" ? process.getuid() : null),
418
484
  alive: (pid) => {
419
485
  try {
@@ -452,11 +518,11 @@ export interface ApplyOptions {
452
518
  io?: Partial<ApplyIO>;
453
519
  }
454
520
 
455
- /** Another apply holds the lock. */
521
+ /** Another apply holds the lock; `pid` is null when its holder is not yet known. */
456
522
  export class DepsLockBusy extends Error {
457
- constructor(readonly pid: number) {
523
+ constructor(readonly pid: number | null) {
458
524
  super(
459
- `another magus dependency install (pid ${pid}) is running — wait for it, then re-run`,
525
+ `another magus dependency install${pid === null ? "" : ` (pid ${pid})`} is running — wait for it, then re-run`,
460
526
  );
461
527
  this.name = "DepsLockBusy";
462
528
  }
@@ -467,33 +533,140 @@ export function defaultLockPath(home: string, xdgCacheHome?: string): string {
467
533
  return join(xdgCacheHome || join(home, ".cache"), "magus", "doctor-fix.lock");
468
534
  }
469
535
 
470
- /** Take the lock (O_EXCL, holding our pid), taking over one whose pid is gone. */
471
- function acquireLock(path: string, io: ApplyIO): () => void {
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> {
472
603
  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);
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.
482
616
  }
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);
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.
492
667
  }
493
- unlinkSync(path); // stale: its holder is gone
494
668
  }
495
669
  }
496
- throw new Error(`could not take ${path}`);
497
670
  }
498
671
 
499
672
  const STEP_TIMEOUT_MS = 15 * 60_000;
@@ -544,8 +717,9 @@ export async function applyDepsPlan(
544
717
  if (plan.steps.length === 0) return [];
545
718
  if (!opts.yes && !(await opts.confirm(plan))) return [];
546
719
  const io: ApplyIO = { ...REAL_APPLY_IO, ...opts.io };
547
- const release = acquireLock(opts.lockPath, io);
720
+ const release = await acquireLock(opts.lockPath, io);
548
721
  try {
722
+ sweepStaleTemp(opts.env.home);
549
723
  return await applySteps(plan, opts, io);
550
724
  } finally {
551
725
  release();
@@ -618,11 +792,12 @@ async function applySteps(
618
792
  }
619
793
  opts.onEvent?.({ type: "step-start", name: step.name, describe });
620
794
  const started = Date.now();
621
- const run = (argv: string[], escalated = false) =>
795
+ const run = (argv: string[], how: RunHow = {}) =>
622
796
  io.run(argv, {
623
797
  timeoutMs: STEP_TIMEOUT_MS,
624
- env: childEnv,
625
- ...(escalated ? { killGraceMs: SUDO_KILL_GRACE_MS } : {}),
798
+ env: how.env ? { ...childEnv, ...how.env } : childEnv,
799
+ ...(how.escalated ? { killGraceMs: SUDO_KILL_GRACE_MS } : {}),
800
+ ...(how.captureStdout ? { captureStdout: true } : {}),
626
801
  onOutput: (chunk) =>
627
802
  opts.onEvent?.({ type: "output", name: step.name, chunk }),
628
803
  });
@@ -708,9 +883,10 @@ async function landingDir(
708
883
  step.argv[0] === "bun" &&
709
884
  step.argv[1] === "add"
710
885
  ) {
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;
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;
714
890
  }
715
891
  return null;
716
892
  }
@@ -750,12 +926,11 @@ async function verifyInstalled(
750
926
  }
751
927
  const first = offPath[0];
752
928
  if (first?.dir) {
753
- const line = pathRcLine(first.dir, env.userShell);
754
929
  return {
755
930
  status: "off-path",
756
931
  dir: first.dir,
757
932
  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`,
933
+ remedy: pathRemedy(first.dir, env),
759
934
  };
760
935
  }
761
936
  return { status: "installed" };
@@ -768,10 +943,20 @@ interface StepResult {
768
943
  outputTail: string;
769
944
  }
770
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
+
771
956
  interface StepContext {
772
957
  env: PlanEnv;
773
958
  io: ApplyIO;
774
- run(argv: string[], escalated?: boolean): Promise<RunResult>;
959
+ run(argv: string[], how?: RunHow): Promise<RunResult>;
775
960
  escalate(argv: string[]): string[];
776
961
  root: boolean;
777
962
  resolve(tool: string): string;
@@ -784,15 +969,25 @@ const fromRun = (argv: string[], r: RunResult): StepResult => ({
784
969
  outputTail: r.spawnError ?? r.outputTail,
785
970
  });
786
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
+
787
982
  /** Run argvs in order, stopping at the first that fails. */
788
983
  async function sequence(
789
984
  ctx: StepContext,
790
985
  argvs: string[][],
791
- escalated: boolean,
986
+ how: RunHow = {},
792
987
  ): Promise<StepResult> {
793
988
  let last: StepResult = { ok: true, exitCode: 0, outputTail: "" };
794
989
  for (const argv of argvs) {
795
- last = fromRun(argv, await ctx.run(argv, escalated));
990
+ last = fromRun(argv, await ctx.run(argv, how));
796
991
  if (!last.ok) return last;
797
992
  }
798
993
  return last;
@@ -814,14 +1009,12 @@ async function runStep(
814
1009
  ctx.escalate(["apt-get", ...APT_LOCK, "update"]),
815
1010
  install,
816
1011
  ],
817
- ctx.root,
1012
+ { escalated: ctx.root },
818
1013
  );
819
1014
  }
820
- return sequence(
821
- ctx,
822
- [ctx.escalate([ctx.resolve(tool), ...args])],
823
- ctx.root,
824
- );
1015
+ return sequence(ctx, [ctx.escalate([ctx.resolve(tool), ...args])], {
1016
+ escalated: ctx.root,
1017
+ });
825
1018
  }
826
1019
  case "uv-module":
827
1020
  return runUvModule(step, ctx);
@@ -839,17 +1032,14 @@ async function runUvModule(
839
1032
  ctx: StepContext,
840
1033
  ): Promise<StepResult> {
841
1034
  const uv = ctx.resolve("uv");
842
- const sync = await sequence(
843
- ctx,
844
- [uvScriptArgv(["sync"], step.script, uv)],
845
- false,
846
- );
1035
+ const sync = await sequence(ctx, [uvScriptArgv(["sync"], step.script, uv)]);
847
1036
  if (!sync.ok) return sync;
848
1037
  const findArgv = uvScriptArgv(["python", "find"], step.script, uv);
849
- const found = await ctx.run(findArgv);
1038
+ const found = await ctx.run(findArgv, { captureStdout: true });
850
1039
  const findResult = fromRun(findArgv, found);
851
1040
  if (!findResult.ok) return findResult;
852
- const py = found.outputTail.trim().split("\n").pop()?.trim() ?? "";
1041
+ // The interpreter is what uv prints on stdout; a warning goes to stderr.
1042
+ const py = lastLine(found.stdout);
853
1043
  if (!py) {
854
1044
  return {
855
1045
  ...findResult,
@@ -869,7 +1059,7 @@ async function runUvModule(
869
1059
  return sequence(
870
1060
  ctx,
871
1061
  [ctx.escalate([py, "-B", "-m", step.module, ...step.args])],
872
- ctx.root,
1062
+ { escalated: ctx.root },
873
1063
  );
874
1064
  }
875
1065
 
@@ -882,7 +1072,9 @@ async function runBootstrap(
882
1072
  try {
883
1073
  const file = join(dir, "install.sh");
884
1074
  writeFileSync(file, body, { mode: 0o600 });
885
- return await sequence(ctx, [[ctx.resolve(step.interpreter), file]], false);
1075
+ return await sequence(ctx, [[ctx.resolve(step.interpreter), file]], {
1076
+ ...(step.env ? { env: step.env } : {}),
1077
+ });
886
1078
  } finally {
887
1079
  rmSync(dir, { recursive: true, force: true });
888
1080
  }
@@ -919,7 +1111,7 @@ async function runRelease(
919
1111
  }
920
1112
  const dest = expandHome(step.dest, ctx.env.home);
921
1113
  mkdirSync(dest, { recursive: true });
922
- const tmp = mkdtempSync(join(dest, ".magus-tmp-"));
1114
+ const tmp = mkdtempSync(join(dest, RELEASE_TMP_PREFIX));
923
1115
  try {
924
1116
  if (step.format === "binary") {
925
1117
  // The asset is the binary: the verified bytes are the file.
@@ -3,11 +3,13 @@
3
3
  # runs this only when Bun is not on the PATH Claude Code started with, which is
4
4
  # the one dependency the TypeScript check cannot report itself. $1 is the label.
5
5
  #
6
- # Writes the same ledger as deps-check.ts: seen/<label> = sh, then claims the
7
- # Bun requirement's dedupe key, so a session prints one Bun line however many
8
- # plugins carry this file. scripts/check-hook-copies.ts recomputes that key, the
9
- # install line and the PATH lines from lib/requires-probe.ts and fails when this
10
- # file disagrees. Exit 0 always.
6
+ # Writes the same ledger as deps-check.ts, 0700: seen/<label> = sh, then claims
7
+ # the Bun requirement's dedupe key, so a session prints one Bun line however
8
+ # many plugins carry this file. A bun in a place an installer or Homebrew puts
9
+ # one is reported as off the PATH, naming that place; only when there is none
10
+ # is Bun called missing. scripts/check-hook-copies.ts recomputes the key, the
11
+ # install line, the Bun prefix and the PATH line formats from
12
+ # lib/requires-probe.ts and fails when this file disagrees. Exit 0 always.
11
13
 
12
14
  label=$1
13
15
  case $label in
@@ -17,6 +19,7 @@ key=bun-3182ab42b0f1
17
19
 
18
20
  sid=$(cat 2>/dev/null | tr -d '\n' | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([A-Za-z0-9_-]*\)".*/\1/p')
19
21
  if [ -n "$sid" ]; then
22
+ umask 077
20
23
  base=${TMPDIR:-/tmp}
21
24
  dir=$base/magus-deps-$sid
22
25
  if mkdir -p "$dir/seen" "$dir/claimed" 2>/dev/null; then
@@ -26,13 +29,35 @@ if [ -n "$sid" ]; then
26
29
  fi
27
30
  fi
28
31
 
29
- if [ -x "$HOME/.bun/bin/bun" ]; then
32
+ # The first bun where an installer or Homebrew leaves one. A path that would
33
+ # break the JSON line is skipped.
34
+ found=
35
+ for d in ${BUN_INSTALL:+"$BUN_INSTALL/bin"} "$HOME/.bun/bin" /opt/homebrew/bin /usr/local/bin /home/linuxbrew/.linuxbrew/bin; do
36
+ case $d in
37
+ *\"* | *\\* | *[[:cntrl:]]*) continue ;;
38
+ esac
39
+ if [ -f "$d/bun" ] && [ -x "$d/bun" ]; then
40
+ found=$d
41
+ break
42
+ fi
43
+ done
44
+
45
+ if [ -n "$found" ]; then
46
+ # Under $HOME: shown as ~/…, and $HOME in the PATH line.
47
+ rel=${found#"$HOME"/}
48
+ if [ -n "$HOME" ] && [ "$HOME" != / ] && [ "$rel" != "$found" ]; then
49
+ shown="~/$rel" pathdir="\$HOME/$rel"
50
+ else
51
+ shown=$found pathdir=$found
52
+ fi
30
53
  case ${SHELL:-} in
31
- *zsh) rc='~/.zshrc' line='export PATH=\"$HOME/.bun/bin:$PATH\"' ;;
32
- *fish) rc='~/.config/fish/config.fish' line='fish_add_path $HOME/.bun/bin' ;;
33
- *) rc='~/.bashrc' line='export PATH=\"$HOME/.bun/bin:$PATH\"' ;;
54
+ *zsh) rc='~/.zshrc' fmt='export PATH="%s:$PATH"' ;;
55
+ *fish) rc='~/.config/fish/config.fish' fmt='fish_add_path %s' ;;
56
+ *) rc='~/.bashrc' fmt='export PATH="%s:$PATH"' ;;
34
57
  esac
35
- printf '{"systemMessage":"magus-deps: Bun is installed at ~/.bun/bin but is not on the PATH Claude Code started with, so magus hooks and MCP servers cannot run (noticed by %s).\\n Add %s to %s, then restart Claude Code from a new terminal."}\n' "$label" "$line" "$rc"
58
+ # shellcheck disable=SC2059 # fmt is one of the three fixed formats above
59
+ line=$(printf "$fmt" "$pathdir" | sed 's/["\\]/\\&/g')
60
+ printf '{"systemMessage":"magus-deps: Bun is installed at %s but is not on the PATH Claude Code started with, so magus hooks and MCP servers cannot run (noticed by %s).\\n Add %s to %s, then restart Claude Code from a new terminal."}\n' "$shown" "$label" "$line" "$rc"
36
61
  else
37
62
  printf '{"systemMessage":"magus-deps: Bun is missing — magus hooks and MCP servers need it (noticed by %s).\\n Install: curl -fsSL https://bun.sh/install | bash, then restart Claude Code from a new terminal."}\n' "$label"
38
63
  fi
@@ -17,7 +17,9 @@
17
17
  * atomic `mkdir claimed/<dedupeKey>`. A failed mkdir means another plugin's copy
18
18
  * reports that dep this session, so one dep is one banner line however many
19
19
  * plugins need it. An unreadable session id means no ledger and no claims:
20
- * more banners, never fewer. Ledgers older than a day are pruned on write.
20
+ * more banners, never fewer. The ledger is 0700. Ledgers older than a day are
21
+ * pruned on write, each on its own, so one this user cannot remove skips only
22
+ * itself.
21
23
  *
22
24
  * Exit 0 always. A watchdog keeps the hook inside its registration timeout.
23
25
  */
@@ -94,28 +96,42 @@ async function readHookInput(): Promise<HookInput> {
94
96
  }
95
97
  }
96
98
 
99
+ /**
100
+ * Remove ledgers older than a day. Each is tried on its own: a shared `/tmp`
101
+ * holds other users' ledgers, which this user cannot remove, and one of those
102
+ * must not stop this user's own from being pruned.
103
+ */
97
104
  function pruneOldLedgers(base: string): void {
105
+ let entries: string[];
98
106
  try {
99
- for (const entry of readdirSync(base)) {
100
- if (!entry.startsWith("magus-deps-")) continue;
101
- const dir = join(base, entry);
107
+ entries = readdirSync(base);
108
+ } catch {
109
+ return; // Pruning is housekeeping; a failure costs nothing now.
110
+ }
111
+ for (const entry of entries) {
112
+ if (!entry.startsWith("magus-deps-")) continue;
113
+ const dir = join(base, entry);
114
+ try {
102
115
  if (Date.now() - statSync(dir).mtimeMs > LEDGER_MAX_AGE_MS) {
103
116
  rmSync(dir, { recursive: true, force: true });
104
117
  }
118
+ } catch {
119
+ // Another user's, or already gone: the next entry is still pruned.
105
120
  }
106
- } catch {
107
- // Pruning is housekeeping; a failure costs nothing now.
108
121
  }
109
122
  }
110
123
 
111
- /** The ledger directory, with `seen/<label>` written, or null without a session. */
124
+ /**
125
+ * The ledger directory, with `seen/<label>` written, or null without a session.
126
+ * Created 0700: in a shared `/tmp`, no other user reads which plugins ran.
127
+ */
112
128
  function openLedger(hookSession: string | null, label: string): string | null {
113
129
  if (!hookSession) return null;
114
130
  const base = process.env.TMPDIR || tmpdir();
115
131
  const dir = join(base, `magus-deps-${hookSession}`);
116
132
  try {
117
- mkdirSync(join(dir, "seen"), { recursive: true });
118
- mkdirSync(join(dir, "claimed"), { recursive: true });
133
+ mkdirSync(join(dir, "seen"), { recursive: true, mode: 0o700 });
134
+ mkdirSync(join(dir, "claimed"), { recursive: true, mode: 0o700 });
119
135
  writeFileSync(join(dir, "seen", label), "ts");
120
136
  } catch {
121
137
  return null;