magus-cli 7.10.2 → 7.10.4
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__/blackbox-convergence.test.ts +0 -1
- package/src/__tests__/blackbox-parity.test.ts +2 -2
- package/src/__tests__/blackbox-tool-state.test.ts +8 -18
- package/src/__tests__/blind/c14-cli-row-label.test.ts +8 -3
- package/src/__tests__/blind/c15-probe-ownership.test.ts +11 -11
- package/src/__tests__/blind/c16-probe-adopts-node-global.test.ts +16 -12
- package/src/__tests__/blind/c17-enter-label.test.ts +42 -7
- package/src/__tests__/blind/c18-probe-adopts-homebrew-keg.test.ts +36 -19
- package/src/__tests__/blind/c19-probe-owner-version-shadow.test.ts +392 -0
- package/src/__tests__/cli-tool-commands.test.ts +2 -2
- package/src/__tests__/cli-tool-external-row.test.tsx +112 -0
- package/src/__tests__/cli-tools-adapter.test.ts +32 -20
- package/src/__tests__/cli-update-view.test.ts +124 -51
- package/src/__tests__/fixtures/bins.ts +9 -2
- package/src/__tests__/npm-package-name.test.ts +3 -2
- package/src/__tests__/tool-adopt-brew-owner.test.ts +167 -0
- package/src/__tests__/tool-probe.test.ts +231 -144
- package/src/__tests__/tool-rows-followups.test.ts +315 -0
- package/src/__tests__/tool-rows-reproduction.test.ts +630 -0
- package/src/__tests__/tool-versions.test.ts +164 -0
- package/src/__tests__/toolchain.test.ts +0 -2
- package/src/__tests__/update-plan.test.ts +40 -9
- package/src/cli/update-view.ts +182 -68
- package/src/cli/update.ts +44 -37
- package/src/data/cli-tools.ts +5 -10
- package/src/data/tool-versions.ts +137 -0
- package/src/services/cli-tool-commands.ts +1 -2
- package/src/services/cli-tool-version.ts +10 -88
- package/src/services/resolver.ts +9 -15
- package/src/services/tool-probe.ts +557 -424
- package/src/services/toolchain.ts +10 -4
- package/src/services/update-engine.ts +9 -15
- package/src/services/update-plan.ts +49 -18
- package/src/types/index.ts +0 -2
- package/src/ui/adapters/cliToolsAdapter.ts +31 -22
- package/src/ui/renderers/cliToolRenderers.tsx +15 -10
- package/src/ui/state/types.ts +0 -1
- package/src/__tests__/blind/c9-cli-tool-version.test.ts +0 -239
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
// REGRESSION: follow-ups from gate B review of the magus update tool-rows fix — /dev:fix session dev-fix-20261008-133732-7ddf079e
|
|
2
|
+
/**
|
|
3
|
+
* Follow-ups to docs/plans/2026-10-08-magus-update-tool-rows.md.
|
|
4
|
+
*
|
|
5
|
+
* The rule under test: no user-facing message — CLI or TUI — names a
|
|
6
|
+
* filesystem path, a Cellar, or a mechanism; and a tool brew owns is managed
|
|
7
|
+
* even when its version cannot be read.
|
|
8
|
+
*
|
|
9
|
+
* Written blind from the spec and the public contract. Same fake ToolProbeIO
|
|
10
|
+
* seam as tool-rows-reproduction.test.ts.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, expect, test } from "bun:test";
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import {
|
|
17
|
+
type ExecResult,
|
|
18
|
+
type ToolFacts,
|
|
19
|
+
type ToolProbeIO,
|
|
20
|
+
classifyTool,
|
|
21
|
+
probeTool,
|
|
22
|
+
} from "../services/tool-probe.js";
|
|
23
|
+
import { type ApplyReporter, applyBins } from "../services/update-engine.js";
|
|
24
|
+
import type { BinUpdateItem, UpdatePlan } from "../services/update-plan.js";
|
|
25
|
+
import type { ManagedTool, ResolvedBin } from "../types/index.js";
|
|
26
|
+
import {
|
|
27
|
+
buildToolRow,
|
|
28
|
+
cliToolEnterAction,
|
|
29
|
+
cliToolStatusText,
|
|
30
|
+
} from "../ui/adapters/cliToolsAdapter.js";
|
|
31
|
+
import { testCatalogue } from "./fixtures/bins.js";
|
|
32
|
+
|
|
33
|
+
// ── fake IO (copied shape from tool-rows-reproduction.test.ts) ──────────────
|
|
34
|
+
|
|
35
|
+
type Handler = Partial<ExecResult> | "timeout";
|
|
36
|
+
|
|
37
|
+
interface Setup {
|
|
38
|
+
exec?: Record<string, Handler>;
|
|
39
|
+
onPath?: Record<string, string>;
|
|
40
|
+
real?: Record<string, string>;
|
|
41
|
+
env?: Record<string, string>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const MISSING: ExecResult = {
|
|
45
|
+
code: null,
|
|
46
|
+
stdout: "",
|
|
47
|
+
stderr: "",
|
|
48
|
+
timedOut: false,
|
|
49
|
+
missing: true,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function fakeIO(setup: Setup): ToolProbeIO & { calls: string[] } {
|
|
53
|
+
const calls: string[] = [];
|
|
54
|
+
const answer = (h: Handler | undefined): ExecResult => {
|
|
55
|
+
if (h === undefined) return MISSING;
|
|
56
|
+
if (h === "timeout")
|
|
57
|
+
return {
|
|
58
|
+
code: null,
|
|
59
|
+
stdout: "",
|
|
60
|
+
stderr: "",
|
|
61
|
+
timedOut: true,
|
|
62
|
+
missing: false,
|
|
63
|
+
};
|
|
64
|
+
return {
|
|
65
|
+
code: 0,
|
|
66
|
+
stdout: "",
|
|
67
|
+
stderr: "",
|
|
68
|
+
timedOut: false,
|
|
69
|
+
missing: false,
|
|
70
|
+
...h,
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
return {
|
|
74
|
+
calls,
|
|
75
|
+
exec: async (command) => {
|
|
76
|
+
const key = [command.cmd, ...command.args].join(" ");
|
|
77
|
+
calls.push(key);
|
|
78
|
+
return answer(setup.exec?.[key]);
|
|
79
|
+
},
|
|
80
|
+
fetchJson: async () => ({ ok: false, reason: "returned 404" }),
|
|
81
|
+
readJson: async () => null,
|
|
82
|
+
readText: async () => null,
|
|
83
|
+
readlink: async () => null,
|
|
84
|
+
realpath: async (file) => setup.real?.[file] ?? file,
|
|
85
|
+
resolveExecutable: async (name) => setup.onPath?.[name] ?? null,
|
|
86
|
+
env: setup.env ?? {},
|
|
87
|
+
homedir: "/Users/j",
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const OPTS = { timeoutMs: 500, deadlineMs: 3000 };
|
|
92
|
+
const BREW_ENV = { HOMEBREW_PREFIX: "/opt/homebrew" };
|
|
93
|
+
const OUTDATED_EMPTY = '{"formulae":[],"casks":[]}';
|
|
94
|
+
|
|
95
|
+
const brewSpec = (name: string, formula?: string): ManagedTool => ({
|
|
96
|
+
name,
|
|
97
|
+
via: "brew",
|
|
98
|
+
...(formula ? { formula } : {}),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
/** An absolute or relative path segment: `/x`, `a/b/`. */
|
|
102
|
+
const PATH_SEGMENT = /\/[\w.@-]|[\w.@-]+\/[\w.@-]+\//;
|
|
103
|
+
|
|
104
|
+
function expectNoPath(text: string): void {
|
|
105
|
+
expect(text).not.toMatch(PATH_SEGMENT);
|
|
106
|
+
for (const banned of ["Cellar", "homebrew/var", "rb_sysopen", ".lock"]) {
|
|
107
|
+
expect(text).not.toContain(banned);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const catalogue = (name: string) => {
|
|
112
|
+
const tool = testCatalogue().find((t) => t.name === name);
|
|
113
|
+
if (!tool) throw new Error(`no catalogue tool ${name}`);
|
|
114
|
+
return tool;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// ── 1. brew error text never carries a path ─────────────────────────────────
|
|
118
|
+
|
|
119
|
+
describe("REQ-1: a brew failure is reported without a path", () => {
|
|
120
|
+
test("F1. brew list exits 1 with a lock-file Permission denied: the note and the TUI row name no path", async () => {
|
|
121
|
+
const io = fakeIO({
|
|
122
|
+
env: BREW_ENV,
|
|
123
|
+
onPath: { jq: "/opt/homebrew/bin/jq" },
|
|
124
|
+
exec: {
|
|
125
|
+
"brew list --versions jq": {
|
|
126
|
+
code: 1,
|
|
127
|
+
stdout: "",
|
|
128
|
+
stderr:
|
|
129
|
+
"Error: Permission denied @ rb_sysopen - /opt/homebrew/var/homebrew/locks/jq.formula.lock\n",
|
|
130
|
+
},
|
|
131
|
+
"jq --version": { stdout: "jq-1.8.2\n" },
|
|
132
|
+
"brew outdated --json=v2 jq": { stdout: OUTDATED_EMPTY },
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
const s = brewSpec("jq");
|
|
136
|
+
const f = await probeTool(s, io, OPTS);
|
|
137
|
+
const st = classifyTool(s, f);
|
|
138
|
+
|
|
139
|
+
// Guard: brew failing loudly is "owner could not be determined".
|
|
140
|
+
expect(st.state).toBe("unknown");
|
|
141
|
+
expect(st.note ?? "").not.toBe("");
|
|
142
|
+
expectNoPath(st.note ?? "");
|
|
143
|
+
expectNoPath(f.failure ?? "");
|
|
144
|
+
|
|
145
|
+
// What the TUI row shows for the same facts.
|
|
146
|
+
const row = buildToolRow(catalogue("mnemex"), s, f);
|
|
147
|
+
expectNoPath(JSON.stringify(cliToolStatusText(row)));
|
|
148
|
+
expectNoPath(row.status?.note ?? "");
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// ── 2. post-install read-back names no path ─────────────────────────────────
|
|
153
|
+
|
|
154
|
+
const binItem = (over: Partial<BinUpdateItem> = {}): BinUpdateItem => ({
|
|
155
|
+
name: "jq",
|
|
156
|
+
action: "upgrade",
|
|
157
|
+
reason: "behind",
|
|
158
|
+
installed: "1.8.1",
|
|
159
|
+
latest: "1.8.2",
|
|
160
|
+
target: "1.8.2",
|
|
161
|
+
sources: [],
|
|
162
|
+
...over,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
function binPlan(item: BinUpdateItem): UpdatePlan {
|
|
166
|
+
const spec: ResolvedBin = { name: item.name, via: "brew", sources: [] };
|
|
167
|
+
return {
|
|
168
|
+
profileId: "test",
|
|
169
|
+
plugins: [],
|
|
170
|
+
bins: [item],
|
|
171
|
+
skills: [],
|
|
172
|
+
binSpecs: new Map([[item.name, spec]]),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
describe("REQ-2: the read-back message says another copy runs, without a path", () => {
|
|
177
|
+
test("F2. upgrade read back with PATH still running another copy: detail names no path", async () => {
|
|
178
|
+
const rows: Array<{ ok: boolean; name: string; detail: string }> = [];
|
|
179
|
+
const reporter: ApplyReporter = {
|
|
180
|
+
begin: () => {},
|
|
181
|
+
finish: (ok, name, detail) => rows.push({ ok, name, detail }),
|
|
182
|
+
detach: (work) => work(),
|
|
183
|
+
};
|
|
184
|
+
const readBack: ToolFacts = {
|
|
185
|
+
name: "jq",
|
|
186
|
+
present: true,
|
|
187
|
+
resolvedPath: "/usr/local/Cellar/jq/1.8.1/bin/jq",
|
|
188
|
+
installed: "1.8.1",
|
|
189
|
+
latest: null,
|
|
190
|
+
behind: false,
|
|
191
|
+
managed: false,
|
|
192
|
+
sources: [],
|
|
193
|
+
};
|
|
194
|
+
const result = await applyBins(
|
|
195
|
+
binPlan(binItem()),
|
|
196
|
+
reporter,
|
|
197
|
+
async () => true,
|
|
198
|
+
async () => readBack,
|
|
199
|
+
);
|
|
200
|
+
expect(result.unchanged).toEqual(["jq"]);
|
|
201
|
+
expect(rows).toHaveLength(1);
|
|
202
|
+
const detail = rows[0]!.detail;
|
|
203
|
+
expectNoPath(detail);
|
|
204
|
+
expect(detail).toMatch(/another copy|different copy|another installation/i);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// ── 3. held back: Enter offers no upgrade ───────────────────────────────────
|
|
209
|
+
|
|
210
|
+
describe("REQ-3: a held-back tool offers no upgrade on Enter", () => {
|
|
211
|
+
test("F3. brew pinned and behind: Enter is not an update that runs brew upgrade", () => {
|
|
212
|
+
const s = brewSpec("mnemex", "madappgang/tap/mnemex");
|
|
213
|
+
const f: ToolFacts = {
|
|
214
|
+
name: "mnemex",
|
|
215
|
+
present: true,
|
|
216
|
+
resolvedPath: "/opt/homebrew/bin/mnemex",
|
|
217
|
+
installed: "0.36.1",
|
|
218
|
+
latest: "0.37.0",
|
|
219
|
+
behind: true,
|
|
220
|
+
managed: true,
|
|
221
|
+
heldBack: "held by brew pin",
|
|
222
|
+
sources: ["brew"],
|
|
223
|
+
};
|
|
224
|
+
const row = buildToolRow(catalogue("mnemex"), s, f);
|
|
225
|
+
// Guard the input shape.
|
|
226
|
+
expect(row.status?.reason).toBe("held-back");
|
|
227
|
+
|
|
228
|
+
const action = cliToolEnterAction(row);
|
|
229
|
+
if (action !== null) {
|
|
230
|
+
expect(["update", "update-anyway"]).not.toContain(action.kind);
|
|
231
|
+
const argv = [action.command.cmd, ...action.command.args].join(" ");
|
|
232
|
+
expect(argv).not.toMatch(/\bupgrade\b/);
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// ── 4. CliTool has no checkCommand ──────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
describe("REQ-4: the TUI CliTool type carries no checkCommand", () => {
|
|
240
|
+
test("F4. CliTool in ui/state/types.ts declares no checkCommand field", () => {
|
|
241
|
+
// Spec decision 2: "The catalogue's `checkCommand` field is deleted, so
|
|
242
|
+
// each tool's version command exists once." bun:test does not type-check,
|
|
243
|
+
// so the contract file's declaration is read directly.
|
|
244
|
+
const src = readFileSync(
|
|
245
|
+
path.join(import.meta.dir, "..", "ui", "state", "types.ts"),
|
|
246
|
+
"utf8",
|
|
247
|
+
);
|
|
248
|
+
const block = /export interface CliTool \{([\s\S]*?)\n\}/.exec(src);
|
|
249
|
+
expect(block).not.toBeNull();
|
|
250
|
+
expect(block![1]).not.toMatch(/\bcheckCommand\??\s*:/);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// ── 5. brew HEAD build whose version the table cannot parse ─────────────────
|
|
255
|
+
|
|
256
|
+
describe("REQ-5: a brew dev build is managed, version unknown", () => {
|
|
257
|
+
test("F5. brew lists tmux HEAD-abc1234, tmux -V prints `tmux next-3.7`: managed and current, no version", async () => {
|
|
258
|
+
const io = fakeIO({
|
|
259
|
+
env: BREW_ENV,
|
|
260
|
+
onPath: { tmux: "/opt/homebrew/bin/tmux" },
|
|
261
|
+
real: {
|
|
262
|
+
"/opt/homebrew/bin/tmux":
|
|
263
|
+
"/opt/homebrew/Cellar/tmux/HEAD-abc1234/bin/tmux",
|
|
264
|
+
},
|
|
265
|
+
exec: {
|
|
266
|
+
"brew list --versions tmux": { stdout: "tmux HEAD-abc1234\n" },
|
|
267
|
+
"brew list tmux": {
|
|
268
|
+
stdout: "/opt/homebrew/Cellar/tmux/HEAD-abc1234/bin/tmux\n",
|
|
269
|
+
},
|
|
270
|
+
"tmux -V": { stdout: "tmux next-3.7\n" },
|
|
271
|
+
"brew outdated --json=v2 tmux": { stdout: OUTDATED_EMPTY },
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
const s = brewSpec("tmux");
|
|
275
|
+
const f = await probeTool(s, io, OPTS);
|
|
276
|
+
const st = classifyTool(s, f);
|
|
277
|
+
expect({ managed: f.managed, state: st.state, reason: st.reason }).toEqual({
|
|
278
|
+
managed: true,
|
|
279
|
+
state: "current",
|
|
280
|
+
reason: "up-to-date",
|
|
281
|
+
});
|
|
282
|
+
expect(st.installed).toBeNull();
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// ── 6. several brew versions, no version-table entry ────────────────────────
|
|
287
|
+
|
|
288
|
+
describe("REQ-6: brew's several versions with no table entry stay managed", () => {
|
|
289
|
+
test("F6. brew lists testball 1.0 1.1, no table entry: managed and current, no version", async () => {
|
|
290
|
+
const io = fakeIO({
|
|
291
|
+
env: BREW_ENV,
|
|
292
|
+
onPath: { testball: "/opt/homebrew/bin/testball" },
|
|
293
|
+
real: {
|
|
294
|
+
"/opt/homebrew/bin/testball":
|
|
295
|
+
"/opt/homebrew/Cellar/testball/1.1/bin/testball",
|
|
296
|
+
},
|
|
297
|
+
exec: {
|
|
298
|
+
"brew list --versions testball": { stdout: "testball 1.0 1.1\n" },
|
|
299
|
+
"brew list testball": {
|
|
300
|
+
stdout: "/opt/homebrew/Cellar/testball/1.1/bin/testball\n",
|
|
301
|
+
},
|
|
302
|
+
"brew outdated --json=v2 testball": { stdout: OUTDATED_EMPTY },
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
const s = brewSpec("testball");
|
|
306
|
+
const f = await probeTool(s, io, OPTS);
|
|
307
|
+
const st = classifyTool(s, f);
|
|
308
|
+
expect({ managed: f.managed, state: st.state, reason: st.reason }).toEqual({
|
|
309
|
+
managed: true,
|
|
310
|
+
state: "current",
|
|
311
|
+
reason: "up-to-date",
|
|
312
|
+
});
|
|
313
|
+
expect(st.installed).toBeNull();
|
|
314
|
+
});
|
|
315
|
+
});
|