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
package/src/utils/run.ts CHANGED
@@ -1,67 +1,158 @@
1
1
  /**
2
- * Running an installer, with the shell split out from the argv.
2
+ * The one way magus runs an installer or any other child process it waits on.
3
3
  *
4
- * Lives in `utils/` rather than `cli/prompt.ts` because the update engine needs
5
- * it and a service must not import from the CLI layer — `cli/` and `ui/` are
6
- * sibling front ends over `services/`, and an arrow from a service back up into
7
- * one of them is how the two front ends ended up with different update logic in
8
- * the first place.
4
+ * Lives in `utils/` rather than `cli/` because services need it and a service
5
+ * must not import from a front end — `cli/` and `ui/` are sibling front ends
6
+ * over `services/`.
9
7
  *
10
- * Both functions live in ONE file on purpose: {@link runShellScript}'s doc only
11
- * makes sense next to {@link runCommand}, because most of what it has to say is
12
- * "you almost certainly want the other one".
8
+ * Contract:
9
+ *
10
+ * - **argv only.** No `shell` option: `cmd` is resolved on PATH as an
11
+ * executable, and every argument stays exactly one argument. Package names
12
+ * reach here from `.claude/profiles.json` and plugin `requires` — committed
13
+ * by teammates, fetched from marketplaces — so under a shell a space or a
14
+ * `$(…)` in one of them would execute.
15
+ * - **Never writes to the terminal.** stdin is ignored and stdout/stderr are
16
+ * piped and captured; what (if anything) is shown, and where, is the
17
+ * caller's decision through `onOutput`. The guarantee is exactly this: a
18
+ * child cannot read the terminal through its stdin. A child that opens
19
+ * `/dev/tty` itself still can; it keeps magus's controlling terminal and
20
+ * session on purpose, so a `sudo -v` ticket stays usable by `sudo -n`.
21
+ * - **Bounded.** At `timeoutMs` the child gets SIGTERM, then SIGKILL after
22
+ * `killGraceMs` (5 s by default; a `sudo` step passes more, because SIGKILL
23
+ * to `sudo` cannot be relayed and would orphan its root child).
13
24
  */
14
25
 
15
26
  import { spawn } from "node:child_process";
16
27
  import type { Command } from "./command-utils.js";
28
+ import { stripAnsi } from "./string-utils.js";
17
29
 
18
- /**
19
- * Run a built command as argv, streaming its output; true on exit 0.
20
- *
21
- * **This is the one every installer uses.** No `shell` option, so:
22
- *
23
- * - `cmd` is resolved on PATH as an executable. There is no profile to source
24
- * and therefore no way for a shell alias to appear to work — the reason the
25
- * shipped `pip install` bug could exist at all was that `/bin/sh` was asked
26
- * to resolve a word that only interactive zsh knew.
27
- * - Every argument stays exactly one argument. Package names reach here from
28
- * `.claude/profiles.json` and plugin `requires.bin` — hand-authored data
29
- * committed by teammates and fetched from marketplaces — and under a shell a
30
- * space or a `$(…)` in one of them would have been executed. Nothing
31
- * validates those names, and after this nothing has to.
32
- *
33
- * stdio is inherited: the installer owns the terminal while it runs, which is
34
- * what makes brew's and bun's own progress output visible. A caller painting an
35
- * animated region must pause it around this (see `ApplyReporter.detach`).
36
- */
37
- export async function runCommand(c: Command): Promise<boolean> {
30
+ /** How much combined output a {@link RunResult} keeps: the last 16 KiB. */
31
+ export const OUTPUT_TAIL_BYTES = 16 * 1024;
32
+
33
+ const DEFAULT_KILL_GRACE_MS = 5_000;
34
+
35
+ export interface RunResult {
36
+ /** null when killed by a signal or never spawned. */
37
+ exitCode: number | null;
38
+ signal: string | null;
39
+ timedOut: boolean;
40
+ /** Set when the process could not be started (ENOENT, EACCES, …). */
41
+ spawnError?: string;
42
+ /** Combined stdout + stderr, last {@link OUTPUT_TAIL_BYTES}, ANSI stripped. */
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;
49
+ durationMs: number;
50
+ }
51
+
52
+ export interface RunOptions {
53
+ timeoutMs: number;
54
+ /** Replaces the child's environment when given. */
55
+ env?: Record<string, string | undefined>;
56
+ cwd?: string;
57
+ /** Between SIGTERM and SIGKILL once `timeoutMs` passes. */
58
+ killGraceMs?: number;
59
+ /** Each output chunk as it arrives, ANSI intact. Never called after return. */
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;
67
+ }
68
+
69
+ /** Keep the last {@link OUTPUT_TAIL_BYTES} of a growing buffer. */
70
+ function tail(text: string): string {
71
+ const bytes = Buffer.from(text, "utf8");
72
+ if (bytes.length <= OUTPUT_TAIL_BYTES) return text;
73
+ return bytes.subarray(bytes.length - OUTPUT_TAIL_BYTES).toString("utf8");
74
+ }
75
+
76
+ /** Run `c` as argv and capture it; resolves when it exits or is killed. */
77
+ export function runArgv(c: Command, opts: RunOptions): Promise<RunResult> {
78
+ const started = Date.now();
38
79
  return new Promise((resolve) => {
39
- const child = spawn(c.cmd, [...c.args], { stdio: "inherit" });
40
- child.on("exit", (code) => resolve(code === 0));
41
- child.on("error", () => resolve(false));
80
+ let output = "";
81
+ let stdout = "";
82
+ let timedOut = false;
83
+ let settled = false;
84
+ let killTimer: ReturnType<typeof setTimeout> | undefined;
85
+ const finish = (
86
+ result: Omit<RunResult, "outputTail" | "stdout" | "durationMs">,
87
+ ) => {
88
+ if (settled) return;
89
+ settled = true;
90
+ clearTimeout(timer);
91
+ if (killTimer) clearTimeout(killTimer);
92
+ resolve({
93
+ ...result,
94
+ outputTail: stripAnsi(tail(output)),
95
+ ...(opts.captureStdout ? { stdout: stripAnsi(tail(stdout)) } : {}),
96
+ durationMs: Date.now() - started,
97
+ });
98
+ };
99
+ const child = spawn(c.cmd, [...c.args], {
100
+ stdio: ["ignore", "pipe", "pipe"],
101
+ ...(opts.env ? { env: opts.env } : {}),
102
+ ...(opts.cwd ? { cwd: opts.cwd } : {}),
103
+ });
104
+ const onData = (data: Buffer) => {
105
+ const chunk = data.toString("utf8");
106
+ output = tail(output + chunk);
107
+ if (!settled) opts.onOutput?.(chunk);
108
+ };
109
+ child.stdout?.on("data", onData);
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
+ }
116
+ const timer = setTimeout(() => {
117
+ timedOut = true;
118
+ child.kill("SIGTERM");
119
+ killTimer = setTimeout(
120
+ () => child.kill("SIGKILL"),
121
+ opts.killGraceMs ?? DEFAULT_KILL_GRACE_MS,
122
+ );
123
+ }, opts.timeoutMs);
124
+ child.on("error", (error) =>
125
+ finish({
126
+ exitCode: null,
127
+ signal: null,
128
+ timedOut,
129
+ spawnError: error.message,
130
+ }),
131
+ );
132
+ child.on("close", (code, signal) =>
133
+ finish({ exitCode: code, signal, timedOut }),
134
+ );
42
135
  });
43
136
  }
44
137
 
138
+ /** True when the run exited 0 (and was neither killed nor unstartable). */
139
+ export function succeeded(r: RunResult): boolean {
140
+ return r.exitCode === 0 && !r.timedOut && r.spawnError === undefined;
141
+ }
142
+
45
143
  /**
46
- * Run a shell SCRIPT through `/bin/sh`, streaming its output; true on exit 0.
47
- *
48
- * **Not for installer commands — use {@link runCommand}.** This exists for
49
- * exactly one input: the values of `TOOLCHAIN_BOOTSTRAP`, which are the
50
- * upstream-published one-liners for bun and Homebrew. Those genuinely are shell
51
- * text — one contains a pipe, the other a command substitution — so they cannot
52
- * be expressed as argv without wrapping them in a fake `/bin/sh -c` at the one
53
- * place where a shell is actually correct.
54
- *
55
- * The distinction is who wrote the string. A bootstrap line is a constant in
56
- * this repo, copied from bun.sh and brew.sh. An installer command is assembled
57
- * from names that arrive with a profile. Passing the second kind here is the
58
- * defect that motivated splitting the two, so
59
- * `src/__tests__/shell-script-callers.test.ts` pins the caller set to one file.
144
+ * One line saying why a run did not succeed, for a failure report. The output
145
+ * tail's last non-empty line is the installer's own reason more often than not.
60
146
  */
61
- export async function runShellScript(script: string): Promise<boolean> {
62
- return new Promise((resolve) => {
63
- const child = spawn(script, { stdio: "inherit", shell: true });
64
- child.on("exit", (code) => resolve(code === 0));
65
- child.on("error", () => resolve(false));
66
- });
147
+ export function runFailureReason(r: RunResult): string {
148
+ if (r.spawnError) return r.spawnError;
149
+ if (r.timedOut) return `timed out after ${Math.round(r.durationMs / 1000)}s`;
150
+ const last = r.outputTail
151
+ .split("\n")
152
+ .map((l) => l.trim())
153
+ .filter(Boolean)
154
+ .pop();
155
+ const code =
156
+ r.exitCode !== null ? `exit ${r.exitCode}` : `killed by ${r.signal}`;
157
+ return last ? `${code}: ${last}` : code;
67
158
  }
@@ -86,3 +86,12 @@ export function parsePluginId(pluginId: string): ParsedPluginId | null {
86
86
  raw: trimmed,
87
87
  };
88
88
  }
89
+
90
+ /** Every CSI sequence, not only SGR — the live region emits cursor moves too. */
91
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: ESC is the thing being matched.
92
+ export const CSI = /\x1b\[[0-9;?]*[\x20-\x2f]*[\x40-\x7e]/g;
93
+
94
+ /** `text` without its ANSI CSI sequences. */
95
+ export function stripAnsi(text: string): string {
96
+ return text.replace(CSI, "");
97
+ }
@@ -1,43 +0,0 @@
1
- /**
2
- * goBinaryName: the one rule for turning a Go module path into the binary
3
- * `go install` produces. Both consumers (plugin-setup's Go path and
4
- * plugin-requires' `setup.go` branch) are tested through their own surfaces;
5
- * this file pins the rule itself.
6
- */
7
-
8
- import { describe, expect, test } from "bun:test";
9
- import { goBinaryName } from "../utils/go-module.js";
10
-
11
- describe("goBinaryName", () => {
12
- test("takes the last segment of a v0/v1 module path", () => {
13
- expect(goBinaryName("github.com/MadAppGang/tmux-mcp")).toBe("tmux-mcp");
14
- });
15
-
16
- test("strips a trailing /v2 major-version suffix before taking the name", () => {
17
- expect(goBinaryName("github.com/MadAppGang/tmux-mcp/v2")).toBe("tmux-mcp");
18
- });
19
-
20
- test("strips multi-digit suffixes (/v10, /v42)", () => {
21
- expect(goBinaryName("github.com/x/tool/v10")).toBe("tool");
22
- expect(goBinaryName("github.com/x/tool/v42")).toBe("tool");
23
- });
24
-
25
- test("a segment that merely starts with v2 is a name, not a suffix", () => {
26
- expect(goBinaryName("github.com/x/v2tool")).toBe("v2tool");
27
- });
28
-
29
- test("v0 and v1 are never path suffixes, so they stay as names", () => {
30
- // Nobody names a binary this, but the rule must not invent an exception.
31
- expect(goBinaryName("github.com/x/v1")).toBe("v1");
32
- expect(goBinaryName("github.com/x/v0")).toBe("v0");
33
- });
34
-
35
- test("a suffix in the middle of the path is not trailing and is kept", () => {
36
- expect(goBinaryName("github.com/x/repo/v2/cmd/cli")).toBe("cli");
37
- });
38
-
39
- test("single-segment and empty inputs pass through", () => {
40
- expect(goBinaryName("mytool")).toBe("mytool");
41
- expect(goBinaryName("")).toBe("");
42
- });
43
- });