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.
- package/package.json +4 -4
- package/src/__tests__/blind/review-followups/bun-install-path.test.ts +57 -0
- package/src/__tests__/blind/review-followups/d1-empty-lock.test.ts +94 -0
- package/src/__tests__/blind/review-followups/d2-disappearing-lock.test.ts +90 -0
- package/src/__tests__/blind/review-followups/d3-release-temp-cleanup.test.ts +83 -0
- package/src/__tests__/blind/review-followups/d4-tool-stdout.test.ts +152 -0
- package/src/__tests__/blind/review-followups/installer-redirect.test.ts +74 -0
- package/src/__tests__/blind/review-followups/ledger-permissions.test.ts +78 -0
- package/src/__tests__/blind/review-followups/ledger-pruning.test.ts +79 -0
- package/src/__tests__/blind/review-followups/path-advice.test.ts +46 -0
- package/src/__tests__/blind/review-followups/stats-requires.test.ts +16 -0
- package/src/__tests__/deps-plan.test.ts +3 -2
- package/src/services/deps-plan.ts +273 -81
- package/src/shared/plugin/hooks/bun-missing.sh +35 -10
- package/src/shared/plugin/hooks/deps-check.ts +25 -9
- package/src/shared/plugin/hooks/lib/requires-probe.ts +101 -5
- package/src/utils/run.ts +21 -1
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
|
|
22
22
|
import { spawn } from "node:child_process";
|
|
23
23
|
import { createHash } from "node:crypto";
|
|
24
|
-
import { constants, accessSync, statSync } from "node:fs";
|
|
24
|
+
import { constants, accessSync, readFileSync, statSync } from "node:fs";
|
|
25
25
|
import { delimiter, join } from "node:path";
|
|
26
26
|
|
|
27
27
|
/** The one `requires` shape this reader knows. Any change to it bumps this. */
|
|
@@ -51,6 +51,17 @@ export interface Bootstrap {
|
|
|
51
51
|
prefix: UserPrefix;
|
|
52
52
|
/** Tools the upstream installer script itself runs. */
|
|
53
53
|
needs: readonly string[];
|
|
54
|
+
/**
|
|
55
|
+
* The environment the installer runs with: what keeps it out of the user's
|
|
56
|
+
* rc files, so the PATH line magus prints is the only one.
|
|
57
|
+
*/
|
|
58
|
+
env?: Readonly<Record<string, string>>;
|
|
59
|
+
/**
|
|
60
|
+
* The installer adds `prefix` to the user's rc file itself and has no
|
|
61
|
+
* switch to stop it, so magus prints no PATH line ahead of it; once it has
|
|
62
|
+
* run, the rc file is read to see whether it did.
|
|
63
|
+
*/
|
|
64
|
+
editsRc?: true;
|
|
54
65
|
}
|
|
55
66
|
|
|
56
67
|
/**
|
|
@@ -64,12 +75,14 @@ export const TOOLCHAIN_BOOTSTRAP = {
|
|
|
64
75
|
interpreter: "bash",
|
|
65
76
|
prefix: BUN_PREFIX,
|
|
66
77
|
needs: ["curl", "unzip"],
|
|
78
|
+
editsRc: true,
|
|
67
79
|
},
|
|
68
80
|
uv: {
|
|
69
81
|
url: "https://astral.sh/uv/install.sh",
|
|
70
82
|
interpreter: "sh",
|
|
71
83
|
prefix: LOCAL_PREFIX,
|
|
72
84
|
needs: ["curl"],
|
|
85
|
+
env: { UV_NO_MODIFY_PATH: "1" },
|
|
73
86
|
},
|
|
74
87
|
} as const satisfies Record<string, Bootstrap>;
|
|
75
88
|
|
|
@@ -679,6 +692,79 @@ export function rcFile(shell: string): string {
|
|
|
679
692
|
return "~/.bashrc";
|
|
680
693
|
}
|
|
681
694
|
|
|
695
|
+
/** The files `shell` reads when a terminal starts it, `rcFile(shell)` first. */
|
|
696
|
+
function startupFiles(shell: string): string[] {
|
|
697
|
+
if (shell.endsWith("zsh")) return ["~/.zshrc", "~/.zprofile", "~/.zshenv"];
|
|
698
|
+
if (shell.endsWith("fish")) return ["~/.config/fish/config.fish"];
|
|
699
|
+
return ["~/.bashrc", "~/.bash_profile"];
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const SHELL_VAR_RE = /\$(?:\{([A-Za-z_]\w*)\}|([A-Za-z_]\w*))/g;
|
|
703
|
+
const ASSIGN_RE = /^(?:export\s+)?([A-Za-z_]\w*)=(.*)$/;
|
|
704
|
+
const FISH_SET_RE = /^set\s+(?:-\S+\s+)*([A-Za-z_]\w*)\s+(.*)$/;
|
|
705
|
+
const PATH_EDIT_RE = /\bPATH\b|^fish_add_path\b|^path\+?=/;
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Does `text` (one rc file) put `abs` on PATH? A line that edits PATH counts
|
|
709
|
+
* when, with `~`, `$HOME` and the variables the file assigns earlier
|
|
710
|
+
* substituted, one of its words is `abs`. That reads the line magus prints,
|
|
711
|
+
* a hand-written one, and bun's installer's
|
|
712
|
+
* `export BUN_INSTALL="$HOME/.bun"` + `export PATH="$BUN_INSTALL/bin:$PATH"`.
|
|
713
|
+
*/
|
|
714
|
+
function textAddsToPath(text: string, abs: string, home: string): boolean {
|
|
715
|
+
const vars = new Map([["HOME", home]]);
|
|
716
|
+
const expand = (s: string): string =>
|
|
717
|
+
s
|
|
718
|
+
.replace(
|
|
719
|
+
SHELL_VAR_RE,
|
|
720
|
+
(whole: string, braced?: string, bare?: string) =>
|
|
721
|
+
vars.get(braced ?? bare ?? "") ?? whole,
|
|
722
|
+
)
|
|
723
|
+
.replace(
|
|
724
|
+
/(^|[\s:="'(])~(?=\/|$)/g,
|
|
725
|
+
(_whole: string, lead: string) => `${lead}${home}`,
|
|
726
|
+
);
|
|
727
|
+
for (const raw of text.split("\n")) {
|
|
728
|
+
const line = raw.trim();
|
|
729
|
+
if (line === "" || line.startsWith("#")) continue;
|
|
730
|
+
if (PATH_EDIT_RE.test(line)) {
|
|
731
|
+
const words = expand(line).split(/[\s:"'()=]+/);
|
|
732
|
+
if (words.some((w) => w === abs || w === `${abs}/`)) return true;
|
|
733
|
+
}
|
|
734
|
+
const assign = line.match(ASSIGN_RE) ?? line.match(FISH_SET_RE);
|
|
735
|
+
if (assign?.[1] && assign[2] !== undefined) {
|
|
736
|
+
const value = expand(assign[2]);
|
|
737
|
+
const quoted = /^(["']).*\1$/.test(value);
|
|
738
|
+
vars.set(assign[1], quoted ? value.slice(1, -1) : value);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
return false;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* The startup file of `shell` (as `~/…`) that already puts `dir` on PATH, or
|
|
746
|
+
* null. A PATH line is advice only while no such file exists: when one does,
|
|
747
|
+
* the prefix is off the PATH only because Claude Code started without reading
|
|
748
|
+
* it, and the fix is a restart, not a second line.
|
|
749
|
+
*/
|
|
750
|
+
export function rcFileAdding(
|
|
751
|
+
dir: string,
|
|
752
|
+
shell: string,
|
|
753
|
+
home: string,
|
|
754
|
+
): string | null {
|
|
755
|
+
const abs = expandHome(dir, home).replace(/\/+$/, "");
|
|
756
|
+
for (const file of startupFiles(shell)) {
|
|
757
|
+
let text: string;
|
|
758
|
+
try {
|
|
759
|
+
text = readFileSync(expandHome(file, home), "utf8");
|
|
760
|
+
} catch {
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
if (textAddsToPath(text, abs, home)) return file;
|
|
764
|
+
}
|
|
765
|
+
return null;
|
|
766
|
+
}
|
|
767
|
+
|
|
682
768
|
// ─── presence ─────────────────────────────────────────────────────────────────
|
|
683
769
|
|
|
684
770
|
export type Presence =
|
|
@@ -824,6 +910,8 @@ export type InstallStep =
|
|
|
824
910
|
url: string;
|
|
825
911
|
interpreter: "sh" | "bash";
|
|
826
912
|
prefix: UserPrefix;
|
|
913
|
+
/** The installer's environment, from {@link Bootstrap.env}. */
|
|
914
|
+
env?: Record<string, string>;
|
|
827
915
|
}
|
|
828
916
|
| {
|
|
829
917
|
kind: "release";
|
|
@@ -980,13 +1068,14 @@ export function renderInstall(
|
|
|
980
1068
|
};
|
|
981
1069
|
}
|
|
982
1070
|
case "bootstrap": {
|
|
983
|
-
const b = TOOLCHAIN_BOOTSTRAP[option.tool];
|
|
1071
|
+
const b: Bootstrap = TOOLCHAIN_BOOTSTRAP[option.tool];
|
|
984
1072
|
return {
|
|
985
1073
|
kind: "bootstrap",
|
|
986
1074
|
name,
|
|
987
1075
|
url: b.url,
|
|
988
1076
|
interpreter: b.interpreter,
|
|
989
1077
|
prefix: b.prefix,
|
|
1078
|
+
...(b.env ? { env: { ...b.env } } : {}),
|
|
990
1079
|
};
|
|
991
1080
|
}
|
|
992
1081
|
case "uv-script":
|
|
@@ -1054,8 +1143,12 @@ export function describeStep(step: InstallStep, os: Os): string {
|
|
|
1054
1143
|
const sudo = step.escalation === "sudo" ? "sudo " : "";
|
|
1055
1144
|
return `${sync} && ${sudo}"$(${find})" ${run}`;
|
|
1056
1145
|
}
|
|
1057
|
-
case "bootstrap":
|
|
1058
|
-
|
|
1146
|
+
case "bootstrap": {
|
|
1147
|
+
const env = Object.entries(step.env ?? {}).map(([k, v]) => `${k}=${v}`);
|
|
1148
|
+
const run =
|
|
1149
|
+
env.length > 0 ? ["env", ...env, step.interpreter] : [step.interpreter];
|
|
1150
|
+
return `curl -fsSL ${step.url} | ${shellLine(run)}`;
|
|
1151
|
+
}
|
|
1059
1152
|
case "release": {
|
|
1060
1153
|
const asset = step.url.slice(step.url.lastIndexOf("/") + 1);
|
|
1061
1154
|
const verify = os === "darwin" ? "shasum -a 256 -c" : "sha256sum -c";
|
|
@@ -1174,8 +1267,11 @@ export function renderFix(
|
|
|
1174
1267
|
for (const p of offPath) {
|
|
1175
1268
|
const prefix = USER_PREFIXES.find((u) => u.dir === p.prefix);
|
|
1176
1269
|
const rc = prefix?.rcLine(shell) ?? "";
|
|
1270
|
+
const adding = rcFileAdding(p.prefix, shell, env.home);
|
|
1177
1271
|
lines.push(
|
|
1178
|
-
|
|
1272
|
+
adding
|
|
1273
|
+
? ` ${p.name} is in ${p.prefix}, which is not on the PATH Claude Code started with; ${adding} already puts it on PATH`
|
|
1274
|
+
: ` ${p.name} is in ${p.prefix}, which is not on the PATH Claude Code started with: add ${rc} to ${rcFile(shell)}`,
|
|
1179
1275
|
);
|
|
1180
1276
|
}
|
|
1181
1277
|
lines.push(" Then restart Claude Code from a new terminal.");
|
package/src/utils/run.ts
CHANGED
|
@@ -41,6 +41,11 @@ export interface RunResult {
|
|
|
41
41
|
spawnError?: string;
|
|
42
42
|
/** Combined stdout + stderr, last {@link OUTPUT_TAIL_BYTES}, ANSI stripped. */
|
|
43
43
|
outputTail: string;
|
|
44
|
+
/**
|
|
45
|
+
* stdout alone, last {@link OUTPUT_TAIL_BYTES}, ANSI stripped. Set only
|
|
46
|
+
* when `captureStdout` asked for it.
|
|
47
|
+
*/
|
|
48
|
+
stdout?: string;
|
|
44
49
|
durationMs: number;
|
|
45
50
|
}
|
|
46
51
|
|
|
@@ -53,6 +58,12 @@ export interface RunOptions {
|
|
|
53
58
|
killGraceMs?: number;
|
|
54
59
|
/** Each output chunk as it arrives, ANSI intact. Never called after return. */
|
|
55
60
|
onOutput?: (chunk: string) => void;
|
|
61
|
+
/**
|
|
62
|
+
* Also keep stdout on its own in `RunResult.stdout`: for a query whose
|
|
63
|
+
* answer is what it prints on stdout, so a warning on stderr is never read
|
|
64
|
+
* as the answer.
|
|
65
|
+
*/
|
|
66
|
+
captureStdout?: boolean;
|
|
56
67
|
}
|
|
57
68
|
|
|
58
69
|
/** Keep the last {@link OUTPUT_TAIL_BYTES} of a growing buffer. */
|
|
@@ -67,10 +78,13 @@ export function runArgv(c: Command, opts: RunOptions): Promise<RunResult> {
|
|
|
67
78
|
const started = Date.now();
|
|
68
79
|
return new Promise((resolve) => {
|
|
69
80
|
let output = "";
|
|
81
|
+
let stdout = "";
|
|
70
82
|
let timedOut = false;
|
|
71
83
|
let settled = false;
|
|
72
84
|
let killTimer: ReturnType<typeof setTimeout> | undefined;
|
|
73
|
-
const finish = (
|
|
85
|
+
const finish = (
|
|
86
|
+
result: Omit<RunResult, "outputTail" | "stdout" | "durationMs">,
|
|
87
|
+
) => {
|
|
74
88
|
if (settled) return;
|
|
75
89
|
settled = true;
|
|
76
90
|
clearTimeout(timer);
|
|
@@ -78,6 +92,7 @@ export function runArgv(c: Command, opts: RunOptions): Promise<RunResult> {
|
|
|
78
92
|
resolve({
|
|
79
93
|
...result,
|
|
80
94
|
outputTail: stripAnsi(tail(output)),
|
|
95
|
+
...(opts.captureStdout ? { stdout: stripAnsi(tail(stdout)) } : {}),
|
|
81
96
|
durationMs: Date.now() - started,
|
|
82
97
|
});
|
|
83
98
|
};
|
|
@@ -93,6 +108,11 @@ export function runArgv(c: Command, opts: RunOptions): Promise<RunResult> {
|
|
|
93
108
|
};
|
|
94
109
|
child.stdout?.on("data", onData);
|
|
95
110
|
child.stderr?.on("data", onData);
|
|
111
|
+
if (opts.captureStdout) {
|
|
112
|
+
child.stdout?.on("data", (data: Buffer) => {
|
|
113
|
+
stdout = tail(stdout + data.toString("utf8"));
|
|
114
|
+
});
|
|
115
|
+
}
|
|
96
116
|
const timer = setTimeout(() => {
|
|
97
117
|
timedOut = true;
|
|
98
118
|
child.kill("SIGTERM");
|