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
@@ -1,24 +1,30 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { PREDEFINED_PROFILES } from "../data/predefined-profiles.js";
3
3
  import {
4
- dedupeBins,
4
+ mergeBins,
5
+ pinOfBin,
5
6
  resolveAllProfiles,
6
7
  resolveExtends,
7
8
  resolveProfile,
8
9
  } from "../services/resolver.js";
9
10
  import type {
10
- BinRequirement,
11
+ ManagedTool,
11
12
  ProfileManifest,
12
13
  ProfileManifestEntry,
13
14
  } from "../types/index.js";
15
+ import { ownedAsDeclared, selected } from "./fixtures/bins.js";
14
16
 
15
- // A binResolver that returns canned requires.bin per plugin — keeps the
16
- // resolver tests hermetic (no marketplace cache / filesystem).
17
- function fakeBinResolver(
18
- table: Record<string, BinRequirement[]>,
19
- ): (pluginId: string) => Promise<BinRequirement[]> {
20
- return async (pluginId) => table[pluginId] ?? [];
17
+ // A binResolver that returns canned requirements per plugin — keeps the
18
+ // resolver tests hermetic (no marketplace cache / filesystem). A catalogue
19
+ // tool a plugin owns resolves as that plugin declares it today.
20
+ function fakeBinResolver(table: Record<string, ManagedTool[]>) {
21
+ return async (pluginId: string) =>
22
+ (table[pluginId] ?? []).map((t) => selected(t));
21
23
  }
24
+ const hermetic = (table: Record<string, ManagedTool[]> = {}) => ({
25
+ binResolver: fakeBinResolver(table),
26
+ ownedResolver: ownedAsDeclared,
27
+ });
22
28
 
23
29
  function manifestOf(
24
30
  profiles: Record<string, ProfileManifestEntry>,
@@ -26,44 +32,60 @@ function manifestOf(
26
32
  return { version: 2, profiles };
27
33
  }
28
34
 
29
- describe("dedupeBins — precedence by order", () => {
30
- test("first-seen wins installer/version; later contributes only provenance", () => {
31
- const claudishPin: BinRequirement = {
32
- name: "claudish",
33
- via: "bun",
34
- package: "claudish",
35
- version: "1.2.0",
36
- };
37
- const claudishLoose: BinRequirement = {
38
- name: "claudish",
39
- via: "npm",
40
- package: "claudish",
41
- };
42
- const result = dedupeBins([
43
- { bin: claudishPin, source: "profile:x.cliTools" },
44
- { bin: claudishLoose, source: "plugin:foo@magus" },
35
+ describe("mergeBins — precedence by order", () => {
36
+ test("a profile pin absorbs a later declaration of the name; it adds only provenance", () => {
37
+ const result = mergeBins([
38
+ {
39
+ bin: selected({ name: "claudish", via: "bun", version: "1.2.0" }),
40
+ source: "profile:x.cliTools",
41
+ },
42
+ {
43
+ bin: selected({ name: "claudish", via: "npm" }),
44
+ source: "plugin:foo@magus",
45
+ },
45
46
  ]);
46
47
  expect(result).toHaveLength(1);
47
- expect(result[0]!.via).toBe("bun");
48
- expect(result[0]!.version).toBe("1.2.0");
48
+ expect(result[0]!.chosen?.via).toBe("bun");
49
+ expect(pinOfBin(result[0]!)).toBe("1.2.0");
49
50
  expect(result[0]!.sources).toEqual([
50
51
  "profile:x.cliTools",
51
52
  "plugin:foo@magus",
52
53
  ]);
53
54
  });
54
55
 
55
- test("a later entry fills a version the winner left unset", () => {
56
- const loose: BinRequirement = { name: "tmux", via: "brew" };
57
- const pinned: BinRequirement = {
58
- name: "tmux",
59
- via: "brew",
60
- version: "3.4",
56
+ test("identical declarations merge; different plugin declarations of one name stay two", () => {
57
+ const jq = selected({ name: "jq", via: "brew", formula: "jq" });
58
+ const result = mergeBins([
59
+ { bin: jq, source: "plugin:dev@magus" },
60
+ { bin: { ...jq }, source: "plugin:code-search@magus" },
61
+ {
62
+ bin: selected({
63
+ name: "jq",
64
+ via: "brew",
65
+ formula: "jq",
66
+ check: "jq -V",
67
+ }),
68
+ source: "plugin:other@elsewhere",
69
+ },
70
+ ]);
71
+ expect(result).toHaveLength(2);
72
+ expect(result[0]!.sources).toEqual([
73
+ "plugin:dev@magus",
74
+ "plugin:code-search@magus",
75
+ ]);
76
+ expect(result[1]!.sources).toEqual(["plugin:other@elsewhere"]);
77
+ });
78
+
79
+ test("a check that reads the plugin root never merges across plugins", () => {
80
+ const env = {
81
+ ...selected({ name: "env", via: "bun" }),
82
+ check: ["${CLAUDE_PLUGIN_ROOT}/x"],
61
83
  };
62
- const result = dedupeBins([
63
- { bin: loose, source: "plugin:a" },
64
- { bin: pinned, source: "plugin:b" },
84
+ const result = mergeBins([
85
+ { bin: { ...env, root: "/a" }, source: "plugin:a@m" },
86
+ { bin: { ...env, root: "/b" }, source: "plugin:b@m" },
65
87
  ]);
66
- expect(result[0]!.version).toBe("3.4");
88
+ expect(result).toHaveLength(2);
67
89
  });
68
90
  });
69
91
 
@@ -161,8 +183,10 @@ describe("resolveProfile", () => {
161
183
  },
162
184
  });
163
185
 
164
- const closure = await resolveProfile(manifest, "frontend", {
165
- binResolver: fakeBinResolver({
186
+ const closure = await resolveProfile(
187
+ manifest,
188
+ "frontend",
189
+ hermetic({
166
190
  "terminal@magus": [
167
191
  { name: "tmux", via: "brew", formula: "tmux" },
168
192
  // terminal ALSO wants claudish, but the profile pinned it →
@@ -170,7 +194,7 @@ describe("resolveProfile", () => {
170
194
  { name: "claudish", via: "npm", package: "claudish" },
171
195
  ],
172
196
  }),
173
- });
197
+ );
174
198
 
175
199
  expect(closure.plugins).toEqual({
176
200
  "terminal@magus": "4.1.0",
@@ -179,12 +203,13 @@ describe("resolveProfile", () => {
179
203
  expect(closure.env.required).toEqual(["FIGMA_ACCESS_TOKEN"]);
180
204
 
181
205
  const claudish = closure.bins.find((b) => b.name === "claudish")!;
182
- expect(claudish.via).toBe("bun"); // from the cli-tools catalog, not npm
183
- expect(claudish.version).toBe("1.2.0");
206
+ // claudish@magus's own declaration (bun), pinned by the profile — not npm.
207
+ expect(claudish.chosen?.via).toBe("bun");
208
+ expect(pinOfBin(claudish)).toBe("1.2.0");
184
209
  expect(claudish.sources[0]).toContain("cliTools");
185
210
 
186
211
  const tmux = closure.bins.find((b) => b.name === "tmux")!;
187
- expect(tmux.via).toBe("brew");
212
+ expect(tmux.chosen?.via).toBe("brew");
188
213
  expect(tmux.sources).toContain("plugin:terminal@magus");
189
214
  });
190
215
 
@@ -213,9 +238,7 @@ describe("resolveAllProfiles — union", () => {
213
238
  },
214
239
  });
215
240
 
216
- const union = await resolveAllProfiles(manifest, {
217
- binResolver: fakeBinResolver({}),
218
- });
241
+ const union = await resolveAllProfiles(manifest, hermetic());
219
242
 
220
243
  expect(union.plugins["dev@magus"]).toBe("2.9.0"); // pin beat latest
221
244
  expect(union.plugins["terminal@magus"]).toBe("4.1.0");
@@ -224,7 +247,9 @@ describe("resolveAllProfiles — union", () => {
224
247
  "DATABASE_URL",
225
248
  "FIGMA_ACCESS_TOKEN",
226
249
  ]);
227
- expect(union.bins.find((b) => b.name === "mnemex")?.via).toBe("bun");
250
+ expect(union.bins.find((b) => b.name === "mnemex")?.chosen?.via).toBe(
251
+ "bun",
252
+ );
228
253
  expect(union.conflicts).toBeUndefined(); // latest-vs-pin isn't a conflict
229
254
  });
230
255
 
@@ -260,16 +285,68 @@ describe("resolveAllProfiles — union", () => {
260
285
  a: { name: "A", cliTools: { claudish: "1.2.0" } },
261
286
  b: { name: "B", cliTools: { claudish: "1.5.0" } },
262
287
  });
263
- const union = await resolveAllProfiles(manifest, {
264
- binResolver: fakeBinResolver({}),
265
- });
266
- expect(union.bins.find((x) => x.name === "claudish")?.version).toBe(
267
- "1.5.0",
268
- );
288
+ const union = await resolveAllProfiles(manifest, hermetic());
289
+ const claudish = union.bins.find((x) => x.name === "claudish");
290
+ expect(claudish && pinOfBin(claudish)).toBe("1.5.0");
269
291
  expect(union.conflicts?.some((c) => c.includes("claudish"))).toBe(true);
270
292
  });
271
293
  });
272
294
 
295
+ describe("a pin on a tool its plugin installs without a version", () => {
296
+ // claudish@magus as it declares claudish off Homebrew: its pinned release.
297
+ const RELEASE = {
298
+ via: "release" as const,
299
+ repo: "MadAppGang/claudish",
300
+ tag: "v10.3.0",
301
+ asset: "claudish-{os}-{arch}",
302
+ binary: "claudish",
303
+ sha256: { linux_amd64: "a".repeat(64) },
304
+ };
305
+ const ownedRelease = async (_pluginId: string, name: string) => ({
306
+ name,
307
+ check: [name, "--version"],
308
+ install: [RELEASE],
309
+ chosen: RELEASE,
310
+ });
311
+ const opts = {
312
+ binResolver: fakeBinResolver({}),
313
+ ownedResolver: ownedRelease,
314
+ };
315
+
316
+ test("is kept as refused: the release stays what installs, and no pin is invented", async () => {
317
+ const closure = await resolveProfile(
318
+ manifestOf({ p: { name: "P", cliTools: { claudish: "6.1.0" } } }),
319
+ "p",
320
+ opts,
321
+ );
322
+ const claudish = closure.bins.find((b) => b.name === "claudish");
323
+ expect(claudish?.chosen).toEqual(RELEASE);
324
+ expect(claudish && pinOfBin(claudish)).toBeUndefined();
325
+ expect(claudish?.refusedPins).toEqual(["6.1.0"]);
326
+ });
327
+
328
+ test("every profile's refused pin survives the union", async () => {
329
+ const union = await resolveAllProfiles(
330
+ manifestOf({
331
+ a: { name: "A", cliTools: { claudish: "6.1.0" } },
332
+ b: { name: "B", cliTools: { claudish: "7.0.0" } },
333
+ }),
334
+ opts,
335
+ );
336
+ const claudish = union.bins.find((b) => b.name === "claudish");
337
+ expect(claudish?.refusedPins).toEqual(["6.1.0", "7.0.0"]);
338
+ });
339
+
340
+ test("latest is not a pin, so nothing is refused", async () => {
341
+ const closure = await resolveProfile(
342
+ manifestOf({ p: { name: "P", cliTools: { claudish: "latest" } } }),
343
+ "p",
344
+ opts,
345
+ );
346
+ expect(closure.bins[0]?.refusedPins).toBeUndefined();
347
+ });
348
+ });
349
+
273
350
  describe("marketplaces derived from plugin ids", () => {
274
351
  const noBins = { binResolver: fakeBinResolver({}) };
275
352
 
@@ -0,0 +1,108 @@
1
+ /**
2
+ * `runArgv` — the one runner: argv only, output captured and tailed, never
3
+ * written to the terminal, bounded by SIGTERM then SIGKILL.
4
+ */
5
+
6
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
7
+ import {
8
+ OUTPUT_TAIL_BYTES,
9
+ runArgv,
10
+ runFailureReason,
11
+ succeeded,
12
+ } from "../utils/run.js";
13
+
14
+ const sh = (script: string) => ({ cmd: "/bin/sh", args: ["-c", script] });
15
+
16
+ describe("runArgv", () => {
17
+ let out: ReturnType<typeof spyOn>;
18
+ let err: ReturnType<typeof spyOn>;
19
+ beforeEach(() => {
20
+ out = spyOn(process.stdout, "write");
21
+ err = spyOn(process.stderr, "write");
22
+ });
23
+ afterEach(() => {
24
+ out.mockRestore();
25
+ err.mockRestore();
26
+ });
27
+
28
+ test("captures stdout and stderr together, ANSI stripped, and never writes to the terminal", async () => {
29
+ const chunks: string[] = [];
30
+ const r = await runArgv(
31
+ sh("printf 'to-out\\n'; printf '\\033[31mto-err\\033[0m\\n' >&2"),
32
+ { timeoutMs: 5_000, onOutput: (c) => chunks.push(c) },
33
+ );
34
+ expect(r.exitCode).toBe(0);
35
+ expect(succeeded(r)).toBe(true);
36
+ expect(r.outputTail).toContain("to-out");
37
+ expect(r.outputTail).toContain("to-err");
38
+ expect(r.outputTail).not.toContain("\u001b[");
39
+ expect(chunks.join("")).toContain("to-out");
40
+ expect(out).not.toHaveBeenCalled();
41
+ expect(err).not.toHaveBeenCalled();
42
+ });
43
+
44
+ test("keeps only the last 16 KiB of output", async () => {
45
+ const r = await runArgv(
46
+ sh(
47
+ "i=0; while [ $i -lt 2000 ]; do echo line-$i-xxxxxxxxxxxxxxxx; i=$((i+1)); done",
48
+ ),
49
+ { timeoutMs: 10_000 },
50
+ );
51
+ expect(Buffer.byteLength(r.outputTail)).toBeLessThanOrEqual(
52
+ OUTPUT_TAIL_BYTES,
53
+ );
54
+ expect(r.outputTail).toContain("line-1999-");
55
+ expect(r.outputTail).not.toContain("line-0-");
56
+ });
57
+
58
+ test("an argument is one argument: nothing reaches a shell", async () => {
59
+ const r = await runArgv(
60
+ { cmd: "/bin/echo", args: ["a b; echo injected"] },
61
+ { timeoutMs: 5_000 },
62
+ );
63
+ expect(r.outputTail.trim()).toBe("a b; echo injected");
64
+ });
65
+
66
+ test("stdin is not the terminal", async () => {
67
+ const r = await runArgv(sh("cat; echo done"), { timeoutMs: 5_000 });
68
+ expect(r.outputTail.trim()).toBe("done");
69
+ });
70
+
71
+ test("a missing executable is a spawn error, not a throw", async () => {
72
+ const r = await runArgv(
73
+ { cmd: "/nonexistent/tool", args: [] },
74
+ { timeoutMs: 5_000 },
75
+ );
76
+ expect(r.exitCode).toBeNull();
77
+ expect(r.spawnError).toContain("ENOENT");
78
+ expect(succeeded(r)).toBe(false);
79
+ });
80
+
81
+ test("over the limit: SIGTERM, then SIGKILL once the grace runs out", async () => {
82
+ const started = Date.now();
83
+ const r = await runArgv(sh("trap '' TERM; while :; do sleep 0.05; done"), {
84
+ timeoutMs: 200,
85
+ killGraceMs: 300,
86
+ });
87
+ expect(r.timedOut).toBe(true);
88
+ expect(r.signal).toBe("SIGKILL");
89
+ expect(Date.now() - started).toBeGreaterThanOrEqual(450);
90
+ expect(runFailureReason(r)).toStartWith("timed out");
91
+ });
92
+
93
+ test("a child that honours SIGTERM ends there", async () => {
94
+ const r = await runArgv(sh("exec sleep 10"), { timeoutMs: 200 });
95
+ expect(r.timedOut).toBe(true);
96
+ expect(r.signal).toBe("SIGTERM");
97
+ });
98
+
99
+ test("the failure reason is the installer's last line", async () => {
100
+ const r = await runArgv(
101
+ sh("echo progress; echo 'error: no such formula' >&2; exit 3"),
102
+ {
103
+ timeoutMs: 5_000,
104
+ },
105
+ );
106
+ expect(runFailureReason(r)).toBe("exit 3: error: no such formula");
107
+ });
108
+ });
@@ -1395,7 +1395,7 @@ describe("binPinCommand — moving an installed tool to exactly its pin", () =>
1395
1395
  test("bun/npm/go carry the version in the package token", () => {
1396
1396
  expect(
1397
1397
  binPinCommand({ name: "m", via: "bun", version: "1.0.0" }, uv),
1398
- ).toEqual({ cmd: "bun", args: ["install", "-g", "m@1.0.0"] });
1398
+ ).toEqual({ cmd: "bun", args: ["add", "-g", "m@1.0.0"] });
1399
1399
  expect(
1400
1400
  binPinCommand(
1401
1401
  { name: "t", via: "go", module: "github.com/x/t", version: "v2.0.0" },
@@ -2,12 +2,13 @@ import { describe, expect, test } from "bun:test";
2
2
  import {
3
3
  PYTHON_CLI_INSTALLERS,
4
4
  type PythonCliInstaller,
5
- binInstallCommand,
6
5
  binUpgradeCommand,
7
- detectToolchains,
6
+ managedBins,
7
+ managedInstallCommand,
8
+ managedOption,
9
+ managedTool,
8
10
  resolvePythonCliInstaller,
9
11
  } from "../services/toolchain.js";
10
- import type { ResolvedBin } from "../types/index.js";
11
12
  import { formatCommand } from "../utils/command-utils.js";
12
13
 
13
14
  /**
@@ -26,21 +27,21 @@ const UV = byProbe("uv");
26
27
  const PIPX = byProbe("pipx");
27
28
  const PY = byProbe("python3");
28
29
 
29
- describe("binInstallCommand", () => {
30
+ describe("managedInstallCommand (rendered by requires-probe's renderInstall)", () => {
30
31
  test("bun with and without a version pin", () => {
31
32
  expect(
32
33
  formatCommand(
33
- binInstallCommand({
34
+ managedInstallCommand({
34
35
  name: "claudish",
35
36
  via: "bun",
36
37
  package: "claudish",
37
38
  version: "1.2.0",
38
39
  }),
39
40
  ),
40
- ).toBe("bun install -g claudish@1.2.0");
41
+ ).toBe("bun add -g claudish@1.2.0");
41
42
  expect(
42
- formatCommand(binInstallCommand({ name: "claudish", via: "bun" })),
43
- ).toBe("bun install -g claudish");
43
+ formatCommand(managedInstallCommand({ name: "claudish", via: "bun" })),
44
+ ).toBe("bun add -g claudish");
44
45
  });
45
46
 
46
47
  test("the package name is ONE argv token, whatever is in it", () => {
@@ -51,18 +52,18 @@ describe("binInstallCommand", () => {
51
52
  // words and a command separator. As argv it cannot be anything but a
52
53
  // (nonexistent) package name, so the install fails instead of running.
53
54
  const evil = "a b; rm -rf /";
54
- const cmd = binInstallCommand({ name: "x", via: "bun", package: evil });
55
+ const cmd = managedInstallCommand({ name: "x", via: "bun", package: evil });
55
56
  expect(cmd.cmd).toBe("bun");
56
- expect(cmd.args).toEqual(["install", "-g", evil]);
57
+ expect(cmd.args).toEqual(["add", "-g", evil]);
57
58
  expect(cmd.args.at(-1)).toBe(evil); // unsplit: still exactly one token
58
59
  // ...and rendering it for display quotes it rather than emitting syntax.
59
- expect(formatCommand(cmd)).toBe("bun install -g 'a b; rm -rf /'");
60
+ expect(formatCommand(cmd)).toBe("bun add -g 'a b; rm -rf /'");
60
61
  });
61
62
 
62
63
  test("a version pin joins the package as one token, not two", () => {
63
64
  // Splitting `pkg@version` would ask the installer for two packages, the
64
65
  // second of which is a version number.
65
- const cmd = binInstallCommand({
66
+ const cmd = managedInstallCommand({
66
67
  name: "claudish",
67
68
  via: "bun",
68
69
  version: "1.2.0",
@@ -76,24 +77,32 @@ describe("binInstallCommand", () => {
76
77
  // quotes nothing and the printed line is byte-identical to the string
77
78
  // these builders returned before they returned argv.
78
79
  expect(
79
- formatCommand(binInstallCommand({ name: "x", via: "npm", package: "x" })),
80
+ formatCommand(
81
+ managedInstallCommand({ name: "x", via: "npm", package: "x" }),
82
+ ),
80
83
  ).toBe("npm install -g x");
81
84
  expect(
82
85
  formatCommand(
83
- binInstallCommand(
84
- { name: "aider", via: "pip", package: "aider", version: "0.1" },
85
- UV,
86
- ),
86
+ managedInstallCommand({
87
+ name: "aider",
88
+ via: "pip",
89
+ package: "aider",
90
+ version: "0.1",
91
+ }),
87
92
  ),
88
93
  ).toBe("uv tool install aider==0.1");
89
94
  expect(
90
95
  formatCommand(
91
- binInstallCommand({ name: "tmux", via: "brew", formula: "tmux" }),
96
+ managedInstallCommand({ name: "tmux", via: "brew", formula: "tmux" }),
92
97
  ),
93
98
  ).toBe("brew install tmux");
94
99
  expect(
95
100
  formatCommand(
96
- binInstallCommand({ name: "t", via: "go", module: "github.com/x/t" }),
101
+ managedInstallCommand({
102
+ name: "t",
103
+ via: "go",
104
+ module: "github.com/x/t",
105
+ }),
97
106
  ),
98
107
  ).toBe("go install github.com/x/t@latest");
99
108
  });
@@ -132,7 +141,7 @@ describe("binUpgradeCommand", () => {
132
141
  // they are distinct references — so comparing them directly would be
133
142
  // an assertion that cannot fail.
134
143
  expect(formatCommand(binUpgradeCommand(bin, python))).not.toBe(
135
- formatCommand(binInstallCommand(bin, python)),
144
+ formatCommand(managedInstallCommand(bin)),
136
145
  );
137
146
  }
138
147
  });
@@ -172,20 +181,11 @@ describe("resolvePythonCliInstaller", () => {
172
181
  });
173
182
 
174
183
  test("a version pin is expressed in the installer's own syntax", () => {
175
- expect(
176
- formatCommand(
177
- binInstallCommand({ name: "a", via: "pip", version: "1.2" }, UV),
178
- ),
179
- ).toBe("uv tool install a==1.2");
180
- expect(
181
- formatCommand(
182
- binInstallCommand({ name: "a", via: "pip", version: "1.2" }, PIPX),
183
- ),
184
- ).toBe("pipx install a==1.2");
184
+ expect(PIPX.install("a", "1.2").args).toEqual(["install", "a==1.2"]);
185
185
  // `a==1.2` is one token. Two would ask uv to install a package called
186
186
  // "1.2".
187
187
  expect(
188
- binInstallCommand({ name: "a", via: "pip", version: "1.2" }, UV).args,
188
+ managedInstallCommand({ name: "a", via: "pip", version: "1.2" }).args,
189
189
  ).toEqual(["tool", "install", "a==1.2"]);
190
190
  });
191
191
 
@@ -207,26 +207,97 @@ describe("resolvePythonCliInstaller", () => {
207
207
  });
208
208
  });
209
209
 
210
- describe("detectToolchains", () => {
211
- test("groups bins by installer and reports requiredBy + presence", async () => {
212
- const bins: ResolvedBin[] = [
213
- { name: "claudish", via: "bun", sources: ["a"] },
214
- { name: "mnemex", via: "bun", sources: ["b"] },
215
- { name: "tmux", via: "brew", sources: ["c"] },
216
- ];
217
- const statuses = await detectToolchains(bins);
218
- const bun = statuses.find((s) => s.name === "bun")!;
219
- const brew = statuses.find((s) => s.name === "brew")!;
220
- expect(bun.requiredBy.sort()).toEqual(["claudish", "mnemex"]);
221
- expect(brew.requiredBy).toEqual(["tmux"]);
222
- // bun/brew are bootstrappable; the field is populated.
223
- expect(bun.bootstrap).toContain("bun.sh");
224
- expect(typeof bun.present).toBe("boolean");
225
- // stable order: bun before brew
226
- expect(statuses.map((s) => s.name)).toEqual(["bun", "brew"]);
227
- });
228
-
229
- test("returns only toolchains actually needed (empty for no bins)", async () => {
230
- expect(await detectToolchains([])).toEqual([]);
210
+ describe("managedTool — the version-managed view of a requirement", () => {
211
+ test("bun/npm/pip/go/brew options map onto the managed shape, pin included", () => {
212
+ expect(
213
+ managedTool({
214
+ name: "claudish",
215
+ check: ["claudish", "--version"],
216
+ install: [],
217
+ chosen: { via: "bun", package: "claudish", version: "1.2.0" },
218
+ }),
219
+ ).toEqual({
220
+ name: "claudish",
221
+ via: "bun",
222
+ package: "claudish",
223
+ check: "claudish --version",
224
+ version: "1.2.0",
225
+ });
226
+ expect(
227
+ managedTool({
228
+ name: "t",
229
+ check: ["t"],
230
+ install: [],
231
+ chosen: { via: "go", package: "github.com/x/t" },
232
+ }),
233
+ ).toMatchObject({ via: "go", module: "github.com/x/t" });
234
+ expect(
235
+ managedTool({
236
+ name: "tmux",
237
+ check: ["tmux", "-V"],
238
+ install: [],
239
+ chosen: { via: "brew", formula: "tmux" },
240
+ }),
241
+ ).toMatchObject({ via: "brew", formula: "tmux" });
242
+ });
243
+
244
+ test("apt, release, bootstrap and the uv kinds have no managed version", () => {
245
+ const base = { name: "x", check: ["x"], install: [] };
246
+ for (const chosen of [
247
+ { via: "apt" as const, packages: ["x"] },
248
+ { via: "bootstrap" as const, tool: "uv" as const },
249
+ { via: "uv-script" as const, script: "s.py", args: [] },
250
+ { via: "uv-module" as const, script: "s.py", module: "m", args: [] },
251
+ ]) {
252
+ expect(managedTool({ ...base, chosen })).toBeNull();
253
+ }
254
+ expect(managedTool({ ...base, chosen: null })).toBeNull();
255
+ });
256
+
257
+ test("managedBins keeps provenance and drops what is not managed", () => {
258
+ const bins = managedBins([
259
+ {
260
+ name: "a",
261
+ check: ["a"],
262
+ install: [],
263
+ chosen: { via: "npm", package: "a" },
264
+ sources: ["plugin:p@m"],
265
+ },
266
+ {
267
+ name: "b",
268
+ check: ["b"],
269
+ install: [],
270
+ chosen: { via: "apt", packages: ["b"] },
271
+ sources: ["plugin:p@m"],
272
+ },
273
+ ]);
274
+ expect(bins).toEqual([
275
+ {
276
+ name: "a",
277
+ via: "npm",
278
+ package: "a",
279
+ check: "a",
280
+ sources: ["plugin:p@m"],
281
+ },
282
+ ]);
283
+ });
284
+
285
+ test("managedOption round-trips through managedTool", () => {
286
+ for (const t of [
287
+ { name: "c", via: "bun" as const, package: "c", version: "1" },
288
+ { name: "t", via: "brew" as const, formula: "tmux" },
289
+ { name: "g", via: "go" as const, module: "github.com/x/g" },
290
+ { name: "p", via: "pip" as const, package: "p" },
291
+ ]) {
292
+ const option = managedOption(t);
293
+ expect(
294
+ managedTool({
295
+ name: t.name,
296
+ check: [t.name],
297
+ install: [option],
298
+ chosen: option,
299
+ }),
300
+ ).toMatchObject(t);
301
+ }
231
302
  });
232
303
  });
@@ -859,7 +859,7 @@ describe("binNeedsWork — one predicate for apply, the meter and 'has work'", (
859
859
  { ...itemFor("upgrade"), name: "mnemex", target: "1.0.0" },
860
860
  spec,
861
861
  ),
862
- ).toEqual({ cmd: "bun", args: ["install", "-g", "mnemex@1.0.0"] });
862
+ ).toEqual({ cmd: "bun", args: ["add", "-g", "mnemex@1.0.0"] });
863
863
  expect(
864
864
  binCommandFor(
865
865
  { ...itemFor("upgrade"), target: "2.0.0" },
package/src/cli/ansi.ts CHANGED
@@ -23,6 +23,7 @@
23
23
 
24
24
  import { barTrack } from "../ui/theme-mode.js";
25
25
  import { brand } from "../ui/theme.js";
26
+ import { CSI, stripAnsi } from "../utils/string-utils.js";
26
27
 
27
28
  // -- colour capability -------------------------------------------------------
28
29
 
@@ -192,13 +193,7 @@ export function dim(text: string): string {
192
193
 
193
194
  // -- measuring ---------------------------------------------------------------
194
195
 
195
- /** Every CSI sequence, not only SGR — the live region emits cursor moves too. */
196
- // biome-ignore lint/suspicious/noControlCharactersInRegex: ESC is the thing being matched.
197
- const CSI = /\x1b\[[0-9;?]*[\x20-\x2f]*[\x40-\x7e]/g;
198
-
199
- export function stripAnsi(text: string): string {
200
- return text.replace(CSI, "");
201
- }
196
+ export { stripAnsi };
202
197
 
203
198
  /**
204
199
  * Cells one codepoint occupies: 0, 1 or 2.