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
|
@@ -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
|
-
|
|
4
|
+
mergeBins,
|
|
5
|
+
pinOfBin,
|
|
5
6
|
resolveAllProfiles,
|
|
6
7
|
resolveExtends,
|
|
7
8
|
resolveProfile,
|
|
8
9
|
} from "../services/resolver.js";
|
|
9
10
|
import type {
|
|
10
|
-
|
|
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
|
|
16
|
-
// resolver tests hermetic (no marketplace cache / filesystem).
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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("
|
|
30
|
-
test("
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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]
|
|
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("
|
|
56
|
-
const
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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 =
|
|
63
|
-
{ bin:
|
|
64
|
-
{ bin:
|
|
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
|
|
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(
|
|
165
|
-
|
|
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
|
-
|
|
183
|
-
expect(claudish.
|
|
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(
|
|
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
|
-
|
|
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: ["
|
|
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
|
-
|
|
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("
|
|
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
|
-
|
|
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
|
|
41
|
+
).toBe("bun add -g claudish@1.2.0");
|
|
41
42
|
expect(
|
|
42
|
-
formatCommand(
|
|
43
|
-
).toBe("bun
|
|
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 =
|
|
55
|
+
const cmd = managedInstallCommand({ name: "x", via: "bun", package: evil });
|
|
55
56
|
expect(cmd.cmd).toBe("bun");
|
|
56
|
-
expect(cmd.args).toEqual(["
|
|
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
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
96
|
+
managedInstallCommand({ name: "tmux", via: "brew", formula: "tmux" }),
|
|
92
97
|
),
|
|
93
98
|
).toBe("brew install tmux");
|
|
94
99
|
expect(
|
|
95
100
|
formatCommand(
|
|
96
|
-
|
|
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(
|
|
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
|
-
|
|
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("
|
|
211
|
-
test("
|
|
212
|
-
|
|
213
|
-
{
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
expect(
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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: ["
|
|
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
|
-
|
|
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.
|