magus-cli 7.10.0 → 7.10.2
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/c16-probe-adopts-node-global.test.ts +323 -0
- package/src/__tests__/blind/c17-misdirected-update-already-landed.test.ts +271 -0
- package/src/__tests__/blind/c18-probe-adopts-homebrew-keg.test.ts +345 -0
- package/src/__tests__/cli-tools-adapter.test.ts +23 -6
- package/src/__tests__/tool-probe.test.ts +21 -13
- package/src/__tests__/update-plan.test.ts +19 -3
- package/src/cli/update-view.ts +17 -14
- package/src/services/tool-probe.ts +190 -15
- package/src/services/update-engine.ts +24 -6
- package/src/services/update-plan.ts +9 -4
- package/src/ui/adapters/cliToolsAdapter.ts +15 -6
- package/src/ui/renderers/cliToolRenderers.tsx +13 -2
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blind tests, contract C18: Homebrew owns an installed tool whose file lies
|
|
3
|
+
* in `<brew prefix>/Cellar/<short name>/<version>/...`, whatever installer the
|
|
4
|
+
* spec declares. A pinned spec is not adopted (brew takes no version).
|
|
5
|
+
*
|
|
6
|
+
* Written from the spec only, through a scripted fake `ToolProbeIO`.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
import {
|
|
10
|
+
type ExecResult,
|
|
11
|
+
type FetchJsonResult,
|
|
12
|
+
type ToolProbeIO,
|
|
13
|
+
classifyTool,
|
|
14
|
+
probeTool,
|
|
15
|
+
} from "../../services/tool-probe.js";
|
|
16
|
+
import type { BinRequirement } from "../../types/index.js";
|
|
17
|
+
|
|
18
|
+
interface FakeSetup {
|
|
19
|
+
exec?: Record<string, Partial<ExecResult>>;
|
|
20
|
+
fetch?: Record<string, FetchJsonResult>;
|
|
21
|
+
files?: Record<string, string>;
|
|
22
|
+
real?: Record<string, string>;
|
|
23
|
+
onPath?: Record<string, string>;
|
|
24
|
+
env?: Record<string, string>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function fakeIO(setup: FakeSetup): ToolProbeIO & { calls: string[] } {
|
|
28
|
+
const calls: string[] = [];
|
|
29
|
+
return {
|
|
30
|
+
calls,
|
|
31
|
+
exec: async (command) => {
|
|
32
|
+
const key = [command.cmd, ...command.args].join(" ");
|
|
33
|
+
calls.push(key);
|
|
34
|
+
const h = setup.exec?.[key];
|
|
35
|
+
if (h === undefined) {
|
|
36
|
+
return {
|
|
37
|
+
code: null,
|
|
38
|
+
stdout: "",
|
|
39
|
+
stderr: "",
|
|
40
|
+
timedOut: false,
|
|
41
|
+
missing: true,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
code: 0,
|
|
46
|
+
stdout: "",
|
|
47
|
+
stderr: "",
|
|
48
|
+
timedOut: false,
|
|
49
|
+
missing: false,
|
|
50
|
+
...h,
|
|
51
|
+
};
|
|
52
|
+
},
|
|
53
|
+
fetchJson: async (url) => {
|
|
54
|
+
const r = setup.fetch?.[url];
|
|
55
|
+
return r ?? { ok: false, reason: "returned 404" };
|
|
56
|
+
},
|
|
57
|
+
readJson: async (file) => {
|
|
58
|
+
const t = setup.files?.[file];
|
|
59
|
+
if (t === undefined) return null;
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(t);
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
readText: async (file) => setup.files?.[file] ?? null,
|
|
67
|
+
readlink: async () => null,
|
|
68
|
+
realpath: async (file) => setup.real?.[file] ?? null,
|
|
69
|
+
resolveExecutable: async (name) => setup.onPath?.[name] ?? null,
|
|
70
|
+
env: setup.env ?? {},
|
|
71
|
+
homedir: "/Users/j",
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const PREFIX = "/opt/homebrew";
|
|
76
|
+
const CELLAR = { "/opt/homebrew/Cellar": "/opt/homebrew/Cellar" };
|
|
77
|
+
const BREW_PREFIX_OK: Record<string, Partial<ExecResult>> = {
|
|
78
|
+
"brew --prefix": { stdout: `${PREFIX}\n` },
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const OUTDATED_NONE = JSON.stringify({ formulae: [], casks: [] });
|
|
82
|
+
const outdatedBehind = (name: string, installed: string, current: string) =>
|
|
83
|
+
JSON.stringify({
|
|
84
|
+
formulae: [
|
|
85
|
+
{
|
|
86
|
+
name,
|
|
87
|
+
installed_versions: [installed],
|
|
88
|
+
current_version: current,
|
|
89
|
+
pinned: false,
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
casks: [],
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/** A tool whose PATH entry resolves into a Homebrew keg. */
|
|
96
|
+
function kegWorld(
|
|
97
|
+
bin: string,
|
|
98
|
+
short: string,
|
|
99
|
+
version: string,
|
|
100
|
+
outdatedKey: string,
|
|
101
|
+
outdatedStdout: string,
|
|
102
|
+
extraExec: Record<string, Partial<ExecResult>> = BREW_PREFIX_OK,
|
|
103
|
+
): FakeSetup {
|
|
104
|
+
const file = `${PREFIX}/Cellar/${short}/${version}/bin/${bin}`;
|
|
105
|
+
const onPath = `${PREFIX}/bin/${bin}`;
|
|
106
|
+
return {
|
|
107
|
+
onPath: { [bin]: onPath },
|
|
108
|
+
real: { [onPath]: file, [file]: file, ...CELLAR },
|
|
109
|
+
exec: {
|
|
110
|
+
...extraExec,
|
|
111
|
+
[`brew outdated --json=v2 ${outdatedKey}`]: { stdout: outdatedStdout },
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const MNEMEX: BinRequirement = {
|
|
117
|
+
name: "mnemex",
|
|
118
|
+
via: "brew",
|
|
119
|
+
formula: "madappgang/tap/mnemex",
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
describe("C18 case 1: tap formula, brew spec, current", () => {
|
|
123
|
+
test("managed, version from the keg, state current", async () => {
|
|
124
|
+
const f = await probeTool(
|
|
125
|
+
MNEMEX,
|
|
126
|
+
fakeIO(
|
|
127
|
+
kegWorld(
|
|
128
|
+
"mnemex",
|
|
129
|
+
"mnemex",
|
|
130
|
+
"0.36.2",
|
|
131
|
+
"madappgang/tap/mnemex",
|
|
132
|
+
OUTDATED_NONE,
|
|
133
|
+
),
|
|
134
|
+
),
|
|
135
|
+
);
|
|
136
|
+
expect(f.managed).toBe(true);
|
|
137
|
+
expect(f.installed).toBe("0.36.2");
|
|
138
|
+
expect(classifyTool(MNEMEX, f).state).toBe("current");
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("C18 case 2: tap formula behind", () => {
|
|
143
|
+
test("state upgrade, latest from brew", async () => {
|
|
144
|
+
const f = await probeTool(
|
|
145
|
+
MNEMEX,
|
|
146
|
+
fakeIO(
|
|
147
|
+
kegWorld(
|
|
148
|
+
"mnemex",
|
|
149
|
+
"mnemex",
|
|
150
|
+
"0.36.2",
|
|
151
|
+
"madappgang/tap/mnemex",
|
|
152
|
+
outdatedBehind("madappgang/tap/mnemex", "0.36.2", "0.37.0"),
|
|
153
|
+
),
|
|
154
|
+
),
|
|
155
|
+
);
|
|
156
|
+
expect(f.managed).toBe(true);
|
|
157
|
+
expect(f.latest).toBe("0.37.0");
|
|
158
|
+
expect(classifyTool(MNEMEX, f).state).toBe("upgrade");
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("C18 case 3: bun spec, file in a Homebrew keg", () => {
|
|
163
|
+
const spec: BinRequirement = {
|
|
164
|
+
name: "claudish",
|
|
165
|
+
via: "bun",
|
|
166
|
+
package: "claudish",
|
|
167
|
+
};
|
|
168
|
+
test("adopted by brew: brew upgrade command, never bun", async () => {
|
|
169
|
+
const f = await probeTool(
|
|
170
|
+
spec,
|
|
171
|
+
fakeIO(
|
|
172
|
+
kegWorld(
|
|
173
|
+
"claudish",
|
|
174
|
+
"claudish",
|
|
175
|
+
"10.5.0",
|
|
176
|
+
"claudish",
|
|
177
|
+
outdatedBehind("claudish", "10.5.0", "10.6.0"),
|
|
178
|
+
),
|
|
179
|
+
),
|
|
180
|
+
);
|
|
181
|
+
expect(f.managed).toBe(true);
|
|
182
|
+
expect(f.installed).toBe("10.5.0");
|
|
183
|
+
expect(f.latest).toBe("10.6.0");
|
|
184
|
+
expect(f.upgradeCommand).toEqual({
|
|
185
|
+
cmd: "brew",
|
|
186
|
+
args: ["upgrade", "claudish"],
|
|
187
|
+
});
|
|
188
|
+
expect(f.upgradeCommand?.cmd).not.toBe("bun");
|
|
189
|
+
expect(classifyTool(spec, f).state).toBe("upgrade");
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("C18 case 4: npm spec, file in a Homebrew keg", () => {
|
|
194
|
+
const spec: BinRequirement = {
|
|
195
|
+
name: "claudish",
|
|
196
|
+
via: "npm",
|
|
197
|
+
package: "claudish",
|
|
198
|
+
};
|
|
199
|
+
test("current, upgrade command is brew", async () => {
|
|
200
|
+
const f = await probeTool(
|
|
201
|
+
spec,
|
|
202
|
+
fakeIO(
|
|
203
|
+
kegWorld("claudish", "claudish", "10.5.0", "claudish", OUTDATED_NONE),
|
|
204
|
+
),
|
|
205
|
+
);
|
|
206
|
+
expect(f.upgradeCommand?.cmd).toBe("brew");
|
|
207
|
+
expect(classifyTool(spec, f).state).toBe("current");
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe("C18 case 5: a pinned spec is not adopted by Homebrew", () => {
|
|
212
|
+
const spec: BinRequirement = {
|
|
213
|
+
name: "claudish",
|
|
214
|
+
via: "bun",
|
|
215
|
+
package: "claudish",
|
|
216
|
+
version: "10.4.0",
|
|
217
|
+
};
|
|
218
|
+
test("unknown, no brew command, note mentions the pin", async () => {
|
|
219
|
+
const f = await probeTool(
|
|
220
|
+
spec,
|
|
221
|
+
fakeIO(
|
|
222
|
+
kegWorld(
|
|
223
|
+
"claudish",
|
|
224
|
+
"claudish",
|
|
225
|
+
"10.5.0",
|
|
226
|
+
"claudish",
|
|
227
|
+
outdatedBehind("claudish", "10.5.0", "10.6.0"),
|
|
228
|
+
),
|
|
229
|
+
),
|
|
230
|
+
);
|
|
231
|
+
expect(f.upgradeCommand?.cmd).not.toBe("brew");
|
|
232
|
+
const s = classifyTool(spec, f);
|
|
233
|
+
expect(s.state).toBe("unknown");
|
|
234
|
+
expect(s.note ?? "").toContain("10.4.0");
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
describe("C18 case 6: a directory merely named Cellar is not Homebrew's", () => {
|
|
239
|
+
const spec: BinRequirement = {
|
|
240
|
+
name: "claudish",
|
|
241
|
+
via: "bun",
|
|
242
|
+
package: "claudish",
|
|
243
|
+
};
|
|
244
|
+
test("not adopted: external, no upgrade command", async () => {
|
|
245
|
+
const file = "/Users/u/projects/Cellar/claudish/1.0.0/bin/claudish";
|
|
246
|
+
const f = await probeTool(
|
|
247
|
+
spec,
|
|
248
|
+
fakeIO({
|
|
249
|
+
onPath: { claudish: file },
|
|
250
|
+
real: { [file]: file, ...CELLAR },
|
|
251
|
+
exec: BREW_PREFIX_OK,
|
|
252
|
+
}),
|
|
253
|
+
);
|
|
254
|
+
expect(f.managed).not.toBe(true);
|
|
255
|
+
expect(f.upgradeCommand).toBeUndefined();
|
|
256
|
+
expect(classifyTool(spec, f).state).toBe("external");
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe("C18 case 7: Homebrew unavailable", () => {
|
|
261
|
+
const spec: BinRequirement = {
|
|
262
|
+
name: "claudish",
|
|
263
|
+
via: "bun",
|
|
264
|
+
package: "claudish",
|
|
265
|
+
};
|
|
266
|
+
const world = (exec: Record<string, Partial<ExecResult>>): FakeSetup =>
|
|
267
|
+
kegWorld(
|
|
268
|
+
"claudish",
|
|
269
|
+
"claudish",
|
|
270
|
+
"10.5.0",
|
|
271
|
+
"claudish",
|
|
272
|
+
outdatedBehind("claudish", "10.5.0", "10.6.0"),
|
|
273
|
+
exec,
|
|
274
|
+
);
|
|
275
|
+
test("brew --prefix missing: not adopted", async () => {
|
|
276
|
+
const f = await probeTool(spec, fakeIO(world({})));
|
|
277
|
+
expect(f.upgradeCommand?.cmd).not.toBe("brew");
|
|
278
|
+
expect(classifyTool(spec, f).state).not.toBe("upgrade");
|
|
279
|
+
});
|
|
280
|
+
test("brew --prefix exits non-zero: not adopted", async () => {
|
|
281
|
+
const f = await probeTool(
|
|
282
|
+
spec,
|
|
283
|
+
fakeIO(world({ "brew --prefix": { code: 1, stderr: "boom" } })),
|
|
284
|
+
);
|
|
285
|
+
expect(f.upgradeCommand?.cmd).not.toBe("brew");
|
|
286
|
+
expect(classifyTool(spec, f).state).not.toBe("upgrade");
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
describe("C18 case 8: a non-tap brew spec still works", () => {
|
|
291
|
+
test("tmux current, installed 3.8", async () => {
|
|
292
|
+
const spec: BinRequirement = {
|
|
293
|
+
name: "tmux",
|
|
294
|
+
via: "brew",
|
|
295
|
+
formula: "tmux",
|
|
296
|
+
check: "tmux -V",
|
|
297
|
+
};
|
|
298
|
+
const f = await probeTool(
|
|
299
|
+
spec,
|
|
300
|
+
fakeIO(kegWorld("tmux", "tmux", "3.8", "tmux", OUTDATED_NONE)),
|
|
301
|
+
);
|
|
302
|
+
expect(f.managed).toBe(true);
|
|
303
|
+
expect(f.installed).toBe("3.8");
|
|
304
|
+
expect(classifyTool(spec, f).state).toBe("current");
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
describe("C18 case 9: bun ownership still wins over a brew spec", () => {
|
|
309
|
+
test("file in bun's global tree: bun command, current", async () => {
|
|
310
|
+
const root = "/Users/j/.bun/install/global/node_modules";
|
|
311
|
+
const pkgDir = `${root}/mnemex`;
|
|
312
|
+
const file = `${pkgDir}/dist/index.js`;
|
|
313
|
+
const onPath = "/Users/j/.bun/bin/mnemex";
|
|
314
|
+
const f = await probeTool(
|
|
315
|
+
MNEMEX,
|
|
316
|
+
fakeIO({
|
|
317
|
+
env: { HOMEBREW_PREFIX: PREFIX },
|
|
318
|
+
onPath: { mnemex: onPath },
|
|
319
|
+
real: {
|
|
320
|
+
[onPath]: file,
|
|
321
|
+
[file]: file,
|
|
322
|
+
[pkgDir]: pkgDir,
|
|
323
|
+
[root]: root,
|
|
324
|
+
...CELLAR,
|
|
325
|
+
},
|
|
326
|
+
files: {
|
|
327
|
+
[`${pkgDir}/package.json`]: JSON.stringify({
|
|
328
|
+
name: "mnemex",
|
|
329
|
+
version: "0.36.2",
|
|
330
|
+
}),
|
|
331
|
+
},
|
|
332
|
+
exec: BREW_PREFIX_OK,
|
|
333
|
+
fetch: {
|
|
334
|
+
"https://registry.npmjs.org/-/package/mnemex/dist-tags": {
|
|
335
|
+
ok: true,
|
|
336
|
+
body: { latest: "0.36.2" },
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
}),
|
|
340
|
+
);
|
|
341
|
+
expect(f.managed).toBe(true);
|
|
342
|
+
expect(f.upgradeCommand?.cmd).toBe("bun");
|
|
343
|
+
expect(classifyTool(MNEMEX, f).state).toBe("current");
|
|
344
|
+
});
|
|
345
|
+
});
|
|
@@ -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,11 @@ 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("installed outside npm");
|
|
217
|
+
expect(row.status?.note).not.toContain("/");
|
|
215
218
|
// Enter still follows how it IS installed (pnpm).
|
|
216
219
|
expect(row.updateCommand?.cmd).toBe("pnpm");
|
|
217
220
|
});
|
|
@@ -249,16 +252,28 @@ describe("cliToolStatusText — the row's words", () => {
|
|
|
249
252
|
primary: "npm",
|
|
250
253
|
all: ["npm"],
|
|
251
254
|
});
|
|
252
|
-
const f = facts("crush", { managed:
|
|
255
|
+
const f = facts("crush", { managed: null });
|
|
253
256
|
const row = buildToolRow(tool, spec, f);
|
|
254
257
|
const text = cliToolStatusText(row);
|
|
255
258
|
expect(text.tone).toBe("warning");
|
|
256
|
-
expect(text.suffix).toBe(" ? not
|
|
257
|
-
// The note
|
|
259
|
+
expect(text.suffix).toBe(" ? owner not known");
|
|
260
|
+
// The note is for the detail pane's "Why", not the row.
|
|
258
261
|
expect(row.status?.note).toBe(classifyTool(spec, f).note);
|
|
259
262
|
expect(text.suffix).not.toContain("/");
|
|
260
263
|
});
|
|
261
264
|
|
|
265
|
+
test("external renders muted, naming the installer it is outside of", () => {
|
|
266
|
+
const tool = catalogue("crush");
|
|
267
|
+
const spec = specForCatalogTool(tool, null, {
|
|
268
|
+
primary: "npm",
|
|
269
|
+
all: ["npm"],
|
|
270
|
+
});
|
|
271
|
+
const row = buildToolRow(tool, spec, facts("crush", { managed: false }));
|
|
272
|
+
const text = cliToolStatusText(row);
|
|
273
|
+
expect(text.tone).toBe("muted");
|
|
274
|
+
expect(text.suffix).toBe(" · outside npm");
|
|
275
|
+
});
|
|
276
|
+
|
|
262
277
|
test("upgrade names the target once, with one v", () => {
|
|
263
278
|
const tool = catalogue("mnemex");
|
|
264
279
|
const spec = specForCatalogTool(tool, null, {
|
|
@@ -287,12 +302,14 @@ describe("countToolRows — header counts from states", () => {
|
|
|
287
302
|
const rows = [
|
|
288
303
|
buildToolRow(tool, spec, facts("mnemex", { behind: false })),
|
|
289
304
|
buildToolRow(tool, spec, facts("mnemex", { behind: true })),
|
|
305
|
+
buildToolRow(tool, spec, facts("mnemex", { managed: null })),
|
|
306
|
+
// Another installer's: installed, and not "unverified".
|
|
290
307
|
buildToolRow(tool, spec, facts("mnemex", { managed: false })),
|
|
291
308
|
buildToolRow(tool, spec, facts("mnemex", { present: false })),
|
|
292
309
|
buildToolRow(tool, spec, undefined),
|
|
293
310
|
];
|
|
294
311
|
expect(countToolRows(rows)).toEqual({
|
|
295
|
-
installed:
|
|
312
|
+
installed: 4,
|
|
296
313
|
updates: 1,
|
|
297
314
|
unverified: 1,
|
|
298
315
|
});
|
|
@@ -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");
|
|
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");
|
|
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
|
});
|
package/src/cli/update-view.ts
CHANGED
|
@@ -90,6 +90,7 @@ type ActionKey =
|
|
|
90
90
|
| "refresh"
|
|
91
91
|
| "stale-load"
|
|
92
92
|
| "remove"
|
|
93
|
+
| "external"
|
|
93
94
|
| "unknown";
|
|
94
95
|
|
|
95
96
|
const ACTIONS: Record<ActionKey, ActionStyle> = {
|
|
@@ -111,6 +112,9 @@ const ACTIONS: Record<ActionKey, ActionStyle> = {
|
|
|
111
112
|
// dead weight Claude Code refuses to load, and the row is work, not a shrug.
|
|
112
113
|
// The plugin table shows the group's DEPRECATED chip, not this label.
|
|
113
114
|
remove: { glyph: "✗", label: "REMOVE", color: brand.danger },
|
|
115
|
+
// A tool another installer put on PATH. Settled, so chrome like CURRENT,
|
|
116
|
+
// and the same word the summary already prints for settled rows.
|
|
117
|
+
external: { glyph: "=", label: "KEPT", color: brand.muted, chrome: true },
|
|
114
118
|
// Warning, not danger: "cannot tell" is not a failure, and red UNKNOWN read
|
|
115
119
|
// as "broken". The summary line and the TUI already use warning for it.
|
|
116
120
|
unknown: { glyph: "?", label: "UNKNOWN", color: brand.warning },
|
|
@@ -453,6 +457,11 @@ function binDetail(item: BinUpdateItem, plan: UpdatePlan): string {
|
|
|
453
457
|
// plain one reads identically to before.
|
|
454
458
|
item.adopted ? ` ${dim(`(${item.adopted})`)}` : ""
|
|
455
459
|
}`;
|
|
460
|
+
case "external":
|
|
461
|
+
// Kept as is: a missing version is not a finding, so no "—" stands in.
|
|
462
|
+
return [item.installed, item.note ? dim(`(${item.note})`) : ""]
|
|
463
|
+
.filter(Boolean)
|
|
464
|
+
.join(" ");
|
|
456
465
|
case "unknown":
|
|
457
466
|
return `${item.installed ?? "—"}${
|
|
458
467
|
item.note ? ` ${dim(`(${item.note})`)}` : ""
|
|
@@ -927,15 +936,18 @@ function worktreeLabel(names: readonly string[]): string {
|
|
|
927
936
|
* The worktree rows: after a setup (`results`), one `✓` line for every
|
|
928
937
|
* worktree set up and a `✗` line for each failure; on a read-only run
|
|
929
938
|
* (`results` null), one `⚠` line for the ones `magus update` would set up.
|
|
930
|
-
*
|
|
931
|
-
*
|
|
939
|
+
*
|
|
940
|
+
* A worktree that needs a choice gets no line. magus cannot set it up from
|
|
941
|
+
* here, and a line per worktree on every run is noise about folders the user
|
|
942
|
+
* may never open: old branches, agent and bench worktrees. The `setup`
|
|
943
|
+
* plugin says so when Claude Code starts in one, and magus run there asks.
|
|
944
|
+
* Empty when there is nothing to say, blank line included.
|
|
932
945
|
*/
|
|
933
946
|
export function worktreeLines(
|
|
934
947
|
worktrees: readonly UnsetWorktree[],
|
|
935
948
|
results: readonly WorktreeSetupResult[] | null,
|
|
936
949
|
): string[] {
|
|
937
|
-
|
|
938
|
-
const lines = [""];
|
|
950
|
+
const lines: string[] = [];
|
|
939
951
|
if (results === null) {
|
|
940
952
|
const auto = worktrees.filter((w) => w.autoActivate).map((w) => w.name);
|
|
941
953
|
if (auto.length > 0) {
|
|
@@ -965,14 +977,5 @@ export function worktreeLines(
|
|
|
965
977
|
}
|
|
966
978
|
}
|
|
967
979
|
}
|
|
968
|
-
|
|
969
|
-
if (!w.autoActivate) {
|
|
970
|
-
lines.push(
|
|
971
|
-
warn(
|
|
972
|
-
`Worktree ${bold(w.name)} is missing this project's plugins. Run magus update there to choose a profile.`,
|
|
973
|
-
),
|
|
974
|
-
);
|
|
975
|
-
}
|
|
976
|
-
}
|
|
977
|
-
return lines;
|
|
980
|
+
return lines.length > 0 ? ["", ...lines] : [];
|
|
978
981
|
}
|