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.
- 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/review-followups/bun-install-path.test.ts +57 -0
- package/src/__tests__/blind/review-followups/d1-empty-lock.test.ts +94 -0
- package/src/__tests__/blind/review-followups/d2-disappearing-lock.test.ts +90 -0
- package/src/__tests__/blind/review-followups/d3-release-temp-cleanup.test.ts +83 -0
- package/src/__tests__/blind/review-followups/d4-tool-stdout.test.ts +152 -0
- package/src/__tests__/blind/review-followups/installer-redirect.test.ts +74 -0
- package/src/__tests__/blind/review-followups/ledger-permissions.test.ts +78 -0
- package/src/__tests__/blind/review-followups/ledger-pruning.test.ts +79 -0
- package/src/__tests__/blind/review-followups/path-advice.test.ts +46 -0
- package/src/__tests__/blind/review-followups/stats-requires.test.ts +16 -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 +897 -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 +1154 -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 +64 -0
- package/src/shared/plugin/hooks/deps-check.ts +240 -0
- package/src/shared/plugin/hooks/lib/requires-probe.ts +1279 -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 +143 -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
|
@@ -17,10 +17,10 @@
|
|
|
17
17
|
* C5 stepFailureReport: settings file that does not parse, fallback fix → "C5"
|
|
18
18
|
* C6 no internal word or absolute binary path in headline/reason → "C6"
|
|
19
19
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* Failures are built in the shape `runArgv` resolves with (`RunResult`):
|
|
21
|
+
* the exit code, whether the timeout killed it, the combined output tail, and
|
|
22
|
+
* for a missing executable a `spawn <cmd> ENOENT` spawn error. Nothing is
|
|
23
|
+
* spawned.
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
import { describe, expect, test } from "bun:test";
|
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
} from "../ui/adapters/startupReports.js";
|
|
41
41
|
import type { FailureReport } from "../ui/state/types.js";
|
|
42
42
|
import type { Command } from "../utils/command-utils.js";
|
|
43
|
+
import type { RunResult } from "../utils/run.js";
|
|
43
44
|
|
|
44
45
|
/** The installer timeout the spec names: 60 seconds. */
|
|
45
46
|
const TIMEOUT_MS = 60_000;
|
|
@@ -73,42 +74,43 @@ interface ExecErrorShape {
|
|
|
73
74
|
code?: number | string | null;
|
|
74
75
|
killed?: boolean;
|
|
75
76
|
signal?: string | null;
|
|
76
|
-
/** Extra lines appended to the "Command failed:" message. */
|
|
77
|
-
messageTail?: string;
|
|
78
77
|
}
|
|
79
78
|
|
|
80
|
-
/** What
|
|
81
|
-
function execError(o: ExecErrorShape):
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
);
|
|
86
|
-
return Object.assign(err, {
|
|
87
|
-
cmd: cmdLine,
|
|
88
|
-
code: o.code === undefined ? 1 : o.code,
|
|
89
|
-
killed: o.killed ?? false,
|
|
79
|
+
/** What `runArgv` resolves with when the command ran and failed. */
|
|
80
|
+
function execError(o: ExecErrorShape): RunResult {
|
|
81
|
+
return {
|
|
82
|
+
exitCode:
|
|
83
|
+
typeof o.code === "number" ? o.code : o.code === undefined ? 1 : null,
|
|
90
84
|
signal: o.signal ?? null,
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
85
|
+
timedOut: o.killed ?? false,
|
|
86
|
+
outputTail: [o.stdout ?? "", o.stderr ?? ""].filter(Boolean).join("\n"),
|
|
87
|
+
durationMs: 0,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** What `runArgv` resolves with when the executable does not exist. */
|
|
92
|
+
function enoent(command: Command): RunResult {
|
|
93
|
+
return {
|
|
94
|
+
exitCode: null,
|
|
95
|
+
signal: null,
|
|
96
|
+
timedOut: false,
|
|
97
|
+
spawnError: `spawn ${command.cmd} ENOENT`,
|
|
98
|
+
outputTail: "",
|
|
99
|
+
durationMs: 0,
|
|
100
|
+
};
|
|
94
101
|
}
|
|
95
102
|
|
|
96
|
-
/** What
|
|
97
|
-
function
|
|
98
|
-
|
|
99
|
-
return Object.assign(err, {
|
|
103
|
+
/** What a spawn throws when the executable does not exist (a step's error). */
|
|
104
|
+
function enoentError(command: Command): Error {
|
|
105
|
+
return Object.assign(new Error(`spawn ${command.cmd} ENOENT`), {
|
|
100
106
|
code: "ENOENT",
|
|
101
|
-
errno: -2,
|
|
102
107
|
syscall: `spawn ${command.cmd}`,
|
|
103
108
|
path: command.cmd,
|
|
104
|
-
spawnargs: [...command.args],
|
|
105
|
-
stdout: "",
|
|
106
|
-
stderr: "",
|
|
107
109
|
});
|
|
108
110
|
}
|
|
109
111
|
|
|
110
|
-
/** What `
|
|
111
|
-
function timedOut(command: Command, partialStderr = ""):
|
|
112
|
+
/** What `runArgv` resolves with when its timeout killed the child. */
|
|
113
|
+
function timedOut(command: Command, partialStderr = ""): RunResult {
|
|
112
114
|
return execError({
|
|
113
115
|
command,
|
|
114
116
|
code: null,
|
|
@@ -241,13 +243,11 @@ describe("C1 commandFailureReason", () => {
|
|
|
241
243
|
command: BREW_UPGRADE,
|
|
242
244
|
stdout: "==> Fetching downloads for: tmux",
|
|
243
245
|
stderr: 'Error: No available formula with the name "tmux-nope".',
|
|
244
|
-
messageTail: "some other text from the message",
|
|
245
246
|
}),
|
|
246
247
|
TIMEOUT_MS,
|
|
247
248
|
);
|
|
248
249
|
expect(reason).toContain("No available formula");
|
|
249
250
|
expect(reason).not.toContain("Fetching downloads");
|
|
250
|
-
expect(reason).not.toContain("some other text from the message");
|
|
251
251
|
});
|
|
252
252
|
|
|
253
253
|
test("C1: is one line, even when stderr has many", () => {
|
|
@@ -319,25 +319,13 @@ describe("C1 commandFailureReason", () => {
|
|
|
319
319
|
expect(reason.trim().length).toBeGreaterThan(0);
|
|
320
320
|
});
|
|
321
321
|
|
|
322
|
-
test("C1:
|
|
322
|
+
test("C1: an installer that printed nothing still yields a non-empty reason", () => {
|
|
323
323
|
const reason = commandFailureReason(
|
|
324
324
|
NPM_INSTALL,
|
|
325
|
-
execError({
|
|
326
|
-
command: NPM_INSTALL,
|
|
327
|
-
messageTail:
|
|
328
|
-
"npm error 404 Not Found - GET https://npm.example.test/x - Not found",
|
|
329
|
-
}),
|
|
325
|
+
execError({ command: NPM_INSTALL }),
|
|
330
326
|
TIMEOUT_MS,
|
|
331
327
|
);
|
|
332
|
-
expect(reason).
|
|
333
|
-
expect(reason.trim().length).toBeGreaterThan(0);
|
|
334
|
-
});
|
|
335
|
-
|
|
336
|
-
test("C1: something that is not an Error still yields a non-empty reason", () => {
|
|
337
|
-
const reason = commandFailureReason(NPM_INSTALL, "boom", TIMEOUT_MS);
|
|
338
|
-
expect(typeof reason).toBe("string");
|
|
339
|
-
expect(reason.trim().length).toBeGreaterThan(0);
|
|
340
|
-
expect(reason).not.toContain("Command failed");
|
|
328
|
+
expect(reason).toBe("npm exited with code 1");
|
|
341
329
|
});
|
|
342
330
|
});
|
|
343
331
|
|
|
@@ -664,7 +652,7 @@ describe("C5 stepFailureReport", () => {
|
|
|
664
652
|
const report = stepFailureReport(
|
|
665
653
|
"Plugins were not refreshed",
|
|
666
654
|
"magus",
|
|
667
|
-
|
|
655
|
+
enoentError({ cmd: "claude", args: ["plugin", "marketplace", "update"] }),
|
|
668
656
|
"Run it yourself: claude plugin marketplace update magus",
|
|
669
657
|
);
|
|
670
658
|
const fixes = fixesOf(report);
|
|
@@ -791,7 +779,7 @@ describe("C6 no internal word in any headline or reason", () => {
|
|
|
791
779
|
stepFailureReport(
|
|
792
780
|
"Plugins were not refreshed",
|
|
793
781
|
"magus",
|
|
794
|
-
|
|
782
|
+
enoentError(ABS_CLAUDE),
|
|
795
783
|
),
|
|
796
784
|
],
|
|
797
785
|
];
|
|
@@ -58,7 +58,6 @@ import {
|
|
|
58
58
|
} from "../ui/adapters/pluginsAdapter.js";
|
|
59
59
|
import {
|
|
60
60
|
activationFailureReport,
|
|
61
|
-
marketplaceRefreshReport,
|
|
62
61
|
profileProblemRow,
|
|
63
62
|
profileProblemsReport,
|
|
64
63
|
staleRepairReport,
|
|
@@ -449,30 +448,6 @@ describe("U4 failed stale-load repairs", () => {
|
|
|
449
448
|
});
|
|
450
449
|
});
|
|
451
450
|
|
|
452
|
-
// ---------------------------------------------------------------------------
|
|
453
|
-
// U5
|
|
454
|
-
// ---------------------------------------------------------------------------
|
|
455
|
-
|
|
456
|
-
describe("U5 a failed marketplace refresh", () => {
|
|
457
|
-
test("U5 the row's subject is the marketplace name", () => {
|
|
458
|
-
const r = marketplaceRefreshReport([
|
|
459
|
-
{
|
|
460
|
-
marketplace: "magus-marketing",
|
|
461
|
-
error: new Error("fatal: unable to access remote"),
|
|
462
|
-
},
|
|
463
|
-
]);
|
|
464
|
-
expect(r).not.toBeNull();
|
|
465
|
-
const report = r as FailureReport;
|
|
466
|
-
expect(report.rows).toHaveLength(1);
|
|
467
|
-
expect((report.rows[0] as FailureRow).subject).toBe("magus-marketing");
|
|
468
|
-
});
|
|
469
|
-
|
|
470
|
-
test("U5 nothing failed → no report", () => {
|
|
471
|
-
const r = marketplaceRefreshReport([]);
|
|
472
|
-
expect(r === null || r.rows.length === 0).toBe(true);
|
|
473
|
-
});
|
|
474
|
-
});
|
|
475
|
-
|
|
476
451
|
// ---------------------------------------------------------------------------
|
|
477
452
|
// U6
|
|
478
453
|
// ---------------------------------------------------------------------------
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tests for the
|
|
2
|
+
* Tests for the progress seams the animated report depends on.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* the coupling between the apply meter's denominator and the items the apply
|
|
4
|
+
* Both shipped untested: `ApplyReporter`, and the coupling between the apply meter's denominator and the items the apply
|
|
6
5
|
* loop actually touches. A review found that last one hand-duplicated
|
|
7
6
|
* `applyPlugins`'s skip conditions and replicated only one of the two, so a
|
|
8
7
|
* plan containing an actionable plugin with no scopes would leave the bar stuck
|
|
@@ -13,7 +12,6 @@
|
|
|
13
12
|
import { describe, expect, mock, test } from "bun:test";
|
|
14
13
|
import { parseArgs } from "../cli/update.js";
|
|
15
14
|
import type { PluginScope } from "../services/claude-cli.js";
|
|
16
|
-
import { checkBinaries } from "../services/doctor-bins.js";
|
|
17
15
|
import {
|
|
18
16
|
type ApplyReporter,
|
|
19
17
|
type PluginApplyDeps,
|
|
@@ -22,7 +20,6 @@ import {
|
|
|
22
20
|
pluginNeedsWork,
|
|
23
21
|
} from "../services/update-engine.js";
|
|
24
22
|
import type { PluginUpdateItem } from "../services/update-plan.js";
|
|
25
|
-
import type { ResolvedBin } from "../types/index.js";
|
|
26
23
|
|
|
27
24
|
const PROJECT = "/tmp/project";
|
|
28
25
|
|
|
@@ -224,65 +221,3 @@ describe("installPluginInScope — the scope-targeted path", () => {
|
|
|
224
221
|
expect(saved).toEqual([]);
|
|
225
222
|
});
|
|
226
223
|
});
|
|
227
|
-
|
|
228
|
-
describe("checkBinaries onProbe", () => {
|
|
229
|
-
const bins: ResolvedBin[] = [
|
|
230
|
-
{ name: "tmux", via: "brew", sources: [] } as unknown as ResolvedBin,
|
|
231
|
-
{ name: "tmux-mcp", via: "bun", sources: [] } as unknown as ResolvedBin,
|
|
232
|
-
{ name: "jq", via: "brew", sources: [] } as unknown as ResolvedBin,
|
|
233
|
-
];
|
|
234
|
-
|
|
235
|
-
test("fires once per binary, before its probe, with a stable total", async () => {
|
|
236
|
-
// Before the probe matters: the point is to name the binary the command
|
|
237
|
-
// is currently blocked on, not the one it just finished.
|
|
238
|
-
const seen: Array<[string, number, number]> = [];
|
|
239
|
-
const order: string[] = [];
|
|
240
|
-
await checkBinaries(
|
|
241
|
-
bins,
|
|
242
|
-
async (name) => {
|
|
243
|
-
order.push(`probe:${name}`);
|
|
244
|
-
return true;
|
|
245
|
-
},
|
|
246
|
-
(name, index, total) => {
|
|
247
|
-
order.push(`announce:${name}`);
|
|
248
|
-
seen.push([name, index, total]);
|
|
249
|
-
},
|
|
250
|
-
);
|
|
251
|
-
|
|
252
|
-
expect(seen).toEqual([
|
|
253
|
-
["tmux", 0, 3],
|
|
254
|
-
["tmux-mcp", 1, 3],
|
|
255
|
-
["jq", 2, 3],
|
|
256
|
-
]);
|
|
257
|
-
expect(order.slice(0, 2)).toEqual(["announce:tmux", "probe:tmux"]);
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
test("the index is 0-based, so done/total needs index+1", async () => {
|
|
261
|
-
// A meter fed the raw index reads 0/3 while the first probe runs, which
|
|
262
|
-
// is correct — it has finished none of them.
|
|
263
|
-
const first: number[] = [];
|
|
264
|
-
await checkBinaries(
|
|
265
|
-
bins,
|
|
266
|
-
async () => true,
|
|
267
|
-
(_n, index) => first.push(index),
|
|
268
|
-
);
|
|
269
|
-
expect(first[0]).toBe(0);
|
|
270
|
-
expect(first.at(-1)).toBe(bins.length - 1);
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
test("no callback means no behaviour change", async () => {
|
|
274
|
-
const results = await checkBinaries(bins, async () => false);
|
|
275
|
-
expect(results.map((r) => r.present)).toEqual([false, false, false]);
|
|
276
|
-
});
|
|
277
|
-
|
|
278
|
-
test("an empty bin list announces nothing", async () => {
|
|
279
|
-
const seen: string[] = [];
|
|
280
|
-
const results = await checkBinaries(
|
|
281
|
-
[],
|
|
282
|
-
async () => true,
|
|
283
|
-
(n) => seen.push(n),
|
|
284
|
-
);
|
|
285
|
-
expect(results).toEqual([]);
|
|
286
|
-
expect(seen).toEqual([]);
|
|
287
|
-
});
|
|
288
|
-
});
|
|
@@ -25,12 +25,18 @@
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
import { describe, expect, test } from "bun:test";
|
|
28
|
-
import {
|
|
28
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
29
|
+
import { join } from "node:path";
|
|
30
|
+
import { cliTools as catalogue, isOwnedEntry } from "../data/cli-tools.js";
|
|
29
31
|
import {
|
|
30
32
|
type InstallMethod,
|
|
31
33
|
cliToolInstall,
|
|
32
34
|
cliToolUninstall,
|
|
33
35
|
cliToolUpdate,
|
|
36
|
+
isPluginManagedItem,
|
|
37
|
+
ownedCliTool,
|
|
38
|
+
resolveCatalogue,
|
|
39
|
+
unownedCatalogue,
|
|
34
40
|
} from "../services/cli-tool-commands.js";
|
|
35
41
|
import { versionFromPypiJson } from "../services/tool-probe.js";
|
|
36
42
|
import {
|
|
@@ -38,6 +44,7 @@ import {
|
|
|
38
44
|
type PythonCliInstaller,
|
|
39
45
|
resolvePythonCliInstaller,
|
|
40
46
|
} from "../services/toolchain.js";
|
|
47
|
+
import { parseRequires } from "../shared/plugin/hooks/lib/requires-probe.js";
|
|
41
48
|
import { type Command, formatCommand } from "../utils/command-utils.js";
|
|
42
49
|
|
|
43
50
|
const byProbe = (probe: string): PythonCliInstaller => {
|
|
@@ -47,10 +54,129 @@ const byProbe = (probe: string): PythonCliInstaller => {
|
|
|
47
54
|
};
|
|
48
55
|
const PYTHON_PROBES = PYTHON_CLI_INSTALLERS.map((i) => i.probe);
|
|
49
56
|
|
|
57
|
+
/** The catalogue entries that carry their own installer (no plugin owns them). */
|
|
58
|
+
const cliTools = unownedCatalogue().map((c) => c.tool);
|
|
59
|
+
|
|
60
|
+
/** Every requirement the repo's plugins declare, by name → the plugins declaring it. */
|
|
61
|
+
function declaredRequirements(): Map<string, string[]> {
|
|
62
|
+
const pluginsDir = join(import.meta.dir, "..", "..", "..", "..", "plugins");
|
|
63
|
+
const out = new Map<string, string[]>();
|
|
64
|
+
for (const plugin of readdirSync(pluginsDir)) {
|
|
65
|
+
let manifest: unknown;
|
|
66
|
+
try {
|
|
67
|
+
manifest = JSON.parse(
|
|
68
|
+
readFileSync(
|
|
69
|
+
join(pluginsDir, plugin, ".claude-plugin", "plugin.json"),
|
|
70
|
+
"utf8",
|
|
71
|
+
),
|
|
72
|
+
);
|
|
73
|
+
} catch {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const parsed = parseRequires(manifest);
|
|
77
|
+
if (!parsed.ok) continue;
|
|
78
|
+
for (const r of parsed.requirements) {
|
|
79
|
+
out.set(r.name, [...(out.get(r.name) ?? []), plugin]);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
50
85
|
/** The methods the resolver models, i.e. the ones that reach toolchain.ts. */
|
|
51
86
|
const RESOLVED_METHODS: InstallMethod[] = ["npm", "bun", "brew", "pip"];
|
|
52
87
|
|
|
53
88
|
describe("the catalogue is well-formed", () => {
|
|
89
|
+
test("no catalogue entry restates a requirement a plugin declares", () => {
|
|
90
|
+
// A tool a plugin declares is owned by that plugin: the catalogue keeps its
|
|
91
|
+
// display fields and `ownedBy`, and its check and installer come from the
|
|
92
|
+
// declaration. A second copy here is what drifted before.
|
|
93
|
+
const declared = declaredRequirements();
|
|
94
|
+
expect(declared.size).toBeGreaterThan(0);
|
|
95
|
+
for (const entry of catalogue) {
|
|
96
|
+
if (isOwnedEntry(entry)) {
|
|
97
|
+
const [plugin] = entry.ownedBy.split("@");
|
|
98
|
+
expect(declared.get(entry.name) ?? []).toContain(plugin);
|
|
99
|
+
expect("packageName" in entry).toBe(false);
|
|
100
|
+
expect("checkCommand" in entry).toBe(false);
|
|
101
|
+
} else {
|
|
102
|
+
expect(declared.has(entry.name)).toBe(false);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
expect(catalogue.some(isOwnedEntry)).toBe(true);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("an owned row takes its check and installer from the plugin's declaration", async () => {
|
|
109
|
+
const owned = await resolveCatalogue(
|
|
110
|
+
"/nonexistent",
|
|
111
|
+
async (pluginId, name) =>
|
|
112
|
+
pluginId === "claudish@magus"
|
|
113
|
+
? {
|
|
114
|
+
name,
|
|
115
|
+
check: [name, "--version"],
|
|
116
|
+
install: [{ via: "bun", package: name }],
|
|
117
|
+
chosen: { via: "bun", package: name },
|
|
118
|
+
}
|
|
119
|
+
: null,
|
|
120
|
+
);
|
|
121
|
+
const claudish = owned.find(
|
|
122
|
+
(c) => !isPluginManagedItem(c) && c.tool.name === "claudish",
|
|
123
|
+
);
|
|
124
|
+
expect(
|
|
125
|
+
claudish && !isPluginManagedItem(claudish) && claudish.tool,
|
|
126
|
+
).toMatchObject({
|
|
127
|
+
checkCommand: "claudish --version",
|
|
128
|
+
packageManager: "bun",
|
|
129
|
+
packageName: "claudish",
|
|
130
|
+
});
|
|
131
|
+
expect(claudish?.owned?.sources).toEqual(["plugin:claudish@magus"]);
|
|
132
|
+
// An owned entry whose declaration cannot be read here is left out.
|
|
133
|
+
const names = owned.map((c) =>
|
|
134
|
+
isPluginManagedItem(c) ? c.entry.name : c.tool.name,
|
|
135
|
+
);
|
|
136
|
+
expect(names).not.toContain("mnemex");
|
|
137
|
+
// One whose chosen install is not version-managed cannot be a row.
|
|
138
|
+
const entry = catalogue.find((e) => e.name === "claudish");
|
|
139
|
+
expect(
|
|
140
|
+
entry &&
|
|
141
|
+
ownedCliTool(entry, {
|
|
142
|
+
name: "claudish",
|
|
143
|
+
check: ["claudish"],
|
|
144
|
+
install: [],
|
|
145
|
+
chosen: { via: "apt", packages: ["claudish"] },
|
|
146
|
+
}),
|
|
147
|
+
).toBeNull();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("an owned tool its plugin installs from a release is a plugin-managed row, in catalogue order", async () => {
|
|
151
|
+
const release = {
|
|
152
|
+
via: "release" as const,
|
|
153
|
+
repo: "MadAppGang/claudish",
|
|
154
|
+
tag: "v10.3.0",
|
|
155
|
+
asset: "claudish-{os}-{arch}",
|
|
156
|
+
binary: "claudish",
|
|
157
|
+
sha256: { linux_amd64: "a".repeat(64) },
|
|
158
|
+
};
|
|
159
|
+
const items = await resolveCatalogue("/nonexistent", async (_id, name) => ({
|
|
160
|
+
name,
|
|
161
|
+
check: [name, "--version"],
|
|
162
|
+
install: [release],
|
|
163
|
+
chosen: release,
|
|
164
|
+
}));
|
|
165
|
+
const names = items.map((c) =>
|
|
166
|
+
isPluginManagedItem(c) ? c.entry.name : c.tool.name,
|
|
167
|
+
);
|
|
168
|
+
// Nothing dropped: the catalogue, in its own order.
|
|
169
|
+
expect(names).toEqual(catalogue.map((e) => e.name));
|
|
170
|
+
const managed = items.filter(isPluginManagedItem);
|
|
171
|
+
expect(managed.map((m) => [m.entry.name, m.entry.ownedBy])).toEqual(
|
|
172
|
+
catalogue.filter(isOwnedEntry).map((e) => [e.name, e.ownedBy]),
|
|
173
|
+
);
|
|
174
|
+
for (const m of managed) {
|
|
175
|
+
expect(m.owned.chosen).toEqual(release);
|
|
176
|
+
expect(m.owned.sources).toEqual([`plugin:${m.entry.ownedBy}`]);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
54
180
|
test("it has entries, and each names a package manager and a package", () => {
|
|
55
181
|
// Guard: every assertion below iterates the catalogue, so an empty or
|
|
56
182
|
// malformed one would make them all pass vacuously.
|
|
@@ -71,11 +197,10 @@ describe("no builder ever emits a bare `pip`", () => {
|
|
|
71
197
|
const cmd = cliToolInstall(tool);
|
|
72
198
|
expect(cmd.cmd).not.toBe("pip");
|
|
73
199
|
if (tool.packageManager === "pip") {
|
|
74
|
-
//
|
|
75
|
-
// the
|
|
76
|
-
|
|
77
|
-
expect(
|
|
78
|
-
expect(cmd.cmd).toBe(resolvePythonCliInstaller().probe);
|
|
200
|
+
// requires-probe renders the profile-only pip kind as a uv tool
|
|
201
|
+
// install: the package gets its own env, immune to PEP 668.
|
|
202
|
+
expect(cmd.cmd).toBe("uv");
|
|
203
|
+
expect(cmd.args.slice(0, 2)).toEqual(["tool", "install"]);
|
|
79
204
|
}
|
|
80
205
|
}
|
|
81
206
|
});
|
|
@@ -7,7 +7,6 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { describe, expect, test } from "bun:test";
|
|
10
|
-
import { cliTools } from "../data/cli-tools.js";
|
|
11
10
|
import { cliToolInstall } from "../services/cli-tool-commands.js";
|
|
12
11
|
import {
|
|
13
12
|
type ToolFacts,
|
|
@@ -18,13 +17,17 @@ import { binCommandFor } from "../services/update-engine.js";
|
|
|
18
17
|
import { planBinUpdates } from "../services/update-plan.js";
|
|
19
18
|
import type { ResolvedBin } from "../types/index.js";
|
|
20
19
|
import {
|
|
20
|
+
type PluginManagedRow,
|
|
21
21
|
buildToolRow,
|
|
22
22
|
cliToolStatusText,
|
|
23
23
|
countToolRows,
|
|
24
|
+
isPluginManagedRow,
|
|
25
|
+
rowName,
|
|
24
26
|
} from "../ui/adapters/cliToolsAdapter.js";
|
|
27
|
+
import { testCatalogue } from "./fixtures/bins.js";
|
|
25
28
|
|
|
26
29
|
const catalogue = (name: string) => {
|
|
27
|
-
const tool =
|
|
30
|
+
const tool = testCatalogue().find((t) => t.name === name);
|
|
28
31
|
if (!tool) throw new Error(`no catalogue tool ${name}`);
|
|
29
32
|
return tool;
|
|
30
33
|
};
|
|
@@ -294,4 +297,51 @@ describe("countToolRows — header counts from states", () => {
|
|
|
294
297
|
unverified: 1,
|
|
295
298
|
});
|
|
296
299
|
});
|
|
300
|
+
|
|
301
|
+
test("a plugin-managed row counts from its probe answer, and never as an update", () => {
|
|
302
|
+
const managed = (
|
|
303
|
+
presence: PluginManagedRow["presence"],
|
|
304
|
+
): PluginManagedRow => ({
|
|
305
|
+
managedBy: "claudish@magus",
|
|
306
|
+
entry: {
|
|
307
|
+
name: "claudish",
|
|
308
|
+
displayName: "Claudish",
|
|
309
|
+
description: "",
|
|
310
|
+
website: "",
|
|
311
|
+
category: "ai-coding",
|
|
312
|
+
ownedBy: "claudish@magus",
|
|
313
|
+
},
|
|
314
|
+
owned: {
|
|
315
|
+
name: "claudish",
|
|
316
|
+
check: ["claudish", "--version"],
|
|
317
|
+
install: [],
|
|
318
|
+
chosen: null,
|
|
319
|
+
sources: ["plugin:claudish@magus"],
|
|
320
|
+
},
|
|
321
|
+
presence,
|
|
322
|
+
installText: "",
|
|
323
|
+
});
|
|
324
|
+
const rows = [
|
|
325
|
+
managed({ name: "claudish", status: "present" }),
|
|
326
|
+
managed({ name: "claudish", status: "off-path", prefix: "~/.local/bin" }),
|
|
327
|
+
managed({ name: "claudish", status: "missing" }),
|
|
328
|
+
managed({ name: "claudish", status: "unknown", reason: "slow" }),
|
|
329
|
+
managed(null),
|
|
330
|
+
];
|
|
331
|
+
expect(countToolRows(rows)).toEqual({
|
|
332
|
+
installed: 2,
|
|
333
|
+
updates: 0,
|
|
334
|
+
unverified: 1,
|
|
335
|
+
});
|
|
336
|
+
expect(rows.every(isPluginManagedRow)).toBe(true);
|
|
337
|
+
expect(rowName(rows[0] as PluginManagedRow)).toBe("claudish");
|
|
338
|
+
const tool = catalogue("mnemex");
|
|
339
|
+
const tracked = buildToolRow(
|
|
340
|
+
tool,
|
|
341
|
+
specForCatalogTool(tool, null, undefined),
|
|
342
|
+
undefined,
|
|
343
|
+
);
|
|
344
|
+
expect(isPluginManagedRow(tracked)).toBe(false);
|
|
345
|
+
expect(rowName(tracked)).toBe("mnemex");
|
|
346
|
+
});
|
|
297
347
|
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A catalogue tool its plugin installs without a version magus can track
|
|
3
|
+
* (claudish and mnemex from their releases off Homebrew) is drawn read-only:
|
|
4
|
+
* the row says which plugin manages it, the detail pane says it cannot be
|
|
5
|
+
* acted on here, and no Enter action is drawn. Rendered through OpenTUI's
|
|
6
|
+
* test renderer; no IO.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from "bun:test";
|
|
10
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
11
|
+
import { act } from "react";
|
|
12
|
+
import type { PluginManagedRow } from "../ui/adapters/cliToolsAdapter.js";
|
|
13
|
+
import {
|
|
14
|
+
renderCliToolDetail,
|
|
15
|
+
renderCliToolRow,
|
|
16
|
+
} from "../ui/renderers/cliToolRenderers.js";
|
|
17
|
+
|
|
18
|
+
function row(presence: PluginManagedRow["presence"]): PluginManagedRow {
|
|
19
|
+
return {
|
|
20
|
+
managedBy: "claudish@magus",
|
|
21
|
+
entry: {
|
|
22
|
+
name: "claudish",
|
|
23
|
+
displayName: "Claudish",
|
|
24
|
+
description: "Run Claude Code with any model",
|
|
25
|
+
website: "https://github.com/MadAppGang/claudish",
|
|
26
|
+
category: "ai-coding",
|
|
27
|
+
ownedBy: "claudish@magus",
|
|
28
|
+
},
|
|
29
|
+
owned: {
|
|
30
|
+
name: "claudish",
|
|
31
|
+
check: ["claudish", "--version"],
|
|
32
|
+
install: [],
|
|
33
|
+
chosen: null,
|
|
34
|
+
sources: ["plugin:claudish@magus"],
|
|
35
|
+
},
|
|
36
|
+
presence,
|
|
37
|
+
installText: "cd $(mktemp -d) && curl -fsSLO https://example/claudish",
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function frame(node: React.ReactNode, width = 80): Promise<string> {
|
|
42
|
+
const { captureCharFrame, renderOnce, renderer } = await testRender(
|
|
43
|
+
<box flexDirection="column">{node}</box>,
|
|
44
|
+
{ width, height: 20 },
|
|
45
|
+
);
|
|
46
|
+
await act(async () => {
|
|
47
|
+
await renderOnce();
|
|
48
|
+
await renderOnce();
|
|
49
|
+
});
|
|
50
|
+
const out = captureCharFrame();
|
|
51
|
+
await act(async () => renderer.destroy());
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe("a plugin-managed CLI tool row", () => {
|
|
56
|
+
test("the row names the plugin that manages it", async () => {
|
|
57
|
+
const out = await frame(
|
|
58
|
+
renderCliToolRow(row({ name: "claudish", status: "present" }), 0, false),
|
|
59
|
+
);
|
|
60
|
+
expect(out).toContain("● Claudish · managed by claudish@magus");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("missing: the detail says so, shows the plugin's install, and offers no Enter", async () => {
|
|
64
|
+
const out = await frame(
|
|
65
|
+
renderCliToolDetail(row({ name: "claudish", status: "missing" }), 70),
|
|
66
|
+
70,
|
|
67
|
+
);
|
|
68
|
+
expect(out).toContain("Status ○ Not installed");
|
|
69
|
+
expect(out).toContain("Managed by claudish@magus");
|
|
70
|
+
expect(out).toContain("Install cd $(mktemp -d)");
|
|
71
|
+
// The note wraps at the pane's width; read it as prose.
|
|
72
|
+
const prose = out.replace(/\s+/g, " ");
|
|
73
|
+
expect(prose).toContain(
|
|
74
|
+
"claudish@magus installs claudish and decides its version; it cannot be installed, updated or removed from here. magus doctor --fix installs it when it is missing.",
|
|
75
|
+
);
|
|
76
|
+
expect(out).not.toContain("Enter");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("while it is being checked, the row says so", async () => {
|
|
80
|
+
const out = await frame(renderCliToolRow(row(null), 0, false));
|
|
81
|
+
expect(out).toContain("○ Claudish ...");
|
|
82
|
+
});
|
|
83
|
+
});
|