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,694 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { access, mkdir, symlink } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
type InstallOption,
|
|
8
|
+
type InstallStep,
|
|
9
|
+
type ProbeEnv,
|
|
10
|
+
type Requirement,
|
|
11
|
+
TOOLCHAIN_BOOTSTRAP,
|
|
12
|
+
USER_PREFIXES,
|
|
13
|
+
dedupeKey,
|
|
14
|
+
describeStep,
|
|
15
|
+
optionBlocker,
|
|
16
|
+
pathRcLine,
|
|
17
|
+
probe,
|
|
18
|
+
rcFile,
|
|
19
|
+
releaseAsset,
|
|
20
|
+
releaseFormat,
|
|
21
|
+
renderFix,
|
|
22
|
+
renderInstall,
|
|
23
|
+
selectOption,
|
|
24
|
+
whyUnselectable,
|
|
25
|
+
} from "../../../shared/plugin/hooks/lib/requires-probe.js";
|
|
26
|
+
import { type World, makeWorld, removeWorld, writeExecutable } from "./helpers";
|
|
27
|
+
|
|
28
|
+
const digest = "b".repeat(64);
|
|
29
|
+
|
|
30
|
+
function req(
|
|
31
|
+
name: string,
|
|
32
|
+
install: InstallOption[],
|
|
33
|
+
check = [name, "--version"],
|
|
34
|
+
): Requirement {
|
|
35
|
+
return { check, install, name };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function env(world: World, patch: Partial<ProbeEnv> = {}): ProbeEnv {
|
|
39
|
+
return {
|
|
40
|
+
arch: "amd64",
|
|
41
|
+
capMs: 500,
|
|
42
|
+
depth: "full",
|
|
43
|
+
home: world.home,
|
|
44
|
+
os: "linux",
|
|
45
|
+
path: world.bin,
|
|
46
|
+
pluginRoot: join(world.root, "plugin"),
|
|
47
|
+
userShell: "/bin/zsh",
|
|
48
|
+
...patch,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe("probe contract", () => {
|
|
53
|
+
// Spec: contract/requires-probe.d.ts dedupeKey, “object keys sorted” and
|
|
54
|
+
// “different ones never hide each other.”
|
|
55
|
+
test("dedupeKey ignores object key order and changes with declarations", () => {
|
|
56
|
+
const first = {
|
|
57
|
+
check: ["tool", "--version"],
|
|
58
|
+
install: [{ formula: "tool", via: "brew" as const }],
|
|
59
|
+
name: "tool",
|
|
60
|
+
};
|
|
61
|
+
const reordered = {
|
|
62
|
+
name: "tool",
|
|
63
|
+
install: [{ via: "brew" as const, formula: "tool" }],
|
|
64
|
+
check: ["tool", "--version"],
|
|
65
|
+
};
|
|
66
|
+
const key = dedupeKey(first);
|
|
67
|
+
expect(key).toBe(dedupeKey(reordered));
|
|
68
|
+
expect(key).toMatch(/^tool-[0-9a-f]{12}$/);
|
|
69
|
+
for (const changed of [
|
|
70
|
+
{ ...first, name: "other" },
|
|
71
|
+
{ ...first, check: ["tool", "-V"] },
|
|
72
|
+
{
|
|
73
|
+
...first,
|
|
74
|
+
install: [{ formula: "other", via: "brew" as const }],
|
|
75
|
+
},
|
|
76
|
+
]) {
|
|
77
|
+
expect(dedupeKey(changed)).not.toBe(key);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Spec: AS-BUILT.md §2, presence skips only self-named checks, full runs
|
|
82
|
+
// all checks, cwd/HOME are pinned, and only ${CLAUDE_PLUGIN_ROOT} expands.
|
|
83
|
+
test("presence skips a self-named check while full executes it", async () => {
|
|
84
|
+
const world = await makeWorld();
|
|
85
|
+
try {
|
|
86
|
+
await mkdir(join(world.root, "plugin"), { recursive: true });
|
|
87
|
+
await writeExecutable(
|
|
88
|
+
join(world.bin, "self-check"),
|
|
89
|
+
": > full-check-ran\nexit 7",
|
|
90
|
+
);
|
|
91
|
+
await writeExecutable(
|
|
92
|
+
join(world.bin, "runner"),
|
|
93
|
+
[
|
|
94
|
+
`[ "$1" = "${join(world.root, "plugin")}" ] || exit 2`,
|
|
95
|
+
"[ \"$2\" = '$HOME' ] || exit 3",
|
|
96
|
+
`[ "$HOME" = "${world.home}" ] || exit 4`,
|
|
97
|
+
": > presence-check-ran",
|
|
98
|
+
"exit 0",
|
|
99
|
+
].join("\n"),
|
|
100
|
+
);
|
|
101
|
+
const selfNamed = req("self-check", [
|
|
102
|
+
{ formula: "self-check", via: "brew" },
|
|
103
|
+
]);
|
|
104
|
+
const otherNamed = req(
|
|
105
|
+
"marker-check",
|
|
106
|
+
[{ formula: "marker-check", via: "brew" }],
|
|
107
|
+
["runner", "${CLAUDE_PLUGIN_ROOT}", "$HOME"],
|
|
108
|
+
);
|
|
109
|
+
const started = performance.now();
|
|
110
|
+
const presence = await probe(
|
|
111
|
+
[selfNamed, otherNamed],
|
|
112
|
+
env(world, { depth: "presence" }),
|
|
113
|
+
);
|
|
114
|
+
expect(performance.now() - started).toBeLessThan(1_000);
|
|
115
|
+
expect(presence).toEqual([
|
|
116
|
+
{ name: "self-check", status: "present" },
|
|
117
|
+
{ name: "marker-check", status: "present" },
|
|
118
|
+
]);
|
|
119
|
+
await expect(
|
|
120
|
+
access(join(world.root, "plugin", "full-check-ran")),
|
|
121
|
+
).rejects.toThrow();
|
|
122
|
+
expect(existsSync(join(world.root, "plugin", "presence-check-ran"))).toBe(
|
|
123
|
+
true,
|
|
124
|
+
);
|
|
125
|
+
const full = await probe(
|
|
126
|
+
[selfNamed, otherNamed],
|
|
127
|
+
env(world, { depth: "full" }),
|
|
128
|
+
);
|
|
129
|
+
expect(full).toEqual([
|
|
130
|
+
{ name: "self-check", status: "missing" },
|
|
131
|
+
{ name: "marker-check", status: "present" },
|
|
132
|
+
]);
|
|
133
|
+
expect(existsSync(join(world.root, "plugin", "full-check-ran"))).toBe(
|
|
134
|
+
true,
|
|
135
|
+
);
|
|
136
|
+
} finally {
|
|
137
|
+
await removeWorld(world);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// Spec: AS-BUILT.md §2, a dangling symlink on PATH is missing and an
|
|
142
|
+
// executable under ~/.local/bin but off PATH is off-path with that prefix.
|
|
143
|
+
test("distinguishes dangling PATH entries from off-path user installs", async () => {
|
|
144
|
+
const world = await makeWorld();
|
|
145
|
+
try {
|
|
146
|
+
await mkdir(join(world.root, "plugin"), { recursive: true });
|
|
147
|
+
await symlink(
|
|
148
|
+
join(world.root, "does-not-exist"),
|
|
149
|
+
join(world.bin, "dangling"),
|
|
150
|
+
);
|
|
151
|
+
await writeExecutable(
|
|
152
|
+
join(world.home, ".local", "bin", "local-tool"),
|
|
153
|
+
"exit 0",
|
|
154
|
+
);
|
|
155
|
+
const result = await probe(
|
|
156
|
+
[
|
|
157
|
+
req("dangling", [{ formula: "dangling", via: "brew" }]),
|
|
158
|
+
req("local-tool", [{ formula: "local-tool", via: "brew" }]),
|
|
159
|
+
],
|
|
160
|
+
env(world, { depth: "presence" }),
|
|
161
|
+
);
|
|
162
|
+
expect(result).toEqual([
|
|
163
|
+
{ name: "dangling", status: "missing" },
|
|
164
|
+
{
|
|
165
|
+
name: "local-tool",
|
|
166
|
+
prefix: "~/.local/bin",
|
|
167
|
+
status: "off-path",
|
|
168
|
+
},
|
|
169
|
+
]);
|
|
170
|
+
} finally {
|
|
171
|
+
await removeWorld(world);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// Spec: AS-BUILT.md §2, over capMs becomes unknown with reason “slow:
|
|
176
|
+
// no answer within Ns”.
|
|
177
|
+
test("caps slow checks as unknown rather than missing", async () => {
|
|
178
|
+
const world = await makeWorld();
|
|
179
|
+
try {
|
|
180
|
+
await mkdir(join(world.root, "plugin"), { recursive: true });
|
|
181
|
+
await writeExecutable(join(world.bin, "slow"), "/bin/sleep 2");
|
|
182
|
+
const result = await probe(
|
|
183
|
+
[req("slow", [{ formula: "slow", via: "brew" }])],
|
|
184
|
+
env(world, { capMs: 100 }),
|
|
185
|
+
);
|
|
186
|
+
expect(result).toEqual([
|
|
187
|
+
expect.objectContaining({
|
|
188
|
+
name: "slow",
|
|
189
|
+
reason: expect.stringMatching(/^slow: no answer within [0-9.]+s$/),
|
|
190
|
+
status: "unknown",
|
|
191
|
+
}),
|
|
192
|
+
]);
|
|
193
|
+
} finally {
|
|
194
|
+
await removeWorld(world);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// Spec: AS-BUILT.md §2, a check using another declared requirement waits
|
|
199
|
+
// and becomes blockedBy without running when that requirement is absent.
|
|
200
|
+
test("blocks dependent checks behind a missing declared tool", async () => {
|
|
201
|
+
const world = await makeWorld();
|
|
202
|
+
try {
|
|
203
|
+
await mkdir(join(world.root, "plugin"), { recursive: true });
|
|
204
|
+
const result = await probe(
|
|
205
|
+
[
|
|
206
|
+
req("uv", [{ tool: "uv", via: "bootstrap" }]),
|
|
207
|
+
req(
|
|
208
|
+
"script-env",
|
|
209
|
+
[
|
|
210
|
+
{
|
|
211
|
+
args: ["--check"],
|
|
212
|
+
script: "scripts/tool.py",
|
|
213
|
+
via: "uv-script",
|
|
214
|
+
},
|
|
215
|
+
],
|
|
216
|
+
["uv", "run", "--script", "tool.py"],
|
|
217
|
+
),
|
|
218
|
+
],
|
|
219
|
+
env(world, { depth: "presence" }),
|
|
220
|
+
);
|
|
221
|
+
expect(result).toEqual([
|
|
222
|
+
{ name: "uv", status: "missing" },
|
|
223
|
+
{ blockedBy: "uv", name: "script-env", status: "missing" },
|
|
224
|
+
]);
|
|
225
|
+
} finally {
|
|
226
|
+
await removeWorld(world);
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// Spec: AS-BUILT.md §2, requirements whose os excludes env.os are
|
|
231
|
+
// skipped and absent from the ordered result.
|
|
232
|
+
test("filters requirements by operating system", async () => {
|
|
233
|
+
const world = await makeWorld();
|
|
234
|
+
try {
|
|
235
|
+
await mkdir(join(world.root, "plugin"), { recursive: true });
|
|
236
|
+
const darwinOnly = {
|
|
237
|
+
...req("mac-tool", [
|
|
238
|
+
{ formula: "mac-tool", os: ["darwin"], via: "brew" },
|
|
239
|
+
]),
|
|
240
|
+
os: ["darwin" as const],
|
|
241
|
+
};
|
|
242
|
+
const result = await probe(
|
|
243
|
+
[
|
|
244
|
+
darwinOnly,
|
|
245
|
+
req("linux-tool", [{ packages: ["linux-tool"], via: "apt" }]),
|
|
246
|
+
],
|
|
247
|
+
env(world, { depth: "presence", os: "linux" }),
|
|
248
|
+
);
|
|
249
|
+
expect(result).toEqual([{ name: "linux-tool", status: "missing" }]);
|
|
250
|
+
} finally {
|
|
251
|
+
await removeWorld(world);
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
describe("selection and rendering contract", () => {
|
|
257
|
+
// Spec: contract/requires-probe.d.ts selectOption/optionBlocker say the
|
|
258
|
+
// first OS-applicable option whose tools are present or planned wins.
|
|
259
|
+
test("selects the first usable option and explains blockers", () => {
|
|
260
|
+
const requirement = req("tool", [
|
|
261
|
+
{ formula: "tool", os: ["darwin"], via: "brew" },
|
|
262
|
+
{ os: ["linux"], packages: ["tool"], via: "apt" },
|
|
263
|
+
]);
|
|
264
|
+
const linux = { arch: "amd64" as const, os: "linux" as const };
|
|
265
|
+
const selected = selectOption(
|
|
266
|
+
requirement,
|
|
267
|
+
linux,
|
|
268
|
+
(tool) => tool === "apt-get",
|
|
269
|
+
);
|
|
270
|
+
expect(selected).toEqual({
|
|
271
|
+
os: ["linux"],
|
|
272
|
+
packages: ["tool"],
|
|
273
|
+
via: "apt",
|
|
274
|
+
});
|
|
275
|
+
expect(
|
|
276
|
+
optionBlocker({ formula: "tool", via: "brew" }, linux, () => false),
|
|
277
|
+
).toContain("Homebrew");
|
|
278
|
+
expect(
|
|
279
|
+
optionBlocker(
|
|
280
|
+
{
|
|
281
|
+
asset: "tool-{os}-{arch}",
|
|
282
|
+
binary: "tool",
|
|
283
|
+
repo: "Owner/repo",
|
|
284
|
+
sha256: {},
|
|
285
|
+
tag: "v1",
|
|
286
|
+
via: "release",
|
|
287
|
+
},
|
|
288
|
+
linux,
|
|
289
|
+
() => true,
|
|
290
|
+
),
|
|
291
|
+
).toBe("no pinned build for linux_amd64");
|
|
292
|
+
expect(whyUnselectable(requirement, linux, () => false)).toContain(
|
|
293
|
+
"apt-get",
|
|
294
|
+
);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// Spec: AS-BUILT.md §1 and §9.1 define every renderInstall via,
|
|
298
|
+
// including versions, escalation, and plugin-root scripts.
|
|
299
|
+
test("renderInstall maps every via to its structured step", () => {
|
|
300
|
+
const e = {
|
|
301
|
+
arch: "amd64" as const,
|
|
302
|
+
os: "linux" as const,
|
|
303
|
+
pluginRoot: "/tmp/plugin",
|
|
304
|
+
};
|
|
305
|
+
const cases: Array<[Requirement, InstallOption, InstallStep]> = [
|
|
306
|
+
[
|
|
307
|
+
req("bun-tool", []),
|
|
308
|
+
{ package: "pkg", version: "2", via: "bun" },
|
|
309
|
+
{
|
|
310
|
+
argv: ["bun", "add", "-g", "pkg@2"],
|
|
311
|
+
escalation: "none",
|
|
312
|
+
kind: "argv",
|
|
313
|
+
name: "bun-tool",
|
|
314
|
+
},
|
|
315
|
+
],
|
|
316
|
+
[
|
|
317
|
+
req("formula", []),
|
|
318
|
+
{ formula: "tool", via: "brew" },
|
|
319
|
+
{
|
|
320
|
+
argv: ["brew", "install", "tool"],
|
|
321
|
+
escalation: "none",
|
|
322
|
+
kind: "argv",
|
|
323
|
+
name: "formula",
|
|
324
|
+
},
|
|
325
|
+
],
|
|
326
|
+
[
|
|
327
|
+
req("cask", []),
|
|
328
|
+
{ cask: "visual", via: "brew" },
|
|
329
|
+
{
|
|
330
|
+
argv: ["brew", "install", "--cask", "visual"],
|
|
331
|
+
escalation: "none",
|
|
332
|
+
kind: "argv",
|
|
333
|
+
name: "cask",
|
|
334
|
+
},
|
|
335
|
+
],
|
|
336
|
+
[
|
|
337
|
+
req("apt", []),
|
|
338
|
+
{ packages: ["one", "two"], via: "apt" },
|
|
339
|
+
{
|
|
340
|
+
argv: [
|
|
341
|
+
"apt-get",
|
|
342
|
+
"install",
|
|
343
|
+
"-y",
|
|
344
|
+
"--no-install-recommends",
|
|
345
|
+
"one",
|
|
346
|
+
"two",
|
|
347
|
+
],
|
|
348
|
+
escalation: "sudo",
|
|
349
|
+
kind: "argv",
|
|
350
|
+
name: "apt",
|
|
351
|
+
},
|
|
352
|
+
],
|
|
353
|
+
[
|
|
354
|
+
req("release", []),
|
|
355
|
+
{
|
|
356
|
+
asset: "tool-{os}-{arch}.tgz",
|
|
357
|
+
binary: "tool",
|
|
358
|
+
repo: "Owner/repo",
|
|
359
|
+
sha256: { linux_amd64: digest },
|
|
360
|
+
tag: "v1",
|
|
361
|
+
via: "release",
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
binary: "tool",
|
|
365
|
+
dest: "~/.local/bin",
|
|
366
|
+
format: "tar.gz",
|
|
367
|
+
kind: "release",
|
|
368
|
+
name: "release",
|
|
369
|
+
sha256: digest,
|
|
370
|
+
url: "https://github.com/Owner/repo/releases/download/v1/tool-linux-amd64.tgz",
|
|
371
|
+
},
|
|
372
|
+
],
|
|
373
|
+
[
|
|
374
|
+
req("bun", []),
|
|
375
|
+
{ tool: "bun", via: "bootstrap" },
|
|
376
|
+
{
|
|
377
|
+
interpreter: "bash",
|
|
378
|
+
kind: "bootstrap",
|
|
379
|
+
name: "bun",
|
|
380
|
+
prefix: "~/.bun/bin",
|
|
381
|
+
url: "https://bun.sh/install",
|
|
382
|
+
},
|
|
383
|
+
],
|
|
384
|
+
[
|
|
385
|
+
req("script", []),
|
|
386
|
+
{ args: ["--check"], script: "scripts/a.py", via: "uv-script" },
|
|
387
|
+
{
|
|
388
|
+
argv: [
|
|
389
|
+
"uv",
|
|
390
|
+
"run",
|
|
391
|
+
"--no-config",
|
|
392
|
+
"--script",
|
|
393
|
+
"/tmp/plugin/scripts/a.py",
|
|
394
|
+
"--check",
|
|
395
|
+
],
|
|
396
|
+
escalation: "none",
|
|
397
|
+
kind: "argv",
|
|
398
|
+
name: "script",
|
|
399
|
+
},
|
|
400
|
+
],
|
|
401
|
+
[
|
|
402
|
+
req("module", []),
|
|
403
|
+
{
|
|
404
|
+
args: ["install"],
|
|
405
|
+
module: "playwright",
|
|
406
|
+
root: true,
|
|
407
|
+
script: "scripts/a.py",
|
|
408
|
+
via: "uv-module",
|
|
409
|
+
},
|
|
410
|
+
{
|
|
411
|
+
args: ["install"],
|
|
412
|
+
escalation: "sudo",
|
|
413
|
+
kind: "uv-module",
|
|
414
|
+
module: "playwright",
|
|
415
|
+
name: "module",
|
|
416
|
+
script: "/tmp/plugin/scripts/a.py",
|
|
417
|
+
},
|
|
418
|
+
],
|
|
419
|
+
[
|
|
420
|
+
req("npm", []),
|
|
421
|
+
{ package: "pkg", version: "3", via: "npm" },
|
|
422
|
+
{
|
|
423
|
+
argv: ["npm", "install", "-g", "pkg@3"],
|
|
424
|
+
escalation: "none",
|
|
425
|
+
kind: "argv",
|
|
426
|
+
name: "npm",
|
|
427
|
+
},
|
|
428
|
+
],
|
|
429
|
+
[
|
|
430
|
+
req("pip", []),
|
|
431
|
+
{ package: "pkg", version: "3", via: "pip" },
|
|
432
|
+
{
|
|
433
|
+
argv: ["uv", "tool", "install", "pkg==3"],
|
|
434
|
+
escalation: "none",
|
|
435
|
+
kind: "argv",
|
|
436
|
+
name: "pip",
|
|
437
|
+
},
|
|
438
|
+
],
|
|
439
|
+
[
|
|
440
|
+
req("go", []),
|
|
441
|
+
{ package: "example/tool", via: "go" },
|
|
442
|
+
{
|
|
443
|
+
argv: ["go", "install", "example/tool@latest"],
|
|
444
|
+
escalation: "none",
|
|
445
|
+
kind: "argv",
|
|
446
|
+
name: "go",
|
|
447
|
+
},
|
|
448
|
+
],
|
|
449
|
+
];
|
|
450
|
+
for (const [requirement, option, expected] of cases) {
|
|
451
|
+
expect(renderInstall(requirement, option, e)).toEqual(expected);
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
// Spec: AS-BUILT.md §1 describeStep joins argv, prefixes sudo,
|
|
456
|
+
// renders bootstrap, and uses the OS verifier and archive/bare commands.
|
|
457
|
+
test("describeStep renders argv, sudo, bootstrap, and both release forms", () => {
|
|
458
|
+
expect(
|
|
459
|
+
describeStep(
|
|
460
|
+
{
|
|
461
|
+
argv: ["tool", "argument"],
|
|
462
|
+
escalation: "none",
|
|
463
|
+
kind: "argv",
|
|
464
|
+
name: "tool",
|
|
465
|
+
},
|
|
466
|
+
"linux",
|
|
467
|
+
),
|
|
468
|
+
).toBe("tool argument");
|
|
469
|
+
expect(
|
|
470
|
+
describeStep(
|
|
471
|
+
{
|
|
472
|
+
argv: ["apt-get", "install", "thing"],
|
|
473
|
+
escalation: "sudo",
|
|
474
|
+
kind: "argv",
|
|
475
|
+
name: "thing",
|
|
476
|
+
},
|
|
477
|
+
"linux",
|
|
478
|
+
),
|
|
479
|
+
).toBe("sudo apt-get install thing");
|
|
480
|
+
expect(
|
|
481
|
+
describeStep(
|
|
482
|
+
{
|
|
483
|
+
interpreter: "bash",
|
|
484
|
+
kind: "bootstrap",
|
|
485
|
+
name: "bun",
|
|
486
|
+
prefix: "~/.bun/bin",
|
|
487
|
+
url: TOOLCHAIN_BOOTSTRAP.bun.url,
|
|
488
|
+
},
|
|
489
|
+
"linux",
|
|
490
|
+
),
|
|
491
|
+
).toBe("curl -fsSL https://bun.sh/install | bash");
|
|
492
|
+
const archive: InstallStep = {
|
|
493
|
+
binary: "tool",
|
|
494
|
+
dest: "~/.local/bin",
|
|
495
|
+
format: "tar.gz",
|
|
496
|
+
kind: "release",
|
|
497
|
+
name: "tool",
|
|
498
|
+
sha256: digest,
|
|
499
|
+
url: "https://github.com/Owner/repo/releases/download/v1/tool.tgz",
|
|
500
|
+
};
|
|
501
|
+
const linux = describeStep(archive, "linux");
|
|
502
|
+
const darwin = describeStep(archive, "darwin");
|
|
503
|
+
expect(linux).toContain("sha256sum -c");
|
|
504
|
+
expect(linux).toContain("tar -xzf tool.tgz tool");
|
|
505
|
+
expect(darwin).toContain("shasum -a 256 -c");
|
|
506
|
+
const bare = describeStep(
|
|
507
|
+
{ ...archive, format: "binary", url: archive.url.replace(".tgz", "") },
|
|
508
|
+
"linux",
|
|
509
|
+
);
|
|
510
|
+
expect(bare).not.toContain("tar -xzf");
|
|
511
|
+
expect(bare).toContain("install -m 755 tool ~/.local/bin/tool");
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
// Spec: contract/requires-probe.d.ts releaseAsset/releaseFormat define
|
|
515
|
+
// substitutions, release-specific arch spellings, and archive detection.
|
|
516
|
+
test("releaseAsset and releaseFormat share the release naming rules", () => {
|
|
517
|
+
expect(
|
|
518
|
+
releaseAsset(
|
|
519
|
+
{
|
|
520
|
+
arch: { amd64: "x64" },
|
|
521
|
+
asset: "tool-{os}-{arch}.tar.gz",
|
|
522
|
+
},
|
|
523
|
+
"linux",
|
|
524
|
+
"amd64",
|
|
525
|
+
),
|
|
526
|
+
).toBe("tool-linux-x64.tar.gz");
|
|
527
|
+
expect(releaseFormat("tool.tgz")).toBe("tar.gz");
|
|
528
|
+
expect(releaseFormat("tool.tar.gz")).toBe("tar.gz");
|
|
529
|
+
expect(releaseFormat("tool-linux-amd64")).toBe("binary");
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
// Spec: AS-BUILT.md §2 and contract requires-probe.d.ts define the two
|
|
533
|
+
// user prefixes, shell-specific rc lines, and rc file choices.
|
|
534
|
+
test("renders user prefix PATH advice for zsh, bash, and fish", () => {
|
|
535
|
+
expect(USER_PREFIXES.map(({ dir }) => dir)).toEqual([
|
|
536
|
+
"~/.bun/bin",
|
|
537
|
+
"~/.local/bin",
|
|
538
|
+
]);
|
|
539
|
+
expect(pathRcLine("~/.local/bin", "/bin/zsh")).toBe(
|
|
540
|
+
'export PATH="$HOME/.local/bin:$PATH"',
|
|
541
|
+
);
|
|
542
|
+
expect(pathRcLine("/opt/tools", "/bin/fish")).toBe(
|
|
543
|
+
"fish_add_path /opt/tools",
|
|
544
|
+
);
|
|
545
|
+
expect(rcFile("/bin/zsh")).toBe("~/.zshrc");
|
|
546
|
+
expect(rcFile("/usr/bin/fish")).toBe("~/.config/fish/config.fish");
|
|
547
|
+
expect(rcFile("/bin/bash")).toBe("~/.bashrc");
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
// Spec: AS-BUILT.md §3, renderFix returns null when healthy and an
|
|
551
|
+
// unknown-only result is silent.
|
|
552
|
+
test("renderFix is silent for healthy and unknown-only probes", async () => {
|
|
553
|
+
const world = await makeWorld();
|
|
554
|
+
try {
|
|
555
|
+
const requirement = req("tool", [{ formula: "tool", via: "brew" }]);
|
|
556
|
+
const e = env(world);
|
|
557
|
+
expect(
|
|
558
|
+
renderFix(
|
|
559
|
+
"plugin",
|
|
560
|
+
[{ name: "tool", status: "present" }],
|
|
561
|
+
[requirement],
|
|
562
|
+
e,
|
|
563
|
+
false,
|
|
564
|
+
),
|
|
565
|
+
).toBeNull();
|
|
566
|
+
expect(
|
|
567
|
+
renderFix(
|
|
568
|
+
"plugin",
|
|
569
|
+
[
|
|
570
|
+
{
|
|
571
|
+
name: "tool",
|
|
572
|
+
reason: "slow: no answer within 3s",
|
|
573
|
+
status: "unknown",
|
|
574
|
+
},
|
|
575
|
+
],
|
|
576
|
+
[requirement],
|
|
577
|
+
e,
|
|
578
|
+
false,
|
|
579
|
+
),
|
|
580
|
+
).toBeNull();
|
|
581
|
+
} finally {
|
|
582
|
+
await removeWorld(world);
|
|
583
|
+
}
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
// Spec: AS-BUILT.md §3 specifies Fix all with magus on/off PATH,
|
|
587
|
+
// apt merging first, blocked/off-path/unknown lines, and the closing line.
|
|
588
|
+
test("renderFix builds the complete actionable banner", async () => {
|
|
589
|
+
const world = await makeWorld();
|
|
590
|
+
try {
|
|
591
|
+
await writeExecutable(join(world.bin, "apt-get"), "exit 0");
|
|
592
|
+
const released = (name: string): InstallOption => ({
|
|
593
|
+
asset: `${name}-{os}-{arch}`,
|
|
594
|
+
binary: name,
|
|
595
|
+
repo: "Owner/repo",
|
|
596
|
+
sha256: { linux_amd64: digest },
|
|
597
|
+
tag: "v1",
|
|
598
|
+
via: "release",
|
|
599
|
+
});
|
|
600
|
+
const requirements = [
|
|
601
|
+
req("one", [{ packages: ["pkg-one"], via: "apt" }]),
|
|
602
|
+
req("two", [{ packages: ["pkg-two"], via: "apt" }]),
|
|
603
|
+
req("blocked", [released("blocked")]),
|
|
604
|
+
req("local", [released("local")]),
|
|
605
|
+
req("slow", [released("slow")]),
|
|
606
|
+
];
|
|
607
|
+
const presences = [
|
|
608
|
+
{ name: "one", status: "missing" as const },
|
|
609
|
+
{ name: "two", status: "missing" as const },
|
|
610
|
+
{
|
|
611
|
+
blockedBy: "foundation",
|
|
612
|
+
name: "blocked",
|
|
613
|
+
status: "missing" as const,
|
|
614
|
+
},
|
|
615
|
+
{
|
|
616
|
+
name: "local",
|
|
617
|
+
prefix: "~/.local/bin" as const,
|
|
618
|
+
status: "off-path" as const,
|
|
619
|
+
},
|
|
620
|
+
{
|
|
621
|
+
name: "slow",
|
|
622
|
+
reason: "slow: no answer within 3s",
|
|
623
|
+
status: "unknown" as const,
|
|
624
|
+
},
|
|
625
|
+
];
|
|
626
|
+
const withoutMagus = renderFix(
|
|
627
|
+
"plugin",
|
|
628
|
+
presences,
|
|
629
|
+
requirements,
|
|
630
|
+
env(world),
|
|
631
|
+
false,
|
|
632
|
+
);
|
|
633
|
+
expect(withoutMagus).not.toBeNull();
|
|
634
|
+
if (withoutMagus === null) {
|
|
635
|
+
throw new Error("a real finding must render a banner");
|
|
636
|
+
}
|
|
637
|
+
expect(withoutMagus).toContain(
|
|
638
|
+
"Fix all: bun add -g magus-cli, then magus doctor --fix",
|
|
639
|
+
);
|
|
640
|
+
const aptLine = [
|
|
641
|
+
"one, two: sudo apt-get install -y --no-install-recommends ",
|
|
642
|
+
"pkg-one pkg-two",
|
|
643
|
+
].join("");
|
|
644
|
+
expect(withoutMagus).toContain(aptLine);
|
|
645
|
+
expect(withoutMagus).toContain("blocked (after foundation):");
|
|
646
|
+
expect(withoutMagus.indexOf(aptLine)).toBeLessThan(
|
|
647
|
+
withoutMagus.indexOf("blocked (after foundation):"),
|
|
648
|
+
);
|
|
649
|
+
expect(withoutMagus.match(/sudo apt-get/g)).toHaveLength(1);
|
|
650
|
+
expect(withoutMagus).toContain("unknown (slow) slow");
|
|
651
|
+
expect(withoutMagus).toContain(
|
|
652
|
+
"local is in ~/.local/bin, which is not on the PATH",
|
|
653
|
+
);
|
|
654
|
+
expect(withoutMagus).toContain(
|
|
655
|
+
'add export PATH="$HOME/.local/bin:$PATH" to ~/.zshrc',
|
|
656
|
+
);
|
|
657
|
+
expect(withoutMagus).toEndWith(
|
|
658
|
+
"Then restart Claude Code from a new terminal.",
|
|
659
|
+
);
|
|
660
|
+
const withMagus = renderFix(
|
|
661
|
+
"plugin",
|
|
662
|
+
presences,
|
|
663
|
+
requirements,
|
|
664
|
+
env(world),
|
|
665
|
+
true,
|
|
666
|
+
);
|
|
667
|
+
expect(withMagus).toContain("Fix all: magus doctor --fix");
|
|
668
|
+
expect(withMagus).not.toContain("bun add -g magus-cli");
|
|
669
|
+
} finally {
|
|
670
|
+
await removeWorld(world);
|
|
671
|
+
}
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
// Spec: AS-BUILT.md §3, a brew dependency with brew absent names
|
|
675
|
+
// Homebrew and https://brew.sh before any install can be selected.
|
|
676
|
+
test("renderFix explains a missing Homebrew prerequisite", async () => {
|
|
677
|
+
const world = await makeWorld();
|
|
678
|
+
try {
|
|
679
|
+
const requirement = req("tool", [{ formula: "tool", via: "brew" }]);
|
|
680
|
+
const banner = renderFix(
|
|
681
|
+
"plugin",
|
|
682
|
+
[{ name: "tool", status: "missing" }],
|
|
683
|
+
[requirement],
|
|
684
|
+
env(world),
|
|
685
|
+
false,
|
|
686
|
+
);
|
|
687
|
+
expect(banner).toContain(
|
|
688
|
+
"tool: Homebrew is missing — install it from https://brew.sh first",
|
|
689
|
+
);
|
|
690
|
+
} finally {
|
|
691
|
+
await removeWorld(world);
|
|
692
|
+
}
|
|
693
|
+
});
|
|
694
|
+
});
|