magus-cli 7.10.5 → 7.11.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 (37) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blind/plugin-deps/doctor.test.ts +2 -2
  3. package/src/__tests__/blind/plugin-deps/hook.test.ts +2 -2
  4. package/src/__tests__/blind/plugin-deps/read-only.test.ts +1 -1
  5. package/src/__tests__/blind/plugin-deps/schema.test.ts +6 -6
  6. package/src/__tests__/blind/requires-min-version/TEST-PLAN.md +54 -0
  7. package/src/__tests__/blind/requires-min-version/manifests.test.ts +69 -0
  8. package/src/__tests__/blind/requires-min-version/parse.test.ts +266 -0
  9. package/src/__tests__/blind/requires-min-version/probe.test.ts +359 -0
  10. package/src/__tests__/blind/requires-min-version/render.test.ts +252 -0
  11. package/src/__tests__/blind/requires-min-version/support.ts +83 -0
  12. package/src/__tests__/blind/requires-min-version/surfaces.test.ts +231 -0
  13. package/src/__tests__/blind/review-followups/bun-install-path.test.ts +1 -1
  14. package/src/__tests__/blind/review-followups/ledger-permissions.test.ts +1 -1
  15. package/src/__tests__/blind/review-followups/ledger-pruning.test.ts +1 -1
  16. package/src/__tests__/blind/review-followups/path-advice.test.ts +1 -1
  17. package/src/__tests__/deps-check-hook.test.ts +9 -9
  18. package/src/__tests__/deps-plan.test.ts +1 -1
  19. package/src/__tests__/deps-report.test.ts +5 -5
  20. package/src/__tests__/doctor-json.test.ts +2 -2
  21. package/src/__tests__/marketplace-refresh.test.ts +1 -1
  22. package/src/__tests__/plugin-requires.test.ts +2 -2
  23. package/src/__tests__/requires-probe.test.ts +2 -2
  24. package/src/__tests__/scope-action-deprecated.test.ts +105 -0
  25. package/src/cli/deps-view.ts +5 -3
  26. package/src/cli/router.ts +6 -1
  27. package/src/services/deps-plan.ts +38 -14
  28. package/src/services/deps-report.ts +24 -7
  29. package/src/services/doctor-bins.ts +40 -12
  30. package/src/services/plugin-manager.ts +5 -1
  31. package/src/services/resolver.ts +1 -0
  32. package/src/shared/plugin/hooks/deps-check.ts +7 -3
  33. package/src/shared/plugin/hooks/lib/requires-probe.ts +257 -33
  34. package/src/ui/adapters/cliToolsAdapter.ts +6 -1
  35. package/src/ui/renderers/cliToolRenderers.tsx +7 -1
  36. package/src/ui/screens/CliToolsScreen.tsx +8 -1
  37. package/src/ui/screens/PluginsScreen.tsx +4 -0
@@ -0,0 +1,359 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ import {
6
+ type Requirement,
7
+ probe,
8
+ } from "../../../shared/plugin/hooks/lib/requires-probe.js";
9
+ import {
10
+ DEPTHS,
11
+ type World,
12
+ fakeTool,
13
+ makeWorld,
14
+ pluginDir,
15
+ probeEnv,
16
+ removeWorld,
17
+ writeExecutable,
18
+ } from "./support";
19
+
20
+ function req(
21
+ name: string,
22
+ minVersion?: string,
23
+ check: string[] = [name, "--version"],
24
+ ): Requirement {
25
+ const r: Record<string, unknown> = {
26
+ check,
27
+ install: [{ formula: name, via: "brew" }],
28
+ name,
29
+ };
30
+ if (minVersion !== undefined) r.minVersion = minVersion;
31
+ return r as unknown as Requirement;
32
+ }
33
+
34
+ async function withWorld(body: (world: World) => Promise<void>): Promise<void> {
35
+ const world = await makeWorld();
36
+ try {
37
+ await pluginDir(world);
38
+ await body(world);
39
+ } finally {
40
+ await removeWorld(world);
41
+ }
42
+ }
43
+
44
+ for (const depth of DEPTHS) {
45
+ describe(`probe with minVersion at depth "${depth}"`, () => {
46
+ // REQ-8: exit 0, found >= minVersion → present
47
+ test("REQ-8 version equal to the floor is present", async () => {
48
+ await withWorld(async (world) => {
49
+ await fakeTool(world, "tool", { stdout: "tool 10.7.0" });
50
+ expect(
51
+ await probe([req("tool", "10.7.0")], probeEnv(world, { depth })),
52
+ ).toEqual([{ name: "tool", status: "present" }]);
53
+ });
54
+ });
55
+
56
+ test("REQ-8 version above the floor is present", async () => {
57
+ await withWorld(async (world) => {
58
+ await fakeTool(world, "tool", { stdout: "tool version 10.8.2" });
59
+ expect(
60
+ await probe([req("tool", "10.7.0")], probeEnv(world, { depth })),
61
+ ).toEqual([{ name: "tool", status: "present" }]);
62
+ });
63
+ });
64
+
65
+ // REQ-9: exit 0, found < minVersion → outdated with found and minVersion.
66
+ // At presence depth this also proves the check runs (REQ-12): a PATH
67
+ // lookup alone cannot produce "outdated".
68
+ test("REQ-9 version below the floor is outdated with found and floor", async () => {
69
+ await withWorld(async (world) => {
70
+ await fakeTool(world, "tool", { stdout: "tool 10.6.3" });
71
+ expect(
72
+ await probe([req("tool", "10.7.0")], probeEnv(world, { depth })),
73
+ ).toEqual([
74
+ {
75
+ found: "10.6.3",
76
+ minVersion: "10.7.0",
77
+ name: "tool",
78
+ status: "outdated",
79
+ },
80
+ ]);
81
+ });
82
+ });
83
+
84
+ // REQ-10: exit 0, no version in output → unknown with the stated reason.
85
+ test("REQ-10 no version in output is unknown with the argv in the reason", async () => {
86
+ await withWorld(async (world) => {
87
+ await fakeTool(world, "tool", {
88
+ stderr: "nothing on stderr either",
89
+ stdout: "tool, unversioned build",
90
+ });
91
+ expect(
92
+ await probe([req("tool", "1.0.0")], probeEnv(world, { depth })),
93
+ ).toEqual([
94
+ {
95
+ name: "tool",
96
+ reason: 'no version in "tool --version" output',
97
+ status: "unknown",
98
+ },
99
+ ]);
100
+ });
101
+ });
102
+
103
+ // REQ-11: read stderr when stdout has no version.
104
+ test("REQ-11 a version only on stderr is read (outdated)", async () => {
105
+ await withWorld(async (world) => {
106
+ await fakeTool(world, "tool", { stderr: "tool 2.0.1" });
107
+ expect(
108
+ await probe([req("tool", "3.0.0")], probeEnv(world, { depth })),
109
+ ).toEqual([
110
+ {
111
+ found: "2.0.1",
112
+ minVersion: "3.0.0",
113
+ name: "tool",
114
+ status: "outdated",
115
+ },
116
+ ]);
117
+ });
118
+ });
119
+
120
+ test("REQ-11 a version only on stderr is read (present)", async () => {
121
+ await withWorld(async (world) => {
122
+ await fakeTool(world, "tool", {
123
+ stderr: "tool 3.1.0",
124
+ stdout: "no digits here",
125
+ });
126
+ expect(
127
+ await probe([req("tool", "3.0.0")], probeEnv(world, { depth })),
128
+ ).toEqual([{ name: "tool", status: "present" }]);
129
+ });
130
+ });
131
+
132
+ test("REQ-11 stdout wins over stderr when stdout has a version", async () => {
133
+ await withWorld(async (world) => {
134
+ await fakeTool(world, "tool", { stderr: "9.9.9", stdout: "1.0.0" });
135
+ expect(
136
+ await probe([req("tool", "2.0.0")], probeEnv(world, { depth })),
137
+ ).toEqual([
138
+ {
139
+ found: "1.0.0",
140
+ minVersion: "2.0.0",
141
+ name: "tool",
142
+ status: "outdated",
143
+ },
144
+ ]);
145
+ });
146
+ });
147
+
148
+ test("REQ-11 the first X.Y.Z match in stdout is the found version", async () => {
149
+ await withWorld(async (world) => {
150
+ await fakeTool(world, "tool", {
151
+ stdout: "tool 1.2.3 (built with runtime 20.11.0)",
152
+ });
153
+ expect(
154
+ await probe([req("tool", "2.0.0")], probeEnv(world, { depth })),
155
+ ).toEqual([
156
+ {
157
+ found: "1.2.3",
158
+ minVersion: "2.0.0",
159
+ name: "tool",
160
+ status: "outdated",
161
+ },
162
+ ]);
163
+ });
164
+ });
165
+
166
+ // REQ-13: a suffix after the match is ignored.
167
+ test("REQ-13 10.7.0-beta.1 satisfies a 10.7.0 floor", async () => {
168
+ await withWorld(async (world) => {
169
+ await fakeTool(world, "tool", { stdout: "10.7.0-beta.1" });
170
+ expect(
171
+ await probe([req("tool", "10.7.0")], probeEnv(world, { depth })),
172
+ ).toEqual([{ name: "tool", status: "present" }]);
173
+ });
174
+ });
175
+
176
+ // REQ-14: comparison is numeric, part by part.
177
+ test("REQ-14 10.10.0 satisfies a 10.9.0 floor (numeric, not lexical)", async () => {
178
+ await withWorld(async (world) => {
179
+ await fakeTool(world, "tool", { stdout: "10.10.0" });
180
+ expect(
181
+ await probe([req("tool", "10.9.0")], probeEnv(world, { depth })),
182
+ ).toEqual([{ name: "tool", status: "present" }]);
183
+ });
184
+ });
185
+
186
+ test("REQ-14 10.9.0 is outdated against a 10.10.0 floor", async () => {
187
+ await withWorld(async (world) => {
188
+ await fakeTool(world, "tool", { stdout: "10.9.0" });
189
+ expect(
190
+ await probe([req("tool", "10.10.0")], probeEnv(world, { depth })),
191
+ ).toEqual([
192
+ {
193
+ found: "10.9.0",
194
+ minVersion: "10.10.0",
195
+ name: "tool",
196
+ status: "outdated",
197
+ },
198
+ ]);
199
+ });
200
+ });
201
+
202
+ test("REQ-14 9.99.99 is outdated against a 10.0.0 floor", async () => {
203
+ await withWorld(async (world) => {
204
+ await fakeTool(world, "tool", { stdout: "9.99.99" });
205
+ expect(
206
+ await probe([req("tool", "10.0.0")], probeEnv(world, { depth })),
207
+ ).toEqual([
208
+ {
209
+ found: "9.99.99",
210
+ minVersion: "10.0.0",
211
+ name: "tool",
212
+ status: "outdated",
213
+ },
214
+ ]);
215
+ });
216
+ });
217
+
218
+ // REQ-15: non-zero exit → as schema 1, missing.
219
+ test("REQ-15 a non-zero exit is missing even when a high version prints", async () => {
220
+ await withWorld(async (world) => {
221
+ await fakeTool(world, "tool", { code: 1, stdout: "tool 99.0.0" });
222
+ expect(
223
+ await probe([req("tool", "1.0.0")], probeEnv(world, { depth })),
224
+ ).toEqual([{ name: "tool", status: "missing" }]);
225
+ });
226
+ });
227
+
228
+ test("REQ-15 a tool absent from PATH is missing", async () => {
229
+ await withWorld(async (world) => {
230
+ expect(
231
+ await probe([req("tool", "1.0.0")], probeEnv(world, { depth })),
232
+ ).toEqual([{ name: "tool", status: "missing" }]);
233
+ });
234
+ });
235
+
236
+ // REQ-16: timeout → as schema 1, unknown (slow).
237
+ test("REQ-16 a slow check under a floor is unknown (slow)", async () => {
238
+ await withWorld(async (world) => {
239
+ await writeExecutable(
240
+ join(world.bin, "tool"),
241
+ "/bin/sleep 3\necho 10.0.0",
242
+ );
243
+ const result = await probe(
244
+ [req("tool", "1.0.0")],
245
+ probeEnv(world, { capMs: 200, depth }),
246
+ );
247
+ expect(result).toEqual([
248
+ {
249
+ name: "tool",
250
+ reason: expect.stringMatching(/^slow: no answer within [0-9.]+s$/),
251
+ status: "unknown",
252
+ },
253
+ ]);
254
+ });
255
+ });
256
+
257
+ // REQ-17: blockedBy ordering unchanged — a dependent of an outdated
258
+ // requirement is probed, since the tool exists and runs.
259
+ test("REQ-17 a dependent of an outdated requirement is probed, not blocked", async () => {
260
+ await withWorld(async (world) => {
261
+ await writeExecutable(
262
+ join(world.bin, "base"),
263
+ 'if [ "$1" = "--version" ]; then echo "base 0.5.0"; exit 0; fi\nexit 0',
264
+ );
265
+ const result = await probe(
266
+ [
267
+ req("base", "1.0.0"),
268
+ req("child", undefined, ["base", "run", "child"]),
269
+ ],
270
+ probeEnv(world, { depth }),
271
+ );
272
+ expect(result).toEqual([
273
+ {
274
+ found: "0.5.0",
275
+ minVersion: "1.0.0",
276
+ name: "base",
277
+ status: "outdated",
278
+ },
279
+ { name: "child", status: "present" },
280
+ ]);
281
+ });
282
+ });
283
+
284
+ // REQ-18: mixed list keeps requirement order.
285
+ test("REQ-18 results keep requirement order across floored and unfloored", async () => {
286
+ await withWorld(async (world) => {
287
+ await fakeTool(world, "old", { stdout: "1.0.0" });
288
+ await fakeTool(world, "new", { stdout: "5.0.0" });
289
+ await fakeTool(world, "plain", { stdout: "0.0.1" });
290
+ expect(
291
+ await probe(
292
+ [req("old", "2.0.0"), req("plain"), req("new", "5.0.0")],
293
+ probeEnv(world, { depth }),
294
+ ),
295
+ ).toEqual([
296
+ {
297
+ found: "1.0.0",
298
+ minVersion: "2.0.0",
299
+ name: "old",
300
+ status: "outdated",
301
+ },
302
+ { name: "plain", status: "present" },
303
+ { name: "new", status: "present" },
304
+ ]);
305
+ });
306
+ });
307
+ });
308
+ }
309
+
310
+ describe("probe without minVersion keeps schema-1 depth behaviour", () => {
311
+ // REQ-12: "A requirement without it behaves as in schema 1" — presence depth
312
+ // decides a self-named check by PATH lookup alone.
313
+ test("REQ-12 presence depth: a fake that exits 1 still reads present and is not run", async () => {
314
+ await withWorld(async (world) => {
315
+ const marker = join(world.root, "plugin", "check-ran");
316
+ await writeExecutable(
317
+ join(world.bin, "tool"),
318
+ `: > "${marker}"\necho 1.0.0\nexit 1`,
319
+ );
320
+ expect(
321
+ await probe([req("tool")], probeEnv(world, { depth: "presence" })),
322
+ ).toEqual([{ name: "tool", status: "present" }]);
323
+ expect(existsSync(marker)).toBe(false);
324
+ });
325
+ });
326
+
327
+ test("REQ-12 presence depth WITH a floor runs the check (same fake → missing)", async () => {
328
+ await withWorld(async (world) => {
329
+ const marker = join(world.root, "plugin", "check-ran");
330
+ await writeExecutable(
331
+ join(world.bin, "tool"),
332
+ `: > "${marker}"\necho 1.0.0\nexit 1`,
333
+ );
334
+ expect(
335
+ await probe(
336
+ [req("tool", "1.0.0")],
337
+ probeEnv(world, { depth: "presence" }),
338
+ ),
339
+ ).toEqual([{ name: "tool", status: "missing" }]);
340
+ expect(existsSync(marker)).toBe(true);
341
+ });
342
+ });
343
+
344
+ test("REQ-12 full depth without a floor: exit 1 is missing, unversioned exit 0 is present", async () => {
345
+ await withWorld(async (world) => {
346
+ await fakeTool(world, "bad", { code: 1, stdout: "1.0.0" });
347
+ await fakeTool(world, "quiet", { stdout: "no version at all" });
348
+ expect(
349
+ await probe(
350
+ [req("bad"), req("quiet")],
351
+ probeEnv(world, { depth: "full" }),
352
+ ),
353
+ ).toEqual([
354
+ { name: "bad", status: "missing" },
355
+ { name: "quiet", status: "present" },
356
+ ]);
357
+ });
358
+ });
359
+ });
@@ -0,0 +1,252 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { join } from "node:path";
3
+
4
+ import {
5
+ type InstallOption,
6
+ type Presence,
7
+ type Requirement,
8
+ describeStep,
9
+ renderFix,
10
+ renderInstall,
11
+ } from "../../../shared/plugin/hooks/lib/requires-probe.js";
12
+ import {
13
+ DIGEST,
14
+ makeWorld,
15
+ probeEnv,
16
+ removeWorld,
17
+ writeExecutable,
18
+ } from "./support";
19
+
20
+ type Reason = "missing" | "outdated";
21
+ // The schema-2 renderInstall takes the reason as a fourth argument (spec:
22
+ // "renderInstall takes the reason ("missing" | "outdated")").
23
+ const render = renderInstall as unknown as (
24
+ r: Requirement,
25
+ o: InstallOption,
26
+ e: { os: "darwin" | "linux"; arch: "amd64" | "arm64"; pluginRoot: string },
27
+ reason: Reason,
28
+ ) => ReturnType<typeof renderInstall>;
29
+
30
+ const e = {
31
+ arch: "arm64" as const,
32
+ os: "darwin" as const,
33
+ pluginRoot: "/tmp/plugin",
34
+ };
35
+
36
+ function req(
37
+ name: string,
38
+ minVersion?: string,
39
+ install: InstallOption[] = [],
40
+ ): Requirement {
41
+ const r: Record<string, unknown> = {
42
+ check: [name, "--version"],
43
+ install,
44
+ name,
45
+ };
46
+ if (minVersion !== undefined) r.minVersion = minVersion;
47
+ return r as unknown as Requirement;
48
+ }
49
+
50
+ function argvStep(name: string, argv: string[]) {
51
+ return { argv, escalation: "none", kind: "argv", name };
52
+ }
53
+
54
+ describe("renderInstall with a reason", () => {
55
+ const cases: Array<[string, InstallOption, Reason, string[]]> = [
56
+ // REQ-19: bun
57
+ [
58
+ "bun unpinned, missing",
59
+ { package: "pkg", via: "bun" },
60
+ "missing",
61
+ ["bun", "add", "-g", "pkg"],
62
+ ],
63
+ [
64
+ "bun pinned, missing",
65
+ { package: "pkg", version: "2.1.0", via: "bun" },
66
+ "missing",
67
+ ["bun", "add", "-g", "pkg@2.1.0"],
68
+ ],
69
+ [
70
+ "bun unpinned, outdated",
71
+ { package: "pkg", via: "bun" },
72
+ "outdated",
73
+ ["bun", "add", "-g", "pkg@latest"],
74
+ ],
75
+ [
76
+ "bun pinned, outdated",
77
+ { package: "pkg", version: "2.1.0", via: "bun" },
78
+ "outdated",
79
+ ["bun", "add", "-g", "pkg@2.1.0"],
80
+ ],
81
+ [
82
+ "bun scoped unpinned, outdated",
83
+ { package: "@scope/pkg", via: "bun" },
84
+ "outdated",
85
+ ["bun", "add", "-g", "@scope/pkg@latest"],
86
+ ],
87
+ // REQ-20: brew formula
88
+ [
89
+ "brew formula, missing",
90
+ { formula: "owner/tap/tool", via: "brew" },
91
+ "missing",
92
+ ["brew", "install", "owner/tap/tool"],
93
+ ],
94
+ [
95
+ "brew formula, outdated",
96
+ { formula: "owner/tap/tool", via: "brew" },
97
+ "outdated",
98
+ ["brew", "upgrade", "owner/tap/tool"],
99
+ ],
100
+ // REQ-21: brew cask
101
+ [
102
+ "brew cask, missing",
103
+ { cask: "visual", via: "brew" },
104
+ "missing",
105
+ ["brew", "install", "--cask", "visual"],
106
+ ],
107
+ [
108
+ "brew cask, outdated",
109
+ { cask: "visual", via: "brew" },
110
+ "outdated",
111
+ ["brew", "upgrade", "--cask", "visual"],
112
+ ],
113
+ ];
114
+ for (const [label, option, reason, argv] of cases) {
115
+ test(`REQ-19..21 ${label} → ${argv.join(" ")}`, () => {
116
+ expect(render(req("tool"), option, e, reason)).toEqual(
117
+ argvStep("tool", argv),
118
+ );
119
+ });
120
+ }
121
+
122
+ // REQ-22: every other via is unchanged between reasons.
123
+ const others: Array<[string, InstallOption]> = [
124
+ ["apt", { packages: ["one"], via: "apt" }],
125
+ [
126
+ "release",
127
+ {
128
+ asset: "tool-{os}-{arch}",
129
+ binary: "tool",
130
+ repo: "Owner/tool",
131
+ sha256: { darwin_arm64: DIGEST },
132
+ tag: "v2.0.0",
133
+ via: "release",
134
+ },
135
+ ],
136
+ ["bootstrap", { tool: "uv", via: "bootstrap" }],
137
+ ["uv-script", { args: ["--x"], script: "s.py", via: "uv-script" }],
138
+ ["npm", { package: "pkg", via: "npm" }],
139
+ ];
140
+ for (const [label, option] of others) {
141
+ test(`REQ-22 ${label}: outdated renders the same step as missing`, () => {
142
+ const r = req("tool");
143
+ expect(render(r, option, e, "outdated")).toEqual(
144
+ render(r, option, e, "missing"),
145
+ );
146
+ });
147
+ }
148
+
149
+ test("REQ-19 describeStep of an outdated bun step is the pasteable upgrade line", () => {
150
+ const step = render(
151
+ req("tool"),
152
+ { package: "claudish", via: "bun" },
153
+ e,
154
+ "outdated",
155
+ );
156
+ expect(describeStep(step, "darwin")).toBe("bun add -g claudish@latest");
157
+ });
158
+ });
159
+
160
+ describe("renderFix with outdated presences", () => {
161
+ // REQ-23: banner head names found and floor; outdated gets a by-hand line.
162
+ test("REQ-23 an outdated dep produces a banner naming found and floor", async () => {
163
+ const world = await makeWorld();
164
+ try {
165
+ await writeExecutable(join(world.bin, "bun"), "echo 1.4.0");
166
+ const claudish = req("claudish", "10.7.0", [
167
+ { package: "claudish", via: "bun" },
168
+ ]);
169
+ const presences = [
170
+ {
171
+ found: "10.6.2",
172
+ minVersion: "10.7.0",
173
+ name: "claudish",
174
+ status: "outdated",
175
+ },
176
+ ] as unknown as Presence[];
177
+ const banner = renderFix(
178
+ "claudish",
179
+ presences,
180
+ [claudish],
181
+ probeEnv(world),
182
+ true,
183
+ );
184
+ expect(banner).not.toBeNull();
185
+ if (banner === null) throw new Error("outdated must render a banner");
186
+ expect(banner).toContain("outdated claudish 10.6.2 (needs 10.7.0)");
187
+ expect(banner).toContain("bun add -g claudish@latest");
188
+ } finally {
189
+ await removeWorld(world);
190
+ }
191
+ });
192
+
193
+ test("REQ-23 an outdated brew dep's by-hand line upgrades rather than installs", async () => {
194
+ const world = await makeWorld();
195
+ try {
196
+ await writeExecutable(join(world.bin, "brew"), "exit 0");
197
+ const tool = req("tool", "2.0.0", [
198
+ { formula: "owner/tap/tool", via: "brew" },
199
+ ]);
200
+ const presences = [
201
+ {
202
+ found: "1.9.9",
203
+ minVersion: "2.0.0",
204
+ name: "tool",
205
+ status: "outdated",
206
+ },
207
+ ] as unknown as Presence[];
208
+ const banner = renderFix(
209
+ "p",
210
+ presences,
211
+ [tool],
212
+ probeEnv(world, { os: "darwin", arch: "arm64" }),
213
+ true,
214
+ );
215
+ expect(banner).toContain("outdated tool 1.9.9 (needs 2.0.0)");
216
+ expect(banner).toContain("brew upgrade owner/tap/tool");
217
+ expect(banner).not.toContain("brew install owner/tap/tool");
218
+ } finally {
219
+ await removeWorld(world);
220
+ }
221
+ });
222
+
223
+ // REQ-24: "A healthy machine still prints nothing."
224
+ test("REQ-24 a healthy set with a floor returns null", async () => {
225
+ const world = await makeWorld();
226
+ try {
227
+ const claudish = req("claudish", "10.7.0", [
228
+ { package: "claudish", via: "bun" },
229
+ ]);
230
+ expect(
231
+ renderFix(
232
+ "claudish",
233
+ [{ name: "claudish", status: "present" }],
234
+ [claudish],
235
+ probeEnv(world),
236
+ true,
237
+ ),
238
+ ).toBeNull();
239
+ expect(
240
+ renderFix(
241
+ "claudish",
242
+ [{ name: "claudish", status: "present" }],
243
+ [claudish],
244
+ probeEnv(world),
245
+ false,
246
+ ),
247
+ ).toBeNull();
248
+ } finally {
249
+ await removeWorld(world);
250
+ }
251
+ });
252
+ });
@@ -0,0 +1,83 @@
1
+ // Shared testdata builders for the requires schema-2 (minVersion) blind suite.
2
+ // World/process helpers are reused from the schema-1 blind suite.
3
+ import { mkdir } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+
6
+ import type { ProbeEnv } from "../../../shared/plugin/hooks/lib/requires-probe.js";
7
+ import { type World, writeExecutable } from "../plugin-deps/helpers";
8
+
9
+ export * from "../plugin-deps/helpers";
10
+
11
+ export const DIGEST = "c".repeat(64);
12
+
13
+ export type Raw = Record<string, unknown>;
14
+
15
+ export function requirement(
16
+ name: string,
17
+ install: Raw[],
18
+ extra: Raw = {},
19
+ check: string[] = [name, "--version"],
20
+ ): Raw {
21
+ return { check, install, name, ...extra };
22
+ }
23
+
24
+ export function requires(bin: Raw[], schema: unknown = 2): Raw {
25
+ return { bin, schema };
26
+ }
27
+
28
+ export function manifest(bin: Raw[], schema: unknown = 2): Raw {
29
+ return { requires: requires(bin, schema) };
30
+ }
31
+
32
+ export function releaseOption(tag: string): Raw {
33
+ return {
34
+ asset: "tool-{os}-{arch}",
35
+ binary: "tool",
36
+ repo: "Owner/tool",
37
+ sha256: { linux_amd64: DIGEST },
38
+ tag,
39
+ via: "release",
40
+ };
41
+ }
42
+
43
+ export function probeEnv(
44
+ world: World,
45
+ patch: Partial<ProbeEnv> = {},
46
+ ): ProbeEnv {
47
+ return {
48
+ arch: "amd64",
49
+ capMs: 5_000,
50
+ depth: "full",
51
+ home: world.home,
52
+ os: "linux",
53
+ path: world.bin,
54
+ pluginRoot: join(world.root, "plugin"),
55
+ userShell: "/bin/zsh",
56
+ ...patch,
57
+ };
58
+ }
59
+
60
+ export async function pluginDir(world: World): Promise<void> {
61
+ await mkdir(join(world.root, "plugin"), { recursive: true });
62
+ }
63
+
64
+ /**
65
+ * A fake executable that prints `stdout` (and optionally `stderr`) and exits
66
+ * with `code`. Never the real tool.
67
+ */
68
+ export async function fakeTool(
69
+ world: World,
70
+ name: string,
71
+ opts: { stdout?: string; stderr?: string; code?: number } = {},
72
+ ): Promise<string> {
73
+ const lines: string[] = [];
74
+ if (opts.stdout !== undefined) lines.push(`printf '%s\\n' '${opts.stdout}'`);
75
+ if (opts.stderr !== undefined)
76
+ lines.push(`printf '%s\\n' '${opts.stderr}' >&2`);
77
+ lines.push(`exit ${opts.code ?? 0}`);
78
+ const path = join(world.bin, name);
79
+ await writeExecutable(path, lines.join("\n"));
80
+ return path;
81
+ }
82
+
83
+ export const DEPTHS = ["presence", "full"] as const;