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
@@ -0,0 +1,96 @@
1
+ import { expect, test } from "bun:test";
2
+ import { readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+
5
+ import {
6
+ makePlugin,
7
+ makeWorld,
8
+ removeWorld,
9
+ runDoctor,
10
+ writeExecutable,
11
+ writeJson,
12
+ } from "./helpers";
13
+
14
+ // Spec: AS-BUILT.md §5, doctor, doctor --json, and doctor --fix --yes
15
+ // leave all five Claude Code files byte-identical even when a marketplace
16
+ // lists a plugin whose source directory is missing.
17
+ test("all doctor modes leave Claude Code marketplace state byte-identical", async () => {
18
+ const world = await makeWorld();
19
+ try {
20
+ const claude = join(world.home, ".claude");
21
+ const clone = join(claude, "plugins", "marketplaces", "test");
22
+ const plugin = await makePlugin(
23
+ world,
24
+ "present",
25
+ {
26
+ bin: [
27
+ {
28
+ check: ["present-tool", "--version"],
29
+ install: [{ formula: "present-tool", via: "brew" }],
30
+ name: "present-tool",
31
+ },
32
+ ],
33
+ schema: 1,
34
+ },
35
+ join(clone, "plugins"),
36
+ );
37
+ const marketplace = join(clone, ".claude-plugin", "marketplace.json");
38
+ const pluginManifest = join(plugin, ".claude-plugin", "plugin.json");
39
+ const known = join(claude, "plugins", "known_marketplaces.json");
40
+ const installed = join(claude, "plugins", "installed_plugins.json");
41
+ const settings = join(claude, "settings.json");
42
+ await writeJson(marketplace, {
43
+ name: "test",
44
+ plugins: [
45
+ { name: "present", source: "./plugins/present", version: "1.0.0" },
46
+ { name: "missing", source: "./plugins/missing", version: "1.0.0" },
47
+ ],
48
+ });
49
+ await writeJson(known, {
50
+ test: {
51
+ installLocation: clone,
52
+ lastUpdated: "2026-01-01T00:00:00.000Z",
53
+ source: { repo: "Example/test", source: "github" },
54
+ },
55
+ });
56
+ await writeJson(installed, {
57
+ plugins: {
58
+ "present@test": [
59
+ {
60
+ installPath: plugin,
61
+ installedAt: "2026-01-01T00:00:00.000Z",
62
+ lastUpdated: "2026-01-01T00:00:00.000Z",
63
+ scope: "user",
64
+ version: "1.0.0",
65
+ },
66
+ ],
67
+ },
68
+ version: 2,
69
+ });
70
+ await writeJson(settings, { enabledPlugins: { "present@test": true } });
71
+ await writeExecutable(join(world.bin, "present-tool"), "exit 0");
72
+ const protectedFiles = [
73
+ marketplace,
74
+ pluginManifest,
75
+ known,
76
+ installed,
77
+ settings,
78
+ ];
79
+ const before = await Promise.all(
80
+ protectedFiles.map((path) => readFile(path)),
81
+ );
82
+ for (const args of [[], ["--json"], ["--fix", "--yes"]]) {
83
+ const result = await runDoctor(world, args);
84
+ expect(result.timedOut).toBe(false);
85
+ expect(result.exitCode).not.toBeNull();
86
+ const after = await Promise.all(
87
+ protectedFiles.map((path) => readFile(path)),
88
+ );
89
+ for (const [index, bytes] of after.entries()) {
90
+ expect(bytes.equals(before[index])).toBe(true);
91
+ }
92
+ }
93
+ } finally {
94
+ await removeWorld(world);
95
+ }
96
+ });
@@ -0,0 +1,390 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { parseRequires } from "../../../shared/plugin/hooks/lib/requires-probe.js";
4
+
5
+ const digest = "a".repeat(64);
6
+
7
+ function requirement(
8
+ name: string,
9
+ install: Record<string, unknown>[],
10
+ check = [name, "--version"],
11
+ ): Record<string, unknown> {
12
+ return { check, install, name };
13
+ }
14
+
15
+ function manifest(bin: Record<string, unknown>[]): Record<string, unknown> {
16
+ return { requires: { bin, schema: 1 } };
17
+ }
18
+
19
+ function invalidText(value: unknown): string {
20
+ const result = parseRequires(value);
21
+ expect(result.ok).toBe(false);
22
+ if (result.ok) {
23
+ throw new Error("testdata unexpectedly parsed");
24
+ }
25
+ expect(result.kind).toBe("invalid");
26
+ return result.errors.join("\n");
27
+ }
28
+
29
+ describe("requires schema 1", () => {
30
+ // Spec: AS-BUILT.md §1, “brew takes EXACTLY ONE ...” through the
31
+ // renderInstall table define the seven manifest installer kinds.
32
+ test("accepts a complete declaration of every manifest via", () => {
33
+ const value = manifest([
34
+ requirement("bun", [{ tool: "bun", via: "bootstrap" }]),
35
+ requirement("uv", [{ tool: "uv", via: "bootstrap" }]),
36
+ requirement("bun-tool", [
37
+ { package: "@scope/tool", version: "1.2.3", via: "bun" },
38
+ ]),
39
+ requirement("formula-tool", [
40
+ { formula: "owner/tap/tool@2", via: "brew" },
41
+ ]),
42
+ requirement("cask-tool", [{ cask: "visual-tool", via: "brew" }]),
43
+ requirement("apt-tool", [
44
+ { packages: ["libfoo2", "foo+bar"], via: "apt" },
45
+ ]),
46
+ requirement("release-tool", [
47
+ {
48
+ arch: { amd64: "x64", arm64: "aarch64" },
49
+ asset: "tool-{os}-{arch}.tgz",
50
+ binary: "tool.bin",
51
+ repo: "Owner/repo.name",
52
+ sha256: { darwin_amd64: digest },
53
+ tag: "v1.2.3",
54
+ via: "release",
55
+ },
56
+ ]),
57
+ requirement("uv-script-tool", [
58
+ { args: ["--check"], script: "scripts/tool.py", via: "uv-script" },
59
+ ]),
60
+ requirement("uv-module-tool", [
61
+ {
62
+ args: ["install", "thing"],
63
+ module: "package.module",
64
+ root: true,
65
+ script: "scripts/tool.py",
66
+ via: "uv-module",
67
+ },
68
+ ]),
69
+ ]);
70
+ const result = parseRequires(value);
71
+ expect(result.ok).toBe(true);
72
+ if (result.ok) {
73
+ expect(result.requirements).toHaveLength(9);
74
+ }
75
+ });
76
+
77
+ // Spec: AS-BUILT.md §1, “Unknown keys anywhere inside requires (top
78
+ // level, a requirement, an option) ... error naming the key.”
79
+ test("rejects and locates unknown keys at all three schema levels", () => {
80
+ const top = invalidText({
81
+ requires: { bin: [], mysteryTop: true, schema: 1 },
82
+ });
83
+ const req = invalidText(
84
+ manifest([
85
+ {
86
+ ...requirement("tool", [{ formula: "tool", via: "brew" }]),
87
+ mysteryRequirement: true,
88
+ },
89
+ ]),
90
+ );
91
+ const option = invalidText(
92
+ manifest([
93
+ requirement("tool", [
94
+ { formula: "tool", mysteryOption: true, via: "brew" },
95
+ ]),
96
+ ]),
97
+ );
98
+ expect(top).toContain("mysteryTop");
99
+ expect(req).toContain("requires.bin[0]");
100
+ expect(req).toContain("mysteryRequirement");
101
+ expect(option).toContain("requires.bin[0]");
102
+ expect(option).toContain("mysteryOption");
103
+ });
104
+
105
+ // Spec: AS-BUILT.md §1, “schema greater than 1 ... smaller ...;
106
+ // missing/non-number → invalid” and “no requires key ... empty.”
107
+ test("classifies schema versions and treats no requires as empty", () => {
108
+ expect(parseRequires({})).toEqual({ ok: true, requirements: [] });
109
+ for (const schema of [undefined, "1", null]) {
110
+ expect(parseRequires({ requires: { bin: [], schema } })).toMatchObject({
111
+ kind: "invalid",
112
+ ok: false,
113
+ });
114
+ }
115
+ expect(parseRequires({ requires: { bin: [], schema: 2 } })).toMatchObject({
116
+ kind: "newer-schema",
117
+ ok: false,
118
+ });
119
+ expect(parseRequires({ requires: { bin: [], schema: 0 } })).toMatchObject({
120
+ kind: "older-schema",
121
+ ok: false,
122
+ });
123
+ });
124
+
125
+ // Spec: arch-7.1-7.2-schema-probe-hook.md §7.1 Schema Rules,
126
+ // “name ... ^[a-z0-9][a-z0-9._-]*$” and check is non-empty argv.
127
+ test("enforces requirement name and check argv grammars", () => {
128
+ const good = parseRequires(
129
+ manifest([
130
+ requirement("a.good_name-2", [{ formula: "good", via: "brew" }]),
131
+ ]),
132
+ );
133
+ expect(good.ok).toBe(true);
134
+ for (const name of ["Upper", "-leading", "has space", ""]) {
135
+ expect(invalidText(manifest([requirement(name, [])]))).toContain(
136
+ "requires.bin[0]",
137
+ );
138
+ }
139
+ for (const check of [[], ["tool", 7]]) {
140
+ expect(
141
+ invalidText(manifest([requirement("tool", [], check as string[])])),
142
+ ).toContain("requires.bin[0]");
143
+ }
144
+ });
145
+
146
+ // Spec: AS-BUILT.md §1, “brew takes EXACTLY ONE of formula | cask.”
147
+ // Design §7.1 gives the formula grammar.
148
+ test("enforces brew formula grammar and formula XOR cask", () => {
149
+ for (const option of [
150
+ { via: "brew" },
151
+ { cask: "tool", formula: "tool", via: "brew" },
152
+ { formula: "-tool", via: "brew" },
153
+ ]) {
154
+ expect(invalidText(manifest([requirement("tool", [option])]))).toContain(
155
+ "requires.bin[0]",
156
+ );
157
+ }
158
+ for (const option of [
159
+ { formula: "owner/tap/tool+v2@1", via: "brew" },
160
+ { cask: "visual-tool", via: "brew" },
161
+ ]) {
162
+ expect(parseRequires(manifest([requirement("tool", [option])])).ok).toBe(
163
+ true,
164
+ );
165
+ }
166
+ });
167
+
168
+ // Spec: arch-7.1-7.2-schema-probe-hook.md §7.1 Schema Rules,
169
+ // apt package names match ^[a-z0-9][a-z0-9+.-]+$ and cannot be options.
170
+ test("enforces apt package grammar and a non-empty package list", () => {
171
+ for (const packages of [[], ["-option"], ["UPPER"], ["x"]]) {
172
+ expect(
173
+ invalidText(
174
+ manifest([requirement("tool", [{ packages, via: "apt" }])]),
175
+ ),
176
+ ).toContain("requires.bin[0]");
177
+ }
178
+ expect(
179
+ parseRequires(
180
+ manifest([
181
+ requirement("tool", [
182
+ { packages: ["libx2", "x+extra", "x.dev"], via: "apt" },
183
+ ]),
184
+ ]),
185
+ ).ok,
186
+ ).toBe(true);
187
+ });
188
+
189
+ // Spec: arch-7.1-7.2-schema-probe-hook.md §7.1 Schema Rules defines
190
+ // release repo, tag, and binary regexes and a fixed GitHub host.
191
+ test("enforces release repo, tag, and binary grammars", () => {
192
+ const base = {
193
+ asset: "tool-{os}-{arch}",
194
+ binary: "tool",
195
+ repo: "Owner/repo",
196
+ sha256: { linux_amd64: digest },
197
+ tag: "v1.0",
198
+ via: "release",
199
+ };
200
+ for (const patch of [
201
+ { repo: "one-part" },
202
+ { repo: "Owner/repo/extra" },
203
+ { tag: "bad tag" },
204
+ { tag: "-leading" },
205
+ { binary: "bad/path" },
206
+ { binary: "-leading" },
207
+ ]) {
208
+ expect(
209
+ invalidText(manifest([requirement("tool", [{ ...base, ...patch }])])),
210
+ ).toContain("requires.bin[0]");
211
+ }
212
+ expect(parseRequires(manifest([requirement("tool", [base])])).ok).toBe(
213
+ true,
214
+ );
215
+ });
216
+
217
+ // Spec: AS-BUILT.md §1 says only {os}/{arch} are asset substitutions,
218
+ // tar.gz/tgz are archives, and the listed other archive kinds are refused.
219
+ test("accepts bare and tar-gzip assets but refuses unsafe templates and archives", () => {
220
+ const option = {
221
+ binary: "tool",
222
+ repo: "Owner/repo",
223
+ sha256: { linux_amd64: digest },
224
+ tag: "v1",
225
+ via: "release",
226
+ };
227
+ for (const asset of [
228
+ "tool-{os}-{arch}",
229
+ "tool-{os}-{arch}.tar.gz",
230
+ "tool-{os}-{arch}.tgz",
231
+ ]) {
232
+ expect(
233
+ parseRequires(manifest([requirement("tool", [{ ...option, asset }])]))
234
+ .ok,
235
+ ).toBe(true);
236
+ }
237
+ for (const asset of [
238
+ "tool-{platform}",
239
+ "tool.zip",
240
+ "tool.tar",
241
+ "tool.tar.xz",
242
+ "tool.txz",
243
+ "tool.tar.bz2",
244
+ "tool.tbz",
245
+ "tool.tbz2",
246
+ "tool.gz",
247
+ "tool.xz",
248
+ "tool.bz2",
249
+ "tool.7z",
250
+ ]) {
251
+ expect(
252
+ invalidText(manifest([requirement("tool", [{ ...option, asset }])])),
253
+ ).toContain("requires.bin[0]");
254
+ }
255
+ });
256
+
257
+ // Spec: AS-BUILT.md §1 limits arch keys and sha256 platform keys, and
258
+ // requires each digest to be 64 lowercase hexadecimal characters.
259
+ test("validates release arch maps and pinned digests", () => {
260
+ const base = {
261
+ asset: "tool-{os}-{arch}",
262
+ binary: "tool",
263
+ repo: "Owner/repo",
264
+ tag: "v1",
265
+ via: "release",
266
+ };
267
+ expect(
268
+ parseRequires(
269
+ manifest([
270
+ requirement("tool", [
271
+ {
272
+ ...base,
273
+ arch: { amd64: "x64", arm64: "aarch64" },
274
+ sha256: { darwin_arm64: digest },
275
+ },
276
+ ]),
277
+ ]),
278
+ ).ok,
279
+ ).toBe(true);
280
+ for (const option of [
281
+ { ...base, arch: { riscv64: "riscv" }, sha256: {} },
282
+ { ...base, sha256: { windows_amd64: digest } },
283
+ { ...base, sha256: { linux_amd64: "A".repeat(64) } },
284
+ { ...base, sha256: { linux_amd64: "a".repeat(63) } },
285
+ ]) {
286
+ expect(invalidText(manifest([requirement("tool", [option])]))).toContain(
287
+ "requires.bin[0]",
288
+ );
289
+ }
290
+ });
291
+
292
+ // Spec: arch-7.1-7.2-schema-probe-hook.md §7.1 Schema Rules says uv
293
+ // scripts are relative and inside the root, and defines the module regex.
294
+ test("enforces uv script paths and module names", () => {
295
+ const uv = requirement("uv", [{ tool: "uv", via: "bootstrap" }]);
296
+ for (const option of [
297
+ { args: [], script: "/abs/tool.py", via: "uv-script" },
298
+ { args: [], script: "../tool.py", via: "uv-script" },
299
+ {
300
+ args: [],
301
+ module: "bad-module",
302
+ script: "scripts/tool.py",
303
+ via: "uv-module",
304
+ },
305
+ {
306
+ args: [],
307
+ module: "9module",
308
+ script: "scripts/tool.py",
309
+ via: "uv-module",
310
+ },
311
+ ]) {
312
+ expect(
313
+ invalidText(manifest([uv, requirement("tool", [option])])),
314
+ ).toContain("requires.bin[1]");
315
+ }
316
+ expect(
317
+ parseRequires(
318
+ manifest([
319
+ uv,
320
+ requirement("tool", [
321
+ {
322
+ args: ["--ok"],
323
+ module: "good_module.child2",
324
+ script: "scripts/tool.py",
325
+ via: "uv-module",
326
+ },
327
+ ]),
328
+ ]),
329
+ ).ok,
330
+ ).toBe(true);
331
+ });
332
+
333
+ // Spec: AS-BUILT.md §1, bun and both uv options need their toolchain
334
+ // declared beside them in the same manifest.
335
+ test("requires bun and uv toolchains to be declared beside consumers", () => {
336
+ for (const option of [
337
+ { package: "pkg", via: "bun" },
338
+ { args: [], script: "tool.py", via: "uv-script" },
339
+ {
340
+ args: [],
341
+ module: "tool",
342
+ script: "tool.py",
343
+ via: "uv-module",
344
+ },
345
+ ]) {
346
+ const error = invalidText(manifest([requirement("consumer", [option])]));
347
+ expect(error).toMatch(/bun|uv/);
348
+ }
349
+ });
350
+
351
+ // Spec: AS-BUILT.md §1, “bootstrap.tool must be a key ... AND equal
352
+ // the requirement's name”; a URL is an unknown key.
353
+ test("restricts bootstrap to its self-named built-in tool", () => {
354
+ expect(
355
+ parseRequires(
356
+ manifest([requirement("bun", [{ tool: "bun", via: "bootstrap" }])]),
357
+ ).ok,
358
+ ).toBe(true);
359
+ for (const option of [
360
+ { tool: "unknown", via: "bootstrap" },
361
+ { tool: "uv", via: "bootstrap" },
362
+ {
363
+ tool: "bun",
364
+ url: "https://example.invalid/install",
365
+ via: "bootstrap",
366
+ },
367
+ ]) {
368
+ expect(invalidText(manifest([requirement("bun", [option])]))).toContain(
369
+ "requires.bin[0]",
370
+ );
371
+ }
372
+ });
373
+
374
+ // Spec: AS-BUILT.md §1, profile-only npm, pip, and go are refused in
375
+ // plugin.json; arch-2-decisions.md D2 says the installer set is closed.
376
+ test("refuses every profile-only installer kind", () => {
377
+ for (const via of ["npm", "pip", "go"]) {
378
+ const error = invalidText(
379
+ manifest([requirement("tool", [{ package: "tool", via }])]),
380
+ );
381
+ expect(error).toContain(via);
382
+ expect(error).toContain("not allowed");
383
+ }
384
+ expect(
385
+ invalidText(
386
+ manifest([requirement("tool", [{ package: "tool", via: "cargo" }])]),
387
+ ),
388
+ ).toContain("cargo");
389
+ });
390
+ });
@@ -0,0 +1,57 @@
1
+ import { expect, test } from "bun:test";
2
+ import { join } from "node:path";
3
+
4
+ import {
5
+ hookCommand,
6
+ makeHookPlugin,
7
+ makeShellPath,
8
+ makeWorld,
9
+ pinnedEnv,
10
+ removeWorld,
11
+ runChild,
12
+ sessionInput,
13
+ writeExecutable,
14
+ } from "../plugin-deps/helpers";
15
+
16
+ test("a Bun under BUN_INSTALL is reported as installed but absent from Claude Code's PATH", async () => {
17
+ const world = await makeWorld();
18
+ try {
19
+ const plugin = await makeHookPlugin(world, "bun-install", {
20
+ bin: [],
21
+ schema: 1,
22
+ });
23
+ const shellPath = await makeShellPath(world);
24
+ const bunInstall = join(world.root, "bun-install");
25
+ const bunBin = join(bunInstall, "bin");
26
+ await writeExecutable(join(bunBin, "bun"), "exit 0");
27
+
28
+ const result = await runChild(
29
+ join(shellPath, "sh"),
30
+ ["-c", hookCommand("bun-install")],
31
+ {
32
+ cwd: world.project,
33
+ env: pinnedEnv(world, [shellPath], {
34
+ BUN_INSTALL: bunInstall,
35
+ CLAUDE_PLUGIN_ROOT: plugin,
36
+ SHELL: join(shellPath, "sh"),
37
+ }),
38
+ input: sessionInput("bun-install-session"),
39
+ timeoutMs: 9_500,
40
+ },
41
+ );
42
+ const message = JSON.parse(result.stdout).systemMessage as string;
43
+
44
+ expect({
45
+ describesInstalledBun:
46
+ message.includes("Bun is installed") && message.includes(bunBin),
47
+ explainsOffPath: message.includes(
48
+ "not on the PATH Claude Code started with",
49
+ ),
50
+ }).toEqual({
51
+ describesInstalledBun: true,
52
+ explainsOffPath: true,
53
+ });
54
+ } finally {
55
+ await removeWorld(world);
56
+ }
57
+ });
@@ -0,0 +1,94 @@
1
+ import { expect, test } from "bun:test";
2
+ import { writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+
5
+ import {
6
+ type ApplyIO,
7
+ type ApplyOptions,
8
+ DepsLockBusy,
9
+ type DepsPlan,
10
+ applyDepsPlan,
11
+ } from "../../../services/deps-plan.js";
12
+ import type { RunResult } from "../../../utils/run.js";
13
+ import { makeWorld, removeWorld } from "../plugin-deps/helpers.js";
14
+
15
+ function successfulRun(): Promise<RunResult> {
16
+ return Promise.resolve({
17
+ durationMs: 0,
18
+ exitCode: 0,
19
+ outputTail: "",
20
+ signal: null,
21
+ timedOut: false,
22
+ });
23
+ }
24
+
25
+ function plan(): DepsPlan {
26
+ return {
27
+ pathAdvice: [],
28
+ steps: [
29
+ {
30
+ installs: [
31
+ {
32
+ pluginRoot: "",
33
+ requirement: {
34
+ check: ["widget", "--version"],
35
+ install: [],
36
+ name: "widget",
37
+ },
38
+ },
39
+ ],
40
+ needs: [],
41
+ requiredBy: ["testdata@test"],
42
+ step: {
43
+ argv: ["installer", "widget"],
44
+ escalation: "none",
45
+ kind: "argv",
46
+ name: "widget",
47
+ },
48
+ },
49
+ ],
50
+ unfixable: [],
51
+ };
52
+ }
53
+
54
+ function options(
55
+ world: Awaited<ReturnType<typeof makeWorld>>,
56
+ lockPath: string,
57
+ ): ApplyOptions {
58
+ const io: Partial<ApplyIO> = {
59
+ alive: () => false,
60
+ run: successfulRun,
61
+ uid: () => 1000,
62
+ verify: async ({ requirement }) => ({
63
+ name: requirement.name,
64
+ status: "present",
65
+ }),
66
+ };
67
+ return {
68
+ confirm: async () => true,
69
+ env: {
70
+ arch: "amd64",
71
+ home: world.home,
72
+ os: "linux",
73
+ path: world.bin,
74
+ userShell: join(world.bin, "zsh"),
75
+ },
76
+ io,
77
+ lockPath,
78
+ yes: true,
79
+ };
80
+ }
81
+
82
+ test("an empty lock file means another apply is starting", async () => {
83
+ const world = await makeWorld();
84
+ try {
85
+ const lockPath = join(world.cache, "doctor-fix.lock");
86
+ await writeFile(lockPath, "");
87
+
88
+ await expect(
89
+ applyDepsPlan(plan(), options(world, lockPath)),
90
+ ).rejects.toBeInstanceOf(DepsLockBusy);
91
+ } finally {
92
+ await removeWorld(world);
93
+ }
94
+ });