magus-cli 7.8.0 → 7.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +4 -4
- package/src/__tests__/blackbox-parity.test.ts +16 -11
- package/src/__tests__/blind/plugin-deps/doctor.test.ts +622 -0
- package/src/__tests__/blind/plugin-deps/helpers.ts +340 -0
- package/src/__tests__/blind/plugin-deps/hook.test.ts +328 -0
- package/src/__tests__/blind/plugin-deps/probe.test.ts +694 -0
- package/src/__tests__/blind/plugin-deps/read-only.test.ts +96 -0
- package/src/__tests__/blind/plugin-deps/schema.test.ts +390 -0
- package/src/__tests__/blind-780-cli-tools.test.ts +36 -48
- package/src/__tests__/blind-780-review2.test.ts +0 -25
- package/src/__tests__/cli-apply-seams.test.ts +2 -67
- package/src/__tests__/cli-tool-commands.test.ts +131 -6
- package/src/__tests__/cli-tools-adapter.test.ts +52 -2
- package/src/__tests__/cli-tools-plugin-managed.test.tsx +83 -0
- package/src/__tests__/deps-adapter.test.ts +191 -0
- package/src/__tests__/deps-check-hook.test.ts +220 -0
- package/src/__tests__/deps-plan.test.ts +896 -0
- package/src/__tests__/deps-report.test.ts +197 -0
- package/src/__tests__/doctor-bins.test.ts +134 -36
- package/src/__tests__/doctor-json.test.ts +261 -0
- package/src/__tests__/failure-popup-review2.test.tsx +1 -25
- package/src/__tests__/final-fixes-780.test.tsx +25 -9
- package/src/__tests__/fixtures/bins.ts +55 -0
- package/src/__tests__/install-plan.test.ts +15 -1
- package/src/__tests__/marketplace-refresh.test.ts +9 -3
- package/src/__tests__/no-shell-spawn.test.ts +77 -0
- package/src/__tests__/npm-package-name.test.ts +6 -9
- package/src/__tests__/plugin-requires.test.ts +138 -153
- package/src/__tests__/requires-probe.test.ts +858 -0
- package/src/__tests__/resolver.test.ts +129 -52
- package/src/__tests__/run.test.ts +108 -0
- package/src/__tests__/tool-probe.test.ts +1 -1
- package/src/__tests__/toolchain.test.ts +123 -52
- package/src/__tests__/update-plan.test.ts +1 -1
- package/src/cli/ansi.ts +2 -7
- package/src/cli/deps-view.ts +213 -0
- package/src/cli/doctor.ts +169 -24
- package/src/cli/install.ts +51 -66
- package/src/cli/profile.ts +20 -2
- package/src/cli/router.ts +6 -2
- package/src/cli/update.ts +28 -7
- package/src/data/cli-tools.ts +24 -9
- package/src/services/active-closure.ts +3 -3
- package/src/services/claude-settings.ts +4 -348
- package/src/services/cli-tool-commands.ts +104 -3
- package/src/services/deps-plan.ts +962 -0
- package/src/services/deps-report.ts +265 -0
- package/src/services/doctor-bins.ts +115 -43
- package/src/services/install-plan.ts +11 -1
- package/src/services/local-marketplace.ts +12 -206
- package/src/services/marketplace-refresh.ts +4 -4
- package/src/services/plugin-manager.ts +4 -18
- package/src/services/plugin-requires.ts +93 -162
- package/src/services/resolver.ts +168 -58
- package/src/services/tool-probe.ts +18 -25
- package/src/services/toolchain.ts +117 -111
- package/src/services/update-engine.ts +19 -14
- package/src/services/update-plan.ts +3 -3
- package/src/shared/plugin/hooks/bun-missing.sh +39 -0
- package/src/shared/plugin/hooks/deps-check.ts +224 -0
- package/src/shared/plugin/hooks/lib/requires-probe.ts +1183 -0
- package/src/types/index.ts +53 -16
- package/src/ui/App.tsx +6 -40
- package/src/ui/adapters/cliToolsAdapter.ts +75 -37
- package/src/ui/adapters/depsAdapter.ts +129 -0
- package/src/ui/adapters/startupReports.ts +0 -22
- package/src/ui/renderers/cliToolRenderers.tsx +135 -2
- package/src/ui/screens/CliToolsScreen.tsx +141 -51
- package/src/ui/screens/PluginsScreen.tsx +12 -48
- package/src/utils/command-utils.ts +4 -5
- package/src/utils/run.ts +123 -52
- package/src/utils/string-utils.ts +9 -0
- package/src/__tests__/go-module.test.ts +0 -43
- package/src/__tests__/plugin-setup.test.ts +0 -1016
- package/src/__tests__/shell-script-callers.test.ts +0 -92
- package/src/services/plugin-setup.ts +0 -965
- package/src/utils/go-module.ts +0 -36
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { access, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
REPO_ROOT,
|
|
8
|
+
type World,
|
|
9
|
+
configureClaude,
|
|
10
|
+
makePlugin,
|
|
11
|
+
makeShellPath,
|
|
12
|
+
makeWorld,
|
|
13
|
+
removeWorld,
|
|
14
|
+
runChild,
|
|
15
|
+
runDoctor,
|
|
16
|
+
writeExecutable,
|
|
17
|
+
} from "./helpers";
|
|
18
|
+
|
|
19
|
+
function requirement(
|
|
20
|
+
name: string,
|
|
21
|
+
install: Record<string, unknown>[],
|
|
22
|
+
check = [name, "--version"],
|
|
23
|
+
): Record<string, unknown> {
|
|
24
|
+
return { check, install, name };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function requires(bin: Record<string, unknown>[]): Record<string, unknown> {
|
|
28
|
+
return { bin, schema: 1 };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function installPlugin(
|
|
32
|
+
world: World,
|
|
33
|
+
name: string,
|
|
34
|
+
requiresValue: unknown,
|
|
35
|
+
scope: "user" | "project" | "local" = "user",
|
|
36
|
+
): Promise<string> {
|
|
37
|
+
const root = await makePlugin(world, name, requiresValue);
|
|
38
|
+
await configureClaude(world, [{ key: `${name}@test`, root, scope }]);
|
|
39
|
+
return root;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function installPlugins(
|
|
43
|
+
world: World,
|
|
44
|
+
plugins: Array<{
|
|
45
|
+
name: string;
|
|
46
|
+
requires: unknown;
|
|
47
|
+
scope: "user" | "project" | "local";
|
|
48
|
+
}>,
|
|
49
|
+
): Promise<void> {
|
|
50
|
+
const entries = [];
|
|
51
|
+
for (const plugin of plugins) {
|
|
52
|
+
const root = await makePlugin(world, plugin.name, plugin.requires);
|
|
53
|
+
entries.push({ key: `${plugin.name}@test`, root, scope: plugin.scope });
|
|
54
|
+
}
|
|
55
|
+
await configureClaude(world, entries);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function fakeBun(world: World, body: string): Promise<void> {
|
|
59
|
+
await writeExecutable(
|
|
60
|
+
join(world.bin, "bun"),
|
|
61
|
+
`if [ "$1" = "--version" ]; then echo 1.4.0; exit 0; fi\n${body}`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function bunAndWidget(): Record<string, unknown> {
|
|
66
|
+
return requires([
|
|
67
|
+
requirement("bun", [{ tool: "bun", via: "bootstrap" }]),
|
|
68
|
+
requirement("widget", [{ package: "widget", via: "bun" }]),
|
|
69
|
+
]);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function shellLines(...lines: string[]): string {
|
|
73
|
+
return lines.join("\n");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface AppliedJson {
|
|
77
|
+
argv: string[];
|
|
78
|
+
dir?: string;
|
|
79
|
+
durationMs: number;
|
|
80
|
+
exitCode: number | null;
|
|
81
|
+
name: string;
|
|
82
|
+
outputTail: string;
|
|
83
|
+
remedy?: string;
|
|
84
|
+
status: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface DepJson {
|
|
88
|
+
key: string;
|
|
89
|
+
name: string;
|
|
90
|
+
requiredBy: string[];
|
|
91
|
+
status: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface ReportJson {
|
|
95
|
+
applied: AppliedJson[];
|
|
96
|
+
deps: DepJson[];
|
|
97
|
+
invalid: unknown[];
|
|
98
|
+
magusOnPath: boolean;
|
|
99
|
+
pathAdvice: Array<Record<string, unknown>>;
|
|
100
|
+
platform: { arch: string; os: string };
|
|
101
|
+
plugins: string[];
|
|
102
|
+
plugins_needing_update: unknown[];
|
|
103
|
+
schema: number;
|
|
104
|
+
unfixable: unknown[];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface RawReleaseOption extends Record<string, unknown> {
|
|
108
|
+
sha256: Record<string, string>;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface RawReleaseRequirement extends Record<string, unknown> {
|
|
112
|
+
install: RawReleaseOption[];
|
|
113
|
+
name: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function parsed(result: { stdout: string }): ReportJson {
|
|
117
|
+
return JSON.parse(result.stdout) as ReportJson;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
describe("magus doctor black-box contract", () => {
|
|
121
|
+
// Spec: AS-BUILT.md §4, plugin set is enabledPlugins across user,
|
|
122
|
+
// project, and local settings, merges identical declarations, works with
|
|
123
|
+
// no profiles.json, and --json is exactly one dependency document.
|
|
124
|
+
test("discovers all enabled scopes without profiles and reports JSON shape", async () => {
|
|
125
|
+
const world = await makeWorld();
|
|
126
|
+
try {
|
|
127
|
+
const plugins = [
|
|
128
|
+
{ name: "user-tool", scope: "user" as const },
|
|
129
|
+
{ name: "project-tool", scope: "project" as const },
|
|
130
|
+
{ name: "local-tool", scope: "local" as const },
|
|
131
|
+
];
|
|
132
|
+
for (const plugin of plugins) {
|
|
133
|
+
await writeExecutable(join(world.bin, plugin.name), "exit 0");
|
|
134
|
+
}
|
|
135
|
+
await writeExecutable(join(world.bin, "shared-tool"), "exit 0");
|
|
136
|
+
await installPlugins(
|
|
137
|
+
world,
|
|
138
|
+
plugins.map((plugin) => ({
|
|
139
|
+
...plugin,
|
|
140
|
+
requires: requires([
|
|
141
|
+
requirement(plugin.name, [{ formula: plugin.name, via: "brew" }]),
|
|
142
|
+
requirement("shared-tool", [
|
|
143
|
+
{ formula: "shared-tool", via: "brew" },
|
|
144
|
+
]),
|
|
145
|
+
]),
|
|
146
|
+
})),
|
|
147
|
+
);
|
|
148
|
+
await expect(
|
|
149
|
+
access(join(world.home, ".claude", "profiles.json")),
|
|
150
|
+
).rejects.toThrow();
|
|
151
|
+
const result = await runDoctor(world, ["--json"]);
|
|
152
|
+
expect(result.timedOut).toBe(false);
|
|
153
|
+
expect(result.exitCode).toBe(0);
|
|
154
|
+
const report = parsed(result);
|
|
155
|
+
expect(report).toMatchObject({
|
|
156
|
+
invalid: [],
|
|
157
|
+
magusOnPath: false,
|
|
158
|
+
pathAdvice: [],
|
|
159
|
+
plugins_needing_update: [],
|
|
160
|
+
schema: 1,
|
|
161
|
+
unfixable: [],
|
|
162
|
+
});
|
|
163
|
+
expect(report.platform).toEqual({
|
|
164
|
+
arch: expect.any(String),
|
|
165
|
+
os: expect.stringMatching(/^(darwin|linux)$/),
|
|
166
|
+
});
|
|
167
|
+
expect(Object.keys(report).sort()).toEqual([
|
|
168
|
+
"deps",
|
|
169
|
+
"invalid",
|
|
170
|
+
"magusOnPath",
|
|
171
|
+
"pathAdvice",
|
|
172
|
+
"platform",
|
|
173
|
+
"plugins",
|
|
174
|
+
"plugins_needing_update",
|
|
175
|
+
"schema",
|
|
176
|
+
"unfixable",
|
|
177
|
+
]);
|
|
178
|
+
expect(new Set(report.plugins)).toEqual(
|
|
179
|
+
new Set(["user-tool@test", "project-tool@test", "local-tool@test"]),
|
|
180
|
+
);
|
|
181
|
+
expect(
|
|
182
|
+
Object.fromEntries(report.deps.map((dep) => [dep.name, dep.status])),
|
|
183
|
+
).toEqual({
|
|
184
|
+
"local-tool": "present",
|
|
185
|
+
"project-tool": "present",
|
|
186
|
+
"shared-tool": "present",
|
|
187
|
+
"user-tool": "present",
|
|
188
|
+
});
|
|
189
|
+
for (const dep of report.deps) {
|
|
190
|
+
expect(dep.key).toMatch(new RegExp(`^${dep.name}-[0-9a-f]{12}$`));
|
|
191
|
+
if (dep.name === "shared-tool") {
|
|
192
|
+
expect(new Set(dep.requiredBy)).toEqual(
|
|
193
|
+
new Set(["user-tool@test", "project-tool@test", "local-tool@test"]),
|
|
194
|
+
);
|
|
195
|
+
} else {
|
|
196
|
+
expect(dep.requiredBy).toEqual([`${dep.name}@test`]);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
} finally {
|
|
200
|
+
await removeWorld(world);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// Spec: AS-BUILT.md §8.4, --json remains exactly one document under
|
|
205
|
+
// --fix; installer stdout and stderr appear only inside outputTail.
|
|
206
|
+
test("captures installer output without corrupting fix JSON stdout", async () => {
|
|
207
|
+
const world = await makeWorld();
|
|
208
|
+
try {
|
|
209
|
+
await installPlugin(world, "testdata", bunAndWidget());
|
|
210
|
+
await fakeBun(
|
|
211
|
+
world,
|
|
212
|
+
shellLines(
|
|
213
|
+
"echo installer-stdout",
|
|
214
|
+
"echo installer-stderr >&2",
|
|
215
|
+
': > "$INSTALL_MARKER"',
|
|
216
|
+
"printf '#!/bin/sh\\nexit 0\\n' > \"$INSTALL_DEST/widget\"",
|
|
217
|
+
'/bin/chmod 755 "$INSTALL_DEST/widget"',
|
|
218
|
+
"exit 0",
|
|
219
|
+
),
|
|
220
|
+
);
|
|
221
|
+
const marker = join(world.root, "installer-ran");
|
|
222
|
+
const result = await runDoctor(world, ["--fix", "--yes", "--json"], {
|
|
223
|
+
extraEnv: { INSTALL_DEST: world.bin, INSTALL_MARKER: marker },
|
|
224
|
+
});
|
|
225
|
+
expect(result.exitCode).toBe(0);
|
|
226
|
+
expect(result.stderr).not.toContain("installer-stderr");
|
|
227
|
+
const report = parsed(result);
|
|
228
|
+
expect(report.applied).toHaveLength(1);
|
|
229
|
+
expect(report.applied[0]).toMatchObject({
|
|
230
|
+
exitCode: 0,
|
|
231
|
+
name: "widget",
|
|
232
|
+
status: "installed",
|
|
233
|
+
});
|
|
234
|
+
expect(report.applied[0].argv.slice(-3)).toEqual(["add", "-g", "widget"]);
|
|
235
|
+
expect(report.applied[0].durationMs).toBeGreaterThanOrEqual(0);
|
|
236
|
+
expect(report.applied[0].outputTail).toContain("installer-stdout");
|
|
237
|
+
expect(report.applied[0].outputTail).toContain("installer-stderr");
|
|
238
|
+
expect(existsSync(marker)).toBe(true);
|
|
239
|
+
} finally {
|
|
240
|
+
await removeWorld(world);
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// Spec: AS-BUILT.md §4 and §8.1, --fix asks once; n and EOF install
|
|
245
|
+
// nothing, EOF exits 130, and --yes installs without asking.
|
|
246
|
+
test("honors decline, EOF cancellation, and --yes consent", async () => {
|
|
247
|
+
const world = await makeWorld();
|
|
248
|
+
try {
|
|
249
|
+
await installPlugin(world, "testdata", bunAndWidget());
|
|
250
|
+
const marker = join(world.root, "installer-ran");
|
|
251
|
+
await fakeBun(
|
|
252
|
+
world,
|
|
253
|
+
shellLines(
|
|
254
|
+
': > "$INSTALL_MARKER"',
|
|
255
|
+
"printf '#!/bin/sh\\nexit 0\\n' > \"$INSTALL_DEST/widget\"",
|
|
256
|
+
'/bin/chmod 755 "$INSTALL_DEST/widget"',
|
|
257
|
+
"exit 0",
|
|
258
|
+
),
|
|
259
|
+
);
|
|
260
|
+
const options = {
|
|
261
|
+
extraEnv: { INSTALL_DEST: world.bin, INSTALL_MARKER: marker },
|
|
262
|
+
};
|
|
263
|
+
const declined = await runDoctor(world, ["--fix"], {
|
|
264
|
+
...options,
|
|
265
|
+
input: "n\n",
|
|
266
|
+
});
|
|
267
|
+
expect(declined.exitCode).toBe(1);
|
|
268
|
+
expect(
|
|
269
|
+
`${declined.stdout}${declined.stderr}`.match(/Install them now\?/g),
|
|
270
|
+
).toHaveLength(1);
|
|
271
|
+
await expect(access(marker)).rejects.toThrow();
|
|
272
|
+
const eof = await runDoctor(world, ["--fix"], options);
|
|
273
|
+
expect(eof.exitCode).toBe(130);
|
|
274
|
+
expect(
|
|
275
|
+
`${eof.stdout}${eof.stderr}`.match(/Install them now\?/g),
|
|
276
|
+
).toHaveLength(1);
|
|
277
|
+
await expect(access(marker)).rejects.toThrow();
|
|
278
|
+
const accepted = await runDoctor(world, ["--fix", "--yes"], options);
|
|
279
|
+
expect(accepted.exitCode).toBe(0);
|
|
280
|
+
expect(`${accepted.stdout}${accepted.stderr}`).not.toContain(
|
|
281
|
+
"Install them now?",
|
|
282
|
+
);
|
|
283
|
+
expect(existsSync(marker)).toBe(true);
|
|
284
|
+
} finally {
|
|
285
|
+
await removeWorld(world);
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// Spec: AS-BUILT.md §8.2, --fix --json without --yes is refused before
|
|
290
|
+
// probing with exit 2, empty stdout, and the fixed explanation on stderr.
|
|
291
|
+
test("refuses interactive JSON fix with exit code 2", async () => {
|
|
292
|
+
const world = await makeWorld();
|
|
293
|
+
try {
|
|
294
|
+
await installPlugin(world, "testdata", bunAndWidget());
|
|
295
|
+
const marker = join(world.root, "bun-ran");
|
|
296
|
+
await writeExecutable(join(world.bin, "bun"), `: > "${marker}"\nexit 0`);
|
|
297
|
+
const result = await runDoctor(world, ["--fix", "--json"]);
|
|
298
|
+
expect(result.exitCode).toBe(2);
|
|
299
|
+
expect(result.stdout).toBe("");
|
|
300
|
+
expect(result.stderr.trim()).toBe(
|
|
301
|
+
[
|
|
302
|
+
"magus doctor --fix --json needs --yes: nothing can be asked while",
|
|
303
|
+
"stdout carries the report",
|
|
304
|
+
].join(" "),
|
|
305
|
+
);
|
|
306
|
+
await expect(access(marker)).rejects.toThrow();
|
|
307
|
+
} finally {
|
|
308
|
+
await removeWorld(world);
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// Spec: AS-BUILT.md §4, installation into ~/.local/bin off the original
|
|
313
|
+
// PATH is off-path, exit 1, and includes the shell rc remedy.
|
|
314
|
+
test("reports a successful install off PATH instead of claiming health", async () => {
|
|
315
|
+
const world = await makeWorld();
|
|
316
|
+
try {
|
|
317
|
+
const plugin = await installPlugin(
|
|
318
|
+
world,
|
|
319
|
+
"testdata",
|
|
320
|
+
requires([
|
|
321
|
+
requirement("uv", [{ tool: "uv", via: "bootstrap" }]),
|
|
322
|
+
requirement("widget", [
|
|
323
|
+
{
|
|
324
|
+
args: ["--install"],
|
|
325
|
+
script: "scripts/install.py",
|
|
326
|
+
via: "uv-script",
|
|
327
|
+
},
|
|
328
|
+
]),
|
|
329
|
+
]),
|
|
330
|
+
);
|
|
331
|
+
await mkdir(join(plugin, "scripts"), { recursive: true });
|
|
332
|
+
await writeFile(join(plugin, "scripts", "install.py"), "testdata\n");
|
|
333
|
+
await writeExecutable(
|
|
334
|
+
join(world.bin, "uv"),
|
|
335
|
+
shellLines(
|
|
336
|
+
'if [ "$1" = "--version" ]; then exit 0; fi',
|
|
337
|
+
'/bin/mkdir -p "$HOME/.local/bin"',
|
|
338
|
+
"printf '#!/bin/sh\\nexit 0\\n' > \"$HOME/.local/bin/widget\"",
|
|
339
|
+
'/bin/chmod 755 "$HOME/.local/bin/widget"',
|
|
340
|
+
"exit 0",
|
|
341
|
+
),
|
|
342
|
+
);
|
|
343
|
+
const result = await runDoctor(world, ["--fix", "--yes", "--json"]);
|
|
344
|
+
expect(result.exitCode).toBe(1);
|
|
345
|
+
const report = parsed(result);
|
|
346
|
+
expect(report.applied).toEqual([
|
|
347
|
+
expect.objectContaining({
|
|
348
|
+
dir: "~/.local/bin",
|
|
349
|
+
name: "widget",
|
|
350
|
+
status: "off-path",
|
|
351
|
+
}),
|
|
352
|
+
]);
|
|
353
|
+
expect(report.applied[0].remedy).toContain(
|
|
354
|
+
'export PATH="$HOME/.local/bin:$PATH"',
|
|
355
|
+
);
|
|
356
|
+
expect(report.applied[0].remedy).toContain(
|
|
357
|
+
"restart Claude Code from a new terminal",
|
|
358
|
+
);
|
|
359
|
+
expect(report.pathAdvice).toContainEqual({
|
|
360
|
+
prefix: "~/.local/bin",
|
|
361
|
+
rcFile: "~/.zshrc",
|
|
362
|
+
rcLine: 'export PATH="$HOME/.local/bin:$PATH"',
|
|
363
|
+
});
|
|
364
|
+
} finally {
|
|
365
|
+
await removeWorld(world);
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// Spec: AS-BUILT.md §8.3, a failed post-install re-probe keeps the
|
|
370
|
+
// by-hand command in remedy and appends the failure reason to outputTail.
|
|
371
|
+
test("fails an exit-zero installer that leaves the requirement absent", async () => {
|
|
372
|
+
const world = await makeWorld();
|
|
373
|
+
try {
|
|
374
|
+
await installPlugin(world, "testdata", bunAndWidget());
|
|
375
|
+
await fakeBun(world, "echo did-nothing\nexit 0");
|
|
376
|
+
const result = await runDoctor(world, ["--fix", "--yes", "--json"]);
|
|
377
|
+
expect(result.exitCode).toBe(1);
|
|
378
|
+
const report = parsed(result);
|
|
379
|
+
expect(report.applied).toHaveLength(1);
|
|
380
|
+
expect(report.applied[0]).toMatchObject({
|
|
381
|
+
exitCode: 0,
|
|
382
|
+
name: "widget",
|
|
383
|
+
status: "failed",
|
|
384
|
+
});
|
|
385
|
+
expect(report.applied[0].remedy).toBe("bun add -g widget");
|
|
386
|
+
expect(report.applied[0].outputTail).toContain("did-nothing");
|
|
387
|
+
expect(report.applied[0].outputTail.trimEnd().split("\n").at(-1)).toBe(
|
|
388
|
+
"widget: the installer exited 0, but `widget --version` still fails",
|
|
389
|
+
);
|
|
390
|
+
} finally {
|
|
391
|
+
await removeWorld(world);
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
const nonRootTest =
|
|
396
|
+
typeof process.getuid === "function" && process.getuid() !== 0
|
|
397
|
+
? test
|
|
398
|
+
: test.skip;
|
|
399
|
+
|
|
400
|
+
// Spec: AS-BUILT.md §4, failed sudo -n true makes every root step
|
|
401
|
+
// needs-password before any runs; absent sudo makes it needs-root.
|
|
402
|
+
nonRootTest("preflights sudo and never partly runs root work", async () => {
|
|
403
|
+
const passwordWorld = await makeWorld();
|
|
404
|
+
try {
|
|
405
|
+
await installPlugin(
|
|
406
|
+
passwordWorld,
|
|
407
|
+
"testdata",
|
|
408
|
+
requires([
|
|
409
|
+
requirement("root-tool", [{ packages: ["root-tool"], via: "apt" }]),
|
|
410
|
+
]),
|
|
411
|
+
);
|
|
412
|
+
const aptMarker = join(passwordWorld.root, "apt-ran");
|
|
413
|
+
const sudoMarker = join(passwordWorld.root, "sudo-argv");
|
|
414
|
+
await writeExecutable(
|
|
415
|
+
join(passwordWorld.bin, "apt-get"),
|
|
416
|
+
`: > "${aptMarker}"\nexit 0`,
|
|
417
|
+
);
|
|
418
|
+
await writeExecutable(
|
|
419
|
+
join(passwordWorld.bin, "sudo"),
|
|
420
|
+
[
|
|
421
|
+
`printf '%s\\n' "$*" > "${sudoMarker}"`,
|
|
422
|
+
'[ "$1" = "-n" ] && [ "$2" = "true" ] && exit 1',
|
|
423
|
+
"exit 99",
|
|
424
|
+
].join("\n"),
|
|
425
|
+
);
|
|
426
|
+
const password = await runDoctor(passwordWorld, [
|
|
427
|
+
"--fix",
|
|
428
|
+
"--yes",
|
|
429
|
+
"--json",
|
|
430
|
+
]);
|
|
431
|
+
expect(password.exitCode).toBe(1);
|
|
432
|
+
expect(parsed(password).applied).toEqual([
|
|
433
|
+
expect.objectContaining({
|
|
434
|
+
name: "root-tool",
|
|
435
|
+
status: "needs-password",
|
|
436
|
+
}),
|
|
437
|
+
]);
|
|
438
|
+
expect(await readFile(sudoMarker, "utf8")).toBe("-n true\n");
|
|
439
|
+
await expect(access(aptMarker)).rejects.toThrow();
|
|
440
|
+
} finally {
|
|
441
|
+
await removeWorld(passwordWorld);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const noSudoWorld = await makeWorld();
|
|
445
|
+
try {
|
|
446
|
+
await installPlugin(
|
|
447
|
+
noSudoWorld,
|
|
448
|
+
"testdata",
|
|
449
|
+
requires([
|
|
450
|
+
requirement("root-tool", [{ packages: ["root-tool"], via: "apt" }]),
|
|
451
|
+
]),
|
|
452
|
+
);
|
|
453
|
+
const aptMarker = join(noSudoWorld.root, "apt-ran");
|
|
454
|
+
await writeExecutable(
|
|
455
|
+
join(noSudoWorld.bin, "apt-get"),
|
|
456
|
+
`: > "${aptMarker}"\nexit 0`,
|
|
457
|
+
);
|
|
458
|
+
const noSudo = await runDoctor(noSudoWorld, ["--fix", "--yes", "--json"]);
|
|
459
|
+
expect(noSudo.exitCode).toBe(1);
|
|
460
|
+
expect(parsed(noSudo).applied).toEqual([
|
|
461
|
+
expect.objectContaining({
|
|
462
|
+
name: "root-tool",
|
|
463
|
+
status: "needs-root",
|
|
464
|
+
}),
|
|
465
|
+
]);
|
|
466
|
+
await expect(access(aptMarker)).rejects.toThrow();
|
|
467
|
+
} finally {
|
|
468
|
+
await removeWorld(noSudoWorld);
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
// Spec: AS-BUILT.md §4, a live-pid doctor-fix lock refuses work while a
|
|
473
|
+
// dead-pid lock is taken over.
|
|
474
|
+
test("refuses a live install lock and takes over a stale lock", async () => {
|
|
475
|
+
const world = await makeWorld();
|
|
476
|
+
try {
|
|
477
|
+
await installPlugin(world, "testdata", bunAndWidget());
|
|
478
|
+
const marker = join(world.root, "installer-ran");
|
|
479
|
+
await fakeBun(
|
|
480
|
+
world,
|
|
481
|
+
shellLines(
|
|
482
|
+
`: > "${marker}"`,
|
|
483
|
+
"printf '#!/bin/sh\\nexit 0\\n' > \"$INSTALL_DEST/widget\"",
|
|
484
|
+
'/bin/chmod 755 "$INSTALL_DEST/widget"',
|
|
485
|
+
"exit 0",
|
|
486
|
+
),
|
|
487
|
+
);
|
|
488
|
+
const lock = join(world.cache, "magus", "doctor-fix.lock");
|
|
489
|
+
await mkdir(join(world.cache, "magus"), { recursive: true });
|
|
490
|
+
await writeFile(lock, `${process.pid}\n`);
|
|
491
|
+
const options = { extraEnv: { INSTALL_DEST: world.bin } };
|
|
492
|
+
const live = await runDoctor(
|
|
493
|
+
world,
|
|
494
|
+
["--fix", "--yes", "--json"],
|
|
495
|
+
options,
|
|
496
|
+
);
|
|
497
|
+
expect(live.exitCode).toBe(1);
|
|
498
|
+
expect(`${live.stdout}${live.stderr}`).toContain(
|
|
499
|
+
`another magus dependency install (pid ${process.pid}) is running`,
|
|
500
|
+
);
|
|
501
|
+
await expect(access(marker)).rejects.toThrow();
|
|
502
|
+
await writeFile(lock, "2147483647\n");
|
|
503
|
+
const stale = await runDoctor(
|
|
504
|
+
world,
|
|
505
|
+
["--fix", "--yes", "--json"],
|
|
506
|
+
options,
|
|
507
|
+
);
|
|
508
|
+
expect(stale.exitCode).toBe(0);
|
|
509
|
+
expect(parsed(stale).applied[0]).toMatchObject({
|
|
510
|
+
name: "widget",
|
|
511
|
+
status: "installed",
|
|
512
|
+
});
|
|
513
|
+
expect(existsSync(marker)).toBe(true);
|
|
514
|
+
} finally {
|
|
515
|
+
await removeWorld(world);
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
const supportedPlatform =
|
|
521
|
+
(process.platform === "darwin" || process.platform === "linux") &&
|
|
522
|
+
(process.arch === "x64" || process.arch === "arm64");
|
|
523
|
+
const networkTest =
|
|
524
|
+
process.env.MAGUS_BLACKBOX_NETWORK === "1" && supportedPlatform
|
|
525
|
+
? test
|
|
526
|
+
: test.skip;
|
|
527
|
+
|
|
528
|
+
async function realReleaseRequirement(): Promise<RawReleaseRequirement> {
|
|
529
|
+
const path = join(
|
|
530
|
+
REPO_ROOT,
|
|
531
|
+
"plugins",
|
|
532
|
+
"terminal",
|
|
533
|
+
".claude-plugin",
|
|
534
|
+
"plugin.json",
|
|
535
|
+
);
|
|
536
|
+
const manifest = JSON.parse(await readFile(path, "utf8")) as {
|
|
537
|
+
requires: { bin: RawReleaseRequirement[] };
|
|
538
|
+
};
|
|
539
|
+
const requirement = manifest.requires.bin.find(
|
|
540
|
+
(item) => item.name === "tmux-mcp",
|
|
541
|
+
);
|
|
542
|
+
if (!requirement) {
|
|
543
|
+
throw new Error("terminal manifest does not declare tmux-mcp");
|
|
544
|
+
}
|
|
545
|
+
return requirement;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
describe("real pinned release", () => {
|
|
549
|
+
// Spec: AS-BUILT.md §4 observed release and §6 permit reading terminal's
|
|
550
|
+
// pinned v2.0.0 digests; real downloads require MAGUS_BLACKBOX_NETWORK=1.
|
|
551
|
+
networkTest(
|
|
552
|
+
"installs and verifies the pinned tmux-mcp v2.0.0 release",
|
|
553
|
+
async () => {
|
|
554
|
+
const world = await makeWorld();
|
|
555
|
+
try {
|
|
556
|
+
const requirement = await realReleaseRequirement();
|
|
557
|
+
await installPlugin(world, "testdata", requires([requirement]));
|
|
558
|
+
const shellPath = await makeShellPath(world);
|
|
559
|
+
const localBin = join(world.home, ".local", "bin");
|
|
560
|
+
await mkdir(localBin, { recursive: true });
|
|
561
|
+
const result = await runDoctor(world, ["--fix", "--yes", "--json"], {
|
|
562
|
+
pathDirs: [localBin, shellPath],
|
|
563
|
+
timeoutMs: 120_000,
|
|
564
|
+
});
|
|
565
|
+
expect(result.exitCode).toBe(0);
|
|
566
|
+
expect(parsed(result).applied[0]).toMatchObject({
|
|
567
|
+
name: "tmux-mcp",
|
|
568
|
+
status: "installed",
|
|
569
|
+
});
|
|
570
|
+
const version = await runChild(
|
|
571
|
+
join(localBin, "tmux-mcp"),
|
|
572
|
+
["--version"],
|
|
573
|
+
{
|
|
574
|
+
cwd: world.project,
|
|
575
|
+
env: { HOME: world.home, PATH: localBin },
|
|
576
|
+
timeoutMs: 5_000,
|
|
577
|
+
},
|
|
578
|
+
);
|
|
579
|
+
expect(version.exitCode).toBe(0);
|
|
580
|
+
expect(`${version.stdout}${version.stderr}`).toContain("2.0.0");
|
|
581
|
+
} finally {
|
|
582
|
+
await removeWorld(world);
|
|
583
|
+
}
|
|
584
|
+
},
|
|
585
|
+
);
|
|
586
|
+
|
|
587
|
+
// Spec: AS-BUILT.md §4, a release digest mismatch fails closed and writes
|
|
588
|
+
// nothing to ~/.local/bin; real downloads are opt-in.
|
|
589
|
+
networkTest(
|
|
590
|
+
"rejects altered release digest without writing the binary",
|
|
591
|
+
async () => {
|
|
592
|
+
const world = await makeWorld();
|
|
593
|
+
try {
|
|
594
|
+
const requirement = await realReleaseRequirement();
|
|
595
|
+
const option = requirement.install[0];
|
|
596
|
+
const os = process.platform;
|
|
597
|
+
const arch = process.arch === "x64" ? "amd64" : "arm64";
|
|
598
|
+
const platform = `${os}_${arch}`;
|
|
599
|
+
const original = option.sha256[platform];
|
|
600
|
+
option.sha256[platform] =
|
|
601
|
+
`${original[0] === "0" ? "1" : "0"}${original.slice(1)}`;
|
|
602
|
+
await installPlugin(world, "testdata", requires([requirement]));
|
|
603
|
+
const shellPath = await makeShellPath(world);
|
|
604
|
+
const localBin = join(world.home, ".local", "bin");
|
|
605
|
+
await mkdir(localBin, { recursive: true });
|
|
606
|
+
const result = await runDoctor(world, ["--fix", "--yes", "--json"], {
|
|
607
|
+
pathDirs: [localBin, shellPath],
|
|
608
|
+
timeoutMs: 120_000,
|
|
609
|
+
});
|
|
610
|
+
expect(result.exitCode).toBe(1);
|
|
611
|
+
expect(parsed(result).applied[0]).toMatchObject({
|
|
612
|
+
name: "tmux-mcp",
|
|
613
|
+
status: "failed",
|
|
614
|
+
});
|
|
615
|
+
await expect(access(join(localBin, "tmux-mcp"))).rejects.toThrow();
|
|
616
|
+
expect(await readdir(localBin)).toEqual([]);
|
|
617
|
+
} finally {
|
|
618
|
+
await removeWorld(world);
|
|
619
|
+
}
|
|
620
|
+
},
|
|
621
|
+
);
|
|
622
|
+
});
|