magus-cli 7.9.1 → 7.10.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/package.json +4 -4
- package/src/__tests__/blind/c15-probe-ownership.test.ts +3 -3
- package/src/__tests__/blind/plugin-deps/doctor.test.ts +1 -0
- package/src/__tests__/cli-tools-adapter.test.ts +25 -6
- package/src/__tests__/plugin-mcp-source-shapes.test.ts +250 -0
- package/src/__tests__/tool-probe.test.ts +21 -13
- package/src/__tests__/update-plan.test.ts +19 -3
- package/src/__tests__/worktree-profiles.test.ts +583 -0
- package/src/cli/doctor.ts +24 -2
- package/src/cli/update-view.ts +72 -0
- package/src/cli/update.ts +21 -0
- package/src/cli/worktrees.ts +32 -0
- package/src/services/git-worktree.ts +41 -0
- package/src/services/plugin-mcp-config.ts +3 -1
- package/src/services/tool-probe.ts +41 -7
- package/src/services/update-plan.ts +9 -4
- package/src/services/worktree-profiles.ts +181 -0
- package/src/ui/App.tsx +32 -1
- package/src/ui/adapters/cliToolsAdapter.ts +15 -6
- package/src/ui/adapters/startupReports.ts +22 -2
- package/src/ui/renderers/cliToolRenderers.tsx +13 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "magus-cli",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.10.1",
|
|
4
4
|
"description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/main.tsx",
|
|
@@ -65,8 +65,8 @@
|
|
|
65
65
|
"typescript": "^5.6.3"
|
|
66
66
|
},
|
|
67
67
|
"optionalDependencies": {
|
|
68
|
-
"magus-cli-darwin-arm64": "7.
|
|
69
|
-
"magus-cli-darwin-x64": "7.
|
|
70
|
-
"magus-cli-linux-x64": "7.
|
|
68
|
+
"magus-cli-darwin-arm64": "7.10.1",
|
|
69
|
+
"magus-cli-darwin-x64": "7.10.1",
|
|
70
|
+
"magus-cli-linux-x64": "7.10.1"
|
|
71
71
|
}
|
|
72
72
|
}
|
|
@@ -361,11 +361,11 @@ describe("C15 (c) — a crush binary that npm does not own", () => {
|
|
|
361
361
|
expect(facts.managed).not.toBe(true);
|
|
362
362
|
});
|
|
363
363
|
|
|
364
|
-
test("R9.7 classifyTool keeps the not-managed veto for it (
|
|
364
|
+
test("R9.7 classifyTool keeps the not-managed veto for it (external / not-managed)", async () => {
|
|
365
365
|
const { facts } = await probe(CRUSH, elsewhere());
|
|
366
366
|
const status = classifyTool(CRUSH, facts);
|
|
367
367
|
expect({ state: status.state, reason: status.reason }).toEqual({
|
|
368
|
-
state: "
|
|
368
|
+
state: "external",
|
|
369
369
|
reason: "not-managed",
|
|
370
370
|
});
|
|
371
371
|
});
|
|
@@ -526,7 +526,7 @@ describe("C15 (e) — a claude binary outside the native layout and outside node
|
|
|
526
526
|
expect(facts.managed).not.toBe(true);
|
|
527
527
|
const status = classifyTool(CLAUDE, facts);
|
|
528
528
|
expect({ state: status.state, reason: status.reason }).toEqual({
|
|
529
|
-
state: "
|
|
529
|
+
state: "external",
|
|
530
530
|
reason: "not-managed",
|
|
531
531
|
});
|
|
532
532
|
});
|
|
@@ -174,6 +174,7 @@ describe("magus doctor black-box contract", () => {
|
|
|
174
174
|
"plugins_needing_update",
|
|
175
175
|
"schema",
|
|
176
176
|
"unfixable",
|
|
177
|
+
"worktreesWithoutProfile",
|
|
177
178
|
]);
|
|
178
179
|
expect(new Set(report.plugins)).toEqual(
|
|
179
180
|
new Set(["user-tool@test", "project-tool@test", "local-tool@test"]),
|
|
@@ -197,7 +197,7 @@ describe("buildToolRow — which installer a catalogue row is judged through", (
|
|
|
197
197
|
expect(row.installCommand).toEqual(cliToolInstall(codex));
|
|
198
198
|
});
|
|
199
199
|
|
|
200
|
-
test("detected pnpm → the catalogue's via, and a not-managed row
|
|
200
|
+
test("detected pnpm → the catalogue's via, and a not-managed row in the user's terms", () => {
|
|
201
201
|
const opencode = catalogue("opencode");
|
|
202
202
|
const install = { primary: "pnpm" as const, all: ["pnpm" as const] };
|
|
203
203
|
const spec = specForCatalogTool(opencode, null, install);
|
|
@@ -210,8 +210,13 @@ describe("buildToolRow — which installer a catalogue row is judged through", (
|
|
|
210
210
|
owner: "npm (root /Users/j/.local/lib/node_modules)",
|
|
211
211
|
});
|
|
212
212
|
const row = buildToolRow(opencode, spec, f, install);
|
|
213
|
+
expect(row.status?.state).toBe("external");
|
|
213
214
|
expect(row.status?.reason).toBe("not-managed");
|
|
214
|
-
|
|
215
|
+
// No path and no installer directory: the user acts on neither.
|
|
216
|
+
expect(row.status?.note).toBe(
|
|
217
|
+
"installed outside npm, so magus leaves it as is",
|
|
218
|
+
);
|
|
219
|
+
expect(row.status?.note).not.toContain("/");
|
|
215
220
|
// Enter still follows how it IS installed (pnpm).
|
|
216
221
|
expect(row.updateCommand?.cmd).toBe("pnpm");
|
|
217
222
|
});
|
|
@@ -249,16 +254,28 @@ describe("cliToolStatusText — the row's words", () => {
|
|
|
249
254
|
primary: "npm",
|
|
250
255
|
all: ["npm"],
|
|
251
256
|
});
|
|
252
|
-
const f = facts("crush", { managed:
|
|
257
|
+
const f = facts("crush", { managed: null });
|
|
253
258
|
const row = buildToolRow(tool, spec, f);
|
|
254
259
|
const text = cliToolStatusText(row);
|
|
255
260
|
expect(text.tone).toBe("warning");
|
|
256
|
-
expect(text.suffix).toBe(" ? not
|
|
257
|
-
// The note
|
|
261
|
+
expect(text.suffix).toBe(" ? owner not known");
|
|
262
|
+
// The note is for the detail pane's "Why", not the row.
|
|
258
263
|
expect(row.status?.note).toBe(classifyTool(spec, f).note);
|
|
259
264
|
expect(text.suffix).not.toContain("/");
|
|
260
265
|
});
|
|
261
266
|
|
|
267
|
+
test("external renders muted, naming the installer it is outside of", () => {
|
|
268
|
+
const tool = catalogue("crush");
|
|
269
|
+
const spec = specForCatalogTool(tool, null, {
|
|
270
|
+
primary: "npm",
|
|
271
|
+
all: ["npm"],
|
|
272
|
+
});
|
|
273
|
+
const row = buildToolRow(tool, spec, facts("crush", { managed: false }));
|
|
274
|
+
const text = cliToolStatusText(row);
|
|
275
|
+
expect(text.tone).toBe("muted");
|
|
276
|
+
expect(text.suffix).toBe(" · outside npm");
|
|
277
|
+
});
|
|
278
|
+
|
|
262
279
|
test("upgrade names the target once, with one v", () => {
|
|
263
280
|
const tool = catalogue("mnemex");
|
|
264
281
|
const spec = specForCatalogTool(tool, null, {
|
|
@@ -287,12 +304,14 @@ describe("countToolRows — header counts from states", () => {
|
|
|
287
304
|
const rows = [
|
|
288
305
|
buildToolRow(tool, spec, facts("mnemex", { behind: false })),
|
|
289
306
|
buildToolRow(tool, spec, facts("mnemex", { behind: true })),
|
|
307
|
+
buildToolRow(tool, spec, facts("mnemex", { managed: null })),
|
|
308
|
+
// Another installer's: installed, and not "unverified".
|
|
290
309
|
buildToolRow(tool, spec, facts("mnemex", { managed: false })),
|
|
291
310
|
buildToolRow(tool, spec, facts("mnemex", { present: false })),
|
|
292
311
|
buildToolRow(tool, spec, undefined),
|
|
293
312
|
];
|
|
294
313
|
expect(countToolRows(rows)).toEqual({
|
|
295
|
-
installed:
|
|
314
|
+
installed: 4,
|
|
296
315
|
updates: 1,
|
|
297
316
|
unverified: 1,
|
|
298
317
|
});
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marketplace plugin `source` comes in two shapes: a string path
|
|
3
|
+
* ("./plugins/foo") or an object (git-subdir / github / url). Only a string
|
|
4
|
+
* source has an on-disk MCP config under the marketplace clone.
|
|
5
|
+
*
|
|
6
|
+
* Regression guarded: the Plugins screen does
|
|
7
|
+
* src = await getPluginSourcePath(mp, name); if (!src) skip;
|
|
8
|
+
* await getPluginEnvRequirements(mp, src)
|
|
9
|
+
* and an object source used to throw `pluginSource.replace is not a function`.
|
|
10
|
+
*
|
|
11
|
+
* The module resolves the home directory at import time, and Bun's
|
|
12
|
+
* os.homedir() ignores a HOME changed at runtime, so every scenario runs in a
|
|
13
|
+
* child bun process with HOME pointed at a temp directory.
|
|
14
|
+
*/
|
|
15
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
16
|
+
import { spawnSync } from "node:child_process";
|
|
17
|
+
import { promises as fs } from "node:fs";
|
|
18
|
+
import os from "node:os";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
|
|
21
|
+
const MODULE_PATH = path.resolve(
|
|
22
|
+
import.meta.dir,
|
|
23
|
+
"../services/plugin-mcp-config.ts",
|
|
24
|
+
);
|
|
25
|
+
const MP = "testmp";
|
|
26
|
+
|
|
27
|
+
const CHILD_SCRIPT = `
|
|
28
|
+
import { getPluginSourcePath, getPluginEnvRequirements } from ${JSON.stringify(MODULE_PATH)};
|
|
29
|
+
|
|
30
|
+
const input = JSON.parse(process.env.MCP_SHAPES_INPUT ?? "{}");
|
|
31
|
+
const out = {};
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const sp = await getPluginSourcePath(input.mp, input.plugin);
|
|
35
|
+
out.sourcePathType = typeof sp;
|
|
36
|
+
out.sourcePath = sp === undefined ? null : sp;
|
|
37
|
+
out.sourcePathOk = true;
|
|
38
|
+
} catch (e) {
|
|
39
|
+
out.sourcePathOk = false;
|
|
40
|
+
out.sourcePathError = String(e && e.message ? e.message : e);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The exact Plugins-screen sequence.
|
|
44
|
+
try {
|
|
45
|
+
const src = await getPluginSourcePath(input.mp, input.plugin);
|
|
46
|
+
if (!src) {
|
|
47
|
+
out.sequence = { ok: true, skipped: true };
|
|
48
|
+
} else {
|
|
49
|
+
const reqs = await getPluginEnvRequirements(input.mp, src);
|
|
50
|
+
out.sequence = { ok: true, skipped: false, names: reqs.map((r) => r.name) };
|
|
51
|
+
}
|
|
52
|
+
} catch (e) {
|
|
53
|
+
out.sequence = { ok: false, error: String(e && e.message ? e.message : e) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (typeof input.direct === "string") {
|
|
57
|
+
try {
|
|
58
|
+
const reqs = await getPluginEnvRequirements(input.mp, input.direct);
|
|
59
|
+
out.direct = {
|
|
60
|
+
ok: true,
|
|
61
|
+
reqs: reqs.map((r) => ({ name: r.name, serverName: r.serverName })),
|
|
62
|
+
};
|
|
63
|
+
} catch (e) {
|
|
64
|
+
out.direct = { ok: false, error: String(e && e.message ? e.message : e) };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
process.stdout.write("__RESULT__" + JSON.stringify(out) + "\\n");
|
|
69
|
+
`;
|
|
70
|
+
|
|
71
|
+
interface ChildResult {
|
|
72
|
+
sourcePathOk: boolean;
|
|
73
|
+
sourcePathError?: string;
|
|
74
|
+
sourcePathType?: string;
|
|
75
|
+
sourcePath?: unknown;
|
|
76
|
+
sequence: {
|
|
77
|
+
ok: boolean;
|
|
78
|
+
skipped?: boolean;
|
|
79
|
+
names?: string[];
|
|
80
|
+
error?: string;
|
|
81
|
+
};
|
|
82
|
+
direct?: {
|
|
83
|
+
ok: boolean;
|
|
84
|
+
reqs?: Array<{ name: string; serverName: string }>;
|
|
85
|
+
error?: string;
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let tmpHome: string;
|
|
90
|
+
let scriptPath: string;
|
|
91
|
+
|
|
92
|
+
async function writeMarketplace(
|
|
93
|
+
plugins: Array<{ name: string; source: unknown }>,
|
|
94
|
+
): Promise<void> {
|
|
95
|
+
const dir = path.join(
|
|
96
|
+
tmpHome,
|
|
97
|
+
".claude",
|
|
98
|
+
"plugins",
|
|
99
|
+
"marketplaces",
|
|
100
|
+
MP,
|
|
101
|
+
".claude-plugin",
|
|
102
|
+
);
|
|
103
|
+
await fs.mkdir(dir, { recursive: true });
|
|
104
|
+
await fs.writeFile(
|
|
105
|
+
path.join(dir, "marketplace.json"),
|
|
106
|
+
JSON.stringify({ name: MP, plugins }, null, 2),
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function writeMcpConfig(
|
|
111
|
+
relSource: string,
|
|
112
|
+
config: Record<string, unknown>,
|
|
113
|
+
): Promise<void> {
|
|
114
|
+
const dir = path.join(
|
|
115
|
+
tmpHome,
|
|
116
|
+
".claude",
|
|
117
|
+
"plugins",
|
|
118
|
+
"marketplaces",
|
|
119
|
+
MP,
|
|
120
|
+
relSource.replace(/^\.\//, ""),
|
|
121
|
+
"mcp-servers",
|
|
122
|
+
);
|
|
123
|
+
await fs.mkdir(dir, { recursive: true });
|
|
124
|
+
await fs.writeFile(
|
|
125
|
+
path.join(dir, "mcp-config.json"),
|
|
126
|
+
JSON.stringify(config, null, 2),
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function runChild(input: {
|
|
131
|
+
mp: string;
|
|
132
|
+
plugin: string;
|
|
133
|
+
direct?: string;
|
|
134
|
+
}): ChildResult {
|
|
135
|
+
const res = spawnSync("bun", ["--no-env-file", scriptPath], {
|
|
136
|
+
cwd: tmpHome,
|
|
137
|
+
env: {
|
|
138
|
+
...process.env,
|
|
139
|
+
HOME: tmpHome,
|
|
140
|
+
MCP_SHAPES_INPUT: JSON.stringify(input),
|
|
141
|
+
},
|
|
142
|
+
encoding: "utf-8",
|
|
143
|
+
timeout: 30_000,
|
|
144
|
+
});
|
|
145
|
+
const stdout = res.stdout ?? "";
|
|
146
|
+
const line = stdout.split("\n").find((l) => l.startsWith("__RESULT__"));
|
|
147
|
+
if (!line) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
`child produced no result (status ${res.status})\nstdout:\n${stdout}\nstderr:\n${res.stderr ?? ""}`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
return JSON.parse(line.slice("__RESULT__".length)) as ChildResult;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
beforeEach(async () => {
|
|
156
|
+
tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), "mcp-source-shapes-"));
|
|
157
|
+
scriptPath = path.join(tmpHome, "child.ts");
|
|
158
|
+
await fs.writeFile(scriptPath, CHILD_SCRIPT);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
afterEach(async () => {
|
|
162
|
+
await fs.rm(tmpHome, { recursive: true, force: true });
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe("string source", () => {
|
|
166
|
+
test("getPluginSourcePath returns the string and env requirements resolve from its mcp-config", async () => {
|
|
167
|
+
await writeMarketplace([{ name: "foo", source: "./plugins/foo" }]);
|
|
168
|
+
await writeMcpConfig("./plugins/foo", {
|
|
169
|
+
"foo-server": {
|
|
170
|
+
command: "node",
|
|
171
|
+
args: ["server.js"],
|
|
172
|
+
env: { FOO_TOKEN: "${FOO_TOKEN}" },
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const r = runChild({ mp: MP, plugin: "foo", direct: "./plugins/foo" });
|
|
177
|
+
|
|
178
|
+
expect(r.sourcePathOk).toBe(true);
|
|
179
|
+
expect(r.sourcePathType).toBe("string");
|
|
180
|
+
expect(r.sourcePath).toBe("./plugins/foo");
|
|
181
|
+
|
|
182
|
+
expect(r.direct?.ok).toBe(true);
|
|
183
|
+
const names = (r.direct?.reqs ?? []).map((q) => q.name);
|
|
184
|
+
expect(names).toContain("FOO_TOKEN");
|
|
185
|
+
const foo = r.direct?.reqs?.find((q) => q.name === "FOO_TOKEN");
|
|
186
|
+
expect(foo?.serverName).toBe("foo-server");
|
|
187
|
+
|
|
188
|
+
// The Plugins-screen sequence reaches the same requirement.
|
|
189
|
+
expect(r.sequence.ok).toBe(true);
|
|
190
|
+
expect(r.sequence.skipped).toBe(false);
|
|
191
|
+
expect(r.sequence.names).toContain("FOO_TOKEN");
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe("object source", () => {
|
|
196
|
+
const shapes: Array<[string, Record<string, unknown>]> = [
|
|
197
|
+
[
|
|
198
|
+
"git-subdir",
|
|
199
|
+
{
|
|
200
|
+
source: "git-subdir",
|
|
201
|
+
url: "https://github.com/expo/skills.git",
|
|
202
|
+
path: "plugins/expo",
|
|
203
|
+
ref: "main",
|
|
204
|
+
},
|
|
205
|
+
],
|
|
206
|
+
["github", { source: "github", repo: "owner/name" }],
|
|
207
|
+
["url", { source: "url", url: "https://example.com/x.git" }],
|
|
208
|
+
];
|
|
209
|
+
|
|
210
|
+
for (const [label, source] of shapes) {
|
|
211
|
+
test(`${label}: getPluginSourcePath returns undefined, not the object`, async () => {
|
|
212
|
+
await writeMarketplace([
|
|
213
|
+
{ name: "foo", source: "./plugins/foo" },
|
|
214
|
+
{ name: "remote", source },
|
|
215
|
+
]);
|
|
216
|
+
await writeMcpConfig("./plugins/foo", {
|
|
217
|
+
"foo-server": { env: { FOO_TOKEN: "${FOO_TOKEN}" } },
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const r = runChild({ mp: MP, plugin: "remote" });
|
|
221
|
+
|
|
222
|
+
expect(r.sourcePathError).toBeUndefined();
|
|
223
|
+
expect(r.sourcePathOk).toBe(true);
|
|
224
|
+
expect(r.sourcePathType).toBe("undefined");
|
|
225
|
+
expect(r.sourcePathType).not.toBe("object");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test(`${label}: the Plugins-screen sequence resolves without throwing`, async () => {
|
|
229
|
+
await writeMarketplace([{ name: "remote", source }]);
|
|
230
|
+
|
|
231
|
+
const r = runChild({ mp: MP, plugin: "remote" });
|
|
232
|
+
|
|
233
|
+
expect(r.sequence.error).toBeUndefined();
|
|
234
|
+
expect(r.sequence.ok).toBe(true);
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
describe("unknown plugin", () => {
|
|
240
|
+
test("getPluginSourcePath returns undefined for a name not in the marketplace", async () => {
|
|
241
|
+
await writeMarketplace([{ name: "foo", source: "./plugins/foo" }]);
|
|
242
|
+
|
|
243
|
+
const r = runChild({ mp: MP, plugin: "does-not-exist" });
|
|
244
|
+
|
|
245
|
+
expect(r.sourcePathOk).toBe(true);
|
|
246
|
+
expect(r.sourcePathType).toBe("undefined");
|
|
247
|
+
expect(r.sequence.ok).toBe(true);
|
|
248
|
+
expect(r.sequence.skipped).toBe(true);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
@@ -226,7 +226,7 @@ describe("classifyTool — every rule of §4.3, in order", () => {
|
|
|
226
226
|
);
|
|
227
227
|
});
|
|
228
228
|
|
|
229
|
-
test("6. not managed →
|
|
229
|
+
test("6. not managed → external, in the user's terms", () => {
|
|
230
230
|
const s = classifyTool(
|
|
231
231
|
spec({ name: "crush" }),
|
|
232
232
|
facts({
|
|
@@ -237,16 +237,24 @@ describe("classifyTool — every rule of §4.3, in order", () => {
|
|
|
237
237
|
owner: "npm (root /Users/j/.local/lib/node_modules)",
|
|
238
238
|
}),
|
|
239
239
|
);
|
|
240
|
-
expect(s).toMatchObject({ state: "
|
|
241
|
-
expect(s.note).
|
|
242
|
-
expect(s.note).toContain("
|
|
243
|
-
expect(s.note).toContain("second copy");
|
|
240
|
+
expect(s).toMatchObject({ state: "external", reason: "not-managed" });
|
|
241
|
+
expect(s.note).toBe("installed outside npm, so magus leaves it as is");
|
|
242
|
+
expect(s.note).not.toContain("/");
|
|
244
243
|
});
|
|
245
244
|
|
|
246
|
-
test("6. managed === null (owner undeterminable) is also a veto", () => {
|
|
245
|
+
test("6. managed === null (owner undeterminable) is also a veto, as unknown", () => {
|
|
247
246
|
expect(
|
|
248
|
-
classifyTool(spec(), facts({ managed: null, behind: true }))
|
|
249
|
-
).
|
|
247
|
+
classifyTool(spec(), facts({ managed: null, behind: true })),
|
|
248
|
+
).toMatchObject({ state: "unknown", reason: "not-managed" });
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("6. brew is named as the user knows it", () => {
|
|
252
|
+
expect(
|
|
253
|
+
classifyTool(
|
|
254
|
+
spec({ via: "brew" }),
|
|
255
|
+
facts({ managed: false, behind: true }),
|
|
256
|
+
).note,
|
|
257
|
+
).toBe("installed outside Homebrew, so magus leaves it as is");
|
|
250
258
|
});
|
|
251
259
|
|
|
252
260
|
test("7. held back → unknown, with the hold as the note", () => {
|
|
@@ -364,7 +372,7 @@ describe("classifyTool — every rule of §4.3, in order", () => {
|
|
|
364
372
|
];
|
|
365
373
|
for (const [s, f] of cases) {
|
|
366
374
|
const status = classifyTool(spec(s), facts(f));
|
|
367
|
-
expect(["unknown", "upgrade"]).toContain(status.state);
|
|
375
|
+
expect(["unknown", "upgrade", "external"]).toContain(status.state);
|
|
368
376
|
expect(status.note?.length ?? 0).toBeGreaterThan(0);
|
|
369
377
|
}
|
|
370
378
|
});
|
|
@@ -866,11 +874,11 @@ describe("probeTool — ownership decides managed", () => {
|
|
|
866
874
|
const [f] = await probeTools([hostile], io);
|
|
867
875
|
expect(io.calls).toEqual(["npm root -g"]);
|
|
868
876
|
expect(f?.installed).toBeNull();
|
|
869
|
-
//
|
|
870
|
-
//
|
|
871
|
-
//
|
|
877
|
+
// Another installer's file ⇒ external, so it is reported and never
|
|
878
|
+
// applied. The reason is ownership (R9.9), not the unread version that
|
|
879
|
+
// follows from it.
|
|
872
880
|
expect(classifyTool(hostile, f!)).toMatchObject({
|
|
873
|
-
state: "
|
|
881
|
+
state: "external",
|
|
874
882
|
reason: "not-managed",
|
|
875
883
|
});
|
|
876
884
|
|
|
@@ -502,7 +502,7 @@ describe("planIsBehind — the --check gate", () => {
|
|
|
502
502
|
const p = plan({
|
|
503
503
|
bins: planBinUpdates(
|
|
504
504
|
[{ name: "crush", via: "npm", sources: [] }],
|
|
505
|
-
[toolFacts("crush", { managed:
|
|
505
|
+
[toolFacts("crush", { managed: null })],
|
|
506
506
|
),
|
|
507
507
|
});
|
|
508
508
|
expect(p.bins[0]!.action).toBe("unknown");
|
|
@@ -510,6 +510,19 @@ describe("planIsBehind — the --check gate", () => {
|
|
|
510
510
|
expect(planIsBehind(p)).toBe(false);
|
|
511
511
|
});
|
|
512
512
|
|
|
513
|
+
test("an EXTERNAL tool (another installer's) is no work, and not unknown", () => {
|
|
514
|
+
const p = plan({
|
|
515
|
+
bins: planBinUpdates(
|
|
516
|
+
[{ name: "crush", via: "npm", sources: [] }],
|
|
517
|
+
[toolFacts("crush", { managed: false, behind: true })],
|
|
518
|
+
),
|
|
519
|
+
});
|
|
520
|
+
expect(p.bins[0]!.action).toBe("external");
|
|
521
|
+
expect(planHasWork(p)).toBe(false);
|
|
522
|
+
expect(planIsBehind(p)).toBe(false);
|
|
523
|
+
expect(summarizePlan(p)).toMatchObject({ unknownTools: 0, kept: 1 });
|
|
524
|
+
});
|
|
525
|
+
|
|
513
526
|
test("a MISSING binary does fail the gate", () => {
|
|
514
527
|
const p = plan({
|
|
515
528
|
bins: planBinUpdates(
|
|
@@ -946,11 +959,13 @@ describe("planHasWork / summarizePlan", () => {
|
|
|
946
959
|
{ name: "crush", via: "npm", sources: [] },
|
|
947
960
|
{ name: "claude", via: "npm", sources: [] },
|
|
948
961
|
{ name: "tmux", via: "brew", sources: [] },
|
|
962
|
+
{ name: "zsh", via: "brew", sources: [] },
|
|
949
963
|
],
|
|
950
964
|
[
|
|
951
|
-
toolFacts("crush", { managed:
|
|
952
|
-
toolFacts("claude", { managed:
|
|
965
|
+
toolFacts("crush", { managed: null }),
|
|
966
|
+
toolFacts("claude", { managed: null }),
|
|
953
967
|
toolFacts("tmux", { behind: false }),
|
|
968
|
+
toolFacts("zsh", { managed: false }),
|
|
954
969
|
],
|
|
955
970
|
),
|
|
956
971
|
});
|
|
@@ -959,6 +974,7 @@ describe("planHasWork / summarizePlan", () => {
|
|
|
959
974
|
unknownTools: 2,
|
|
960
975
|
unknown: 3,
|
|
961
976
|
current: 1,
|
|
977
|
+
kept: 1,
|
|
962
978
|
});
|
|
963
979
|
});
|
|
964
980
|
});
|