claudeup 5.0.1 → 6.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/scripts/test-isolated.ts +7 -1
- package/src/__tests__/cli-ansi.test.ts +297 -0
- package/src/__tests__/cli-apply-seams.test.ts +281 -0
- package/src/__tests__/cli-live.test.ts +384 -0
- package/src/__tests__/cli-tool-commands.test.ts +239 -0
- package/src/__tests__/cli-update-view.test.ts +286 -0
- package/src/__tests__/format-command.test.ts +78 -0
- package/src/__tests__/gitignore-fixer.test.ts +20 -1
- package/src/__tests__/marketplace-refresh.test.ts +63 -0
- package/src/__tests__/shell-script-callers.test.ts +92 -0
- package/src/__tests__/toolchain.test.ts +176 -31
- package/src/__tests__/ui-version-writers.test.ts +88 -0
- package/src/__tests__/update-apply.test.ts +46 -1
- package/src/cli/ansi.ts +512 -0
- package/src/cli/bootstrap.ts +0 -6
- package/src/cli/install.ts +15 -6
- package/src/cli/live.ts +374 -0
- package/src/cli/profile.ts +1 -1
- package/src/cli/prompt.ts +0 -9
- package/src/cli/router.ts +7 -3
- package/src/cli/update-view.ts +441 -0
- package/src/cli/update.ts +357 -298
- package/src/data/cli-tools.ts +20 -13
- package/src/services/cli-tool-commands.ts +133 -0
- package/src/services/doctor-bins.ts +18 -4
- package/src/services/marketplace-refresh.ts +39 -1
- package/src/services/toolchain.ts +235 -33
- package/src/services/update-engine.ts +412 -0
- package/src/ui/renderers/cliToolRenderers.tsx +40 -39
- package/src/ui/screens/CliToolsScreen.tsx +56 -64
- package/src/ui/screens/PluginsScreen.tsx +119 -64
- package/src/utils/command-utils.ts +102 -1
- package/src/utils/run.ts +67 -0
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the live region.
|
|
3
|
+
*
|
|
4
|
+
* The invariant worth defending is the ORDER of writes around a permanent line.
|
|
5
|
+
* `note()` must erase the animated block, write, then repaint. Writing first
|
|
6
|
+
* puts the line inside the block, where the next repaint's cursor-up counts it
|
|
7
|
+
* as a frame row and starts eating the scrollback record — a data-loss bug that
|
|
8
|
+
* looks like a flicker.
|
|
9
|
+
*
|
|
10
|
+
* The console-capture suite exists because that ordering was documented as a
|
|
11
|
+
* rule and the rule was broken in production by code two call layers away
|
|
12
|
+
* (`plugin-manager`'s stale-marketplace advisory, reached through
|
|
13
|
+
* `getAvailablePlugins`). A rule spanning a call graph this module does not own
|
|
14
|
+
* has to be a mechanism, so these tests pin the mechanism.
|
|
15
|
+
*
|
|
16
|
+
* The clamps are the other half. A frame line that reaches the terminal's last
|
|
17
|
+
* column auto-wraps into two rows while the repainter still counts it as one,
|
|
18
|
+
* so the block walks down the screen a row per frame.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
22
|
+
import { LiveRegion, withPaused } from "../cli/live.js";
|
|
23
|
+
|
|
24
|
+
/** A write stream that records instead of drawing. */
|
|
25
|
+
function fakeStream(
|
|
26
|
+
opts: { isTTY?: boolean; columns?: number; rows?: number } = {},
|
|
27
|
+
) {
|
|
28
|
+
const writes: string[] = [];
|
|
29
|
+
const listeners = new Map<string, Array<() => void>>();
|
|
30
|
+
const stream = {
|
|
31
|
+
write: (chunk: string) => {
|
|
32
|
+
writes.push(chunk);
|
|
33
|
+
return true;
|
|
34
|
+
},
|
|
35
|
+
on(event: string, fn: () => void) {
|
|
36
|
+
const list = listeners.get(event) ?? [];
|
|
37
|
+
list.push(fn);
|
|
38
|
+
listeners.set(event, list);
|
|
39
|
+
return this;
|
|
40
|
+
},
|
|
41
|
+
off(event: string, fn: () => void) {
|
|
42
|
+
const list = (listeners.get(event) ?? []).filter((f) => f !== fn);
|
|
43
|
+
listeners.set(event, list);
|
|
44
|
+
return this;
|
|
45
|
+
},
|
|
46
|
+
isTTY: opts.isTTY ?? true,
|
|
47
|
+
columns: opts.columns ?? 80,
|
|
48
|
+
rows: opts.rows ?? 24,
|
|
49
|
+
} as unknown as NodeJS.WriteStream & {
|
|
50
|
+
columns: number;
|
|
51
|
+
rows: number;
|
|
52
|
+
};
|
|
53
|
+
const emit = (event: string) => {
|
|
54
|
+
for (const fn of listeners.get(event) ?? []) fn();
|
|
55
|
+
};
|
|
56
|
+
return { stream, writes, emit, all: () => writes.join("") };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* An animated region that needs no timer: paint via `refresh()`.
|
|
61
|
+
*
|
|
62
|
+
* `captureConsole` is OFF here. These tests assert on stream writes, and a
|
|
63
|
+
* region that hijacks the global console would also hijack the test runner's
|
|
64
|
+
* own reporting. The capture suite below opts back in deliberately.
|
|
65
|
+
*/
|
|
66
|
+
function region(opts: Parameters<typeof fakeStream>[0] = {}) {
|
|
67
|
+
const fake = fakeStream(opts);
|
|
68
|
+
const live = new LiveRegion({
|
|
69
|
+
stream: fake.stream,
|
|
70
|
+
animate: true,
|
|
71
|
+
fps: 1000,
|
|
72
|
+
captureConsole: false,
|
|
73
|
+
});
|
|
74
|
+
return { ...fake, live };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe("non-animated mode — a pipe, CI, or NO_COLOR", () => {
|
|
78
|
+
test("the transient block is never painted, but the record still prints", () => {
|
|
79
|
+
const fake = fakeStream({ isTTY: false });
|
|
80
|
+
const live = new LiveRegion({ stream: fake.stream, animate: false });
|
|
81
|
+
live.start(() => ["spinning"]);
|
|
82
|
+
live.refresh();
|
|
83
|
+
live.note("permanent one");
|
|
84
|
+
live.stop(["final"]);
|
|
85
|
+
|
|
86
|
+
expect(fake.writes).toEqual(["permanent one\n", "final\n"]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("no escape sequence of any kind reaches a pipe", () => {
|
|
90
|
+
const fake = fakeStream({ isTTY: false });
|
|
91
|
+
const live = new LiveRegion({ stream: fake.stream, animate: false });
|
|
92
|
+
live.start(() => ["frame"]);
|
|
93
|
+
live.note("line");
|
|
94
|
+
live.pause();
|
|
95
|
+
live.resume();
|
|
96
|
+
live.stop(["done"]);
|
|
97
|
+
expect(fake.all()).not.toContain("\x1b");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("console is never captured when nothing is being painted", () => {
|
|
101
|
+
const fake = fakeStream({ isTTY: false });
|
|
102
|
+
const original = console.log;
|
|
103
|
+
const live = new LiveRegion({ stream: fake.stream, animate: false });
|
|
104
|
+
live.start(() => ["frame"]);
|
|
105
|
+
try {
|
|
106
|
+
expect(console.log).toBe(original);
|
|
107
|
+
} finally {
|
|
108
|
+
live.stop();
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("animated mode", () => {
|
|
114
|
+
test("start hides the cursor and paints the frame", () => {
|
|
115
|
+
const { live, all } = region();
|
|
116
|
+
live.start(() => ["row one", "row two"]);
|
|
117
|
+
expect(all()).toContain("\x1b[?25l");
|
|
118
|
+
expect(all()).toContain("row one\nrow two\n");
|
|
119
|
+
live.stop();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("a repaint erases exactly the rows it painted", () => {
|
|
123
|
+
const { live, writes } = region();
|
|
124
|
+
live.start(() => ["a", "b", "c"]);
|
|
125
|
+
writes.length = 0;
|
|
126
|
+
live.refresh();
|
|
127
|
+
// Three rows painted, so three rows up and erase below.
|
|
128
|
+
expect(writes[0]).toBe("\x1b[3A\x1b[0J");
|
|
129
|
+
live.stop();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("note erases BEFORE writing, then repaints", () => {
|
|
133
|
+
const { live, writes } = region();
|
|
134
|
+
live.start(() => ["frame"]);
|
|
135
|
+
writes.length = 0;
|
|
136
|
+
live.note("kept");
|
|
137
|
+
|
|
138
|
+
expect(writes[0]).toBe("\x1b[1A\x1b[0J");
|
|
139
|
+
expect(writes[1]).toBe("kept\n");
|
|
140
|
+
expect(writes[2]).toBe("frame\n");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("a frame that shrinks does not leave orphaned rows", () => {
|
|
144
|
+
let rows = ["a", "b", "c"];
|
|
145
|
+
const { live, writes } = region();
|
|
146
|
+
live.start(() => rows);
|
|
147
|
+
rows = ["a"];
|
|
148
|
+
writes.length = 0;
|
|
149
|
+
live.refresh();
|
|
150
|
+
// Erase-below clears the two rows the shorter frame no longer covers.
|
|
151
|
+
expect(writes[0]).toBe("\x1b[3A\x1b[0J");
|
|
152
|
+
expect(writes[1]).toBe("a\n");
|
|
153
|
+
live.stop();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("a line is clipped one column short of the terminal width", () => {
|
|
157
|
+
const { live, writes } = region({ columns: 20 });
|
|
158
|
+
live.start(() => ["x".repeat(40)]);
|
|
159
|
+
const painted = writes.at(-1) ?? "";
|
|
160
|
+
expect(painted.replace(/\n$/, "")).toHaveLength(19);
|
|
161
|
+
live.stop();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("the block is capped one row short of the terminal height", () => {
|
|
165
|
+
const { live, writes } = region({ rows: 6 });
|
|
166
|
+
live.start(() => Array.from({ length: 20 }, (_, i) => `row ${i}`));
|
|
167
|
+
const painted = (writes.at(-1) ?? "").split("\n").filter(Boolean);
|
|
168
|
+
expect(painted).toHaveLength(5);
|
|
169
|
+
live.stop();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("stop erases the frame and restores the cursor", () => {
|
|
173
|
+
const { live, writes } = region();
|
|
174
|
+
live.start(() => ["frame"]);
|
|
175
|
+
writes.length = 0;
|
|
176
|
+
live.stop(["record"]);
|
|
177
|
+
expect(writes[0]).toBe("\x1b[1A\x1b[0J");
|
|
178
|
+
expect(writes[1]).toBe("record\n");
|
|
179
|
+
expect(writes[2]).toBe("\x1b[?25h");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("stop with nothing painted still shows the cursor", () => {
|
|
183
|
+
const { live, all } = region();
|
|
184
|
+
live.start(() => []);
|
|
185
|
+
live.stop();
|
|
186
|
+
expect(all()).toContain("\x1b[?25h");
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe("resize", () => {
|
|
191
|
+
test("a resize abandons the old block instead of mis-erasing it", () => {
|
|
192
|
+
// `painted` is a row count measured at the OLD width. After a narrowing,
|
|
193
|
+
// the block occupies more rows than it says, so a cursor-up of `painted`
|
|
194
|
+
// lands inside it and smears. The only safe move is to stop claiming
|
|
195
|
+
// ownership of those rows.
|
|
196
|
+
const { live, writes, emit, stream } = region({ columns: 80 });
|
|
197
|
+
live.start(() => ["x".repeat(70)]);
|
|
198
|
+
writes.length = 0;
|
|
199
|
+
(stream as unknown as { columns: number }).columns = 40;
|
|
200
|
+
emit("resize");
|
|
201
|
+
|
|
202
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: ESC is the thing being matched.
|
|
203
|
+
const CURSOR_UP = /\x1b\[\d+A/;
|
|
204
|
+
expect(writes.some((w) => CURSOR_UP.test(w))).toBe(false);
|
|
205
|
+
expect(writes.at(-1)?.replace(/\n$/, "")).toHaveLength(39);
|
|
206
|
+
live.stop();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("the resize listener is removed on stop", () => {
|
|
210
|
+
const { live, writes, emit } = region();
|
|
211
|
+
live.start(() => ["frame"]);
|
|
212
|
+
live.stop();
|
|
213
|
+
writes.length = 0;
|
|
214
|
+
emit("resize");
|
|
215
|
+
expect(writes).toEqual([]);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe("handing the terminal over", () => {
|
|
220
|
+
test("pause erases the block and shows the cursor before the subprocess", () => {
|
|
221
|
+
const { live, writes } = region();
|
|
222
|
+
live.start(() => ["frame"]);
|
|
223
|
+
writes.length = 0;
|
|
224
|
+
live.pause();
|
|
225
|
+
expect(writes).toEqual(["\x1b[1A\x1b[0J", "\x1b[?25h"]);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("note does NOT repaint while paused", () => {
|
|
229
|
+
// A repaint here draws the block underneath output from whatever now owns
|
|
230
|
+
// the terminal — corrupt, and in the way of a readline prompt.
|
|
231
|
+
const { live, writes } = region();
|
|
232
|
+
live.start(() => ["FRAME"]);
|
|
233
|
+
live.pause();
|
|
234
|
+
writes.length = 0;
|
|
235
|
+
live.note("subprocess said something");
|
|
236
|
+
expect(writes).toEqual(["subprocess said something\n"]);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("resume repaints and re-hides", () => {
|
|
240
|
+
const { live, writes } = region();
|
|
241
|
+
live.start(() => ["frame"]);
|
|
242
|
+
live.pause();
|
|
243
|
+
writes.length = 0;
|
|
244
|
+
live.resume();
|
|
245
|
+
expect(writes[0]).toBe("\x1b[?25l");
|
|
246
|
+
expect(writes[1]).toBe("frame\n");
|
|
247
|
+
live.stop();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("withPaused resumes even when the work throws", async () => {
|
|
251
|
+
const { live, writes } = region();
|
|
252
|
+
live.start(() => ["frame"]);
|
|
253
|
+
await expect(
|
|
254
|
+
withPaused(live, async () => {
|
|
255
|
+
throw new Error("installer failed");
|
|
256
|
+
}),
|
|
257
|
+
).rejects.toThrow("installer failed");
|
|
258
|
+
// A resume repaint after the throw proves the region was handed back.
|
|
259
|
+
expect(writes.at(-1)).toBe("frame\n");
|
|
260
|
+
live.stop();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("withPaused returns the work's value", async () => {
|
|
264
|
+
const { live } = region();
|
|
265
|
+
live.start(() => ["frame"]);
|
|
266
|
+
expect(await withPaused(live, async () => 42)).toBe(42);
|
|
267
|
+
live.stop();
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
describe("console capture — the invariant as a mechanism", () => {
|
|
272
|
+
/** Console is process-global: every test here must put it back. */
|
|
273
|
+
let restore: (() => void) | null = null;
|
|
274
|
+
afterEach(() => {
|
|
275
|
+
restore?.();
|
|
276
|
+
restore = null;
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
function capturing(opts: Parameters<typeof fakeStream>[0] = {}) {
|
|
280
|
+
const out = fakeStream(opts);
|
|
281
|
+
const err = fakeStream(opts);
|
|
282
|
+
const live = new LiveRegion({
|
|
283
|
+
stream: out.stream,
|
|
284
|
+
errorStream: err.stream,
|
|
285
|
+
animate: true,
|
|
286
|
+
fps: 1000,
|
|
287
|
+
});
|
|
288
|
+
const original = {
|
|
289
|
+
log: console.log,
|
|
290
|
+
info: console.info,
|
|
291
|
+
warn: console.warn,
|
|
292
|
+
error: console.error,
|
|
293
|
+
};
|
|
294
|
+
restore = () => {
|
|
295
|
+
live.stop();
|
|
296
|
+
Object.assign(console, original);
|
|
297
|
+
};
|
|
298
|
+
return { live, out, err };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
test("a bare console.log from anywhere is routed, not left inside the block", () => {
|
|
302
|
+
// This is the production bug: plugin-manager's stale-marketplace advisory
|
|
303
|
+
// prints from two call layers below the catalog step. Without capture it
|
|
304
|
+
// lands under the block, the next cursor-up is short by a row, and the
|
|
305
|
+
// advisory is erased unread.
|
|
306
|
+
const { live, out } = capturing();
|
|
307
|
+
live.start(() => ["FRAME"]);
|
|
308
|
+
out.writes.length = 0;
|
|
309
|
+
console.log("ℹ Marketplace magus is stale");
|
|
310
|
+
|
|
311
|
+
expect(out.writes).toEqual([
|
|
312
|
+
"\x1b[1A\x1b[0J",
|
|
313
|
+
"ℹ Marketplace magus is stale\n",
|
|
314
|
+
"FRAME\n",
|
|
315
|
+
]);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("without capture the same call corrupts the block", () => {
|
|
319
|
+
// The negative control. This is what the code did before the fix, and it
|
|
320
|
+
// is why documenting the rule was not enough.
|
|
321
|
+
const out = fakeStream();
|
|
322
|
+
const live = new LiveRegion({
|
|
323
|
+
stream: out.stream,
|
|
324
|
+
animate: true,
|
|
325
|
+
fps: 1000,
|
|
326
|
+
captureConsole: false,
|
|
327
|
+
});
|
|
328
|
+
live.start(() => ["FRAME"]);
|
|
329
|
+
out.writes.length = 0;
|
|
330
|
+
out.stream.write("ℹ Marketplace magus is stale\n"); // an uncaptured write
|
|
331
|
+
live.refresh();
|
|
332
|
+
// One row up from a cursor now two rows below the block's start: the
|
|
333
|
+
// erase begins inside the frame rather than at its top.
|
|
334
|
+
expect(out.writes[1]).toBe("\x1b[1A\x1b[0J");
|
|
335
|
+
live.stop();
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("stderr stays on stderr", () => {
|
|
339
|
+
// Silently moving warn/error to stdout would break `2>err.log`.
|
|
340
|
+
const { live, out, err } = capturing();
|
|
341
|
+
live.start(() => ["FRAME"]);
|
|
342
|
+
out.writes.length = 0;
|
|
343
|
+
err.writes.length = 0;
|
|
344
|
+
console.warn("a warning");
|
|
345
|
+
console.error("an error");
|
|
346
|
+
|
|
347
|
+
expect(err.writes.filter((w) => !w.startsWith("\x1b"))).toEqual([
|
|
348
|
+
"a warning\n",
|
|
349
|
+
"an error\n",
|
|
350
|
+
]);
|
|
351
|
+
expect(out.writes.some((w) => w.includes("a warning"))).toBe(false);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test("format specifiers and multi-line writes survive", () => {
|
|
355
|
+
// A newline inside one write is a row the erase arithmetic cannot see,
|
|
356
|
+
// so it has to be split into separate notes.
|
|
357
|
+
const { live, out } = capturing();
|
|
358
|
+
live.start(() => ["FRAME"]);
|
|
359
|
+
out.writes.length = 0;
|
|
360
|
+
console.log("%s has %d plugins\nsecond line", "magus", 12);
|
|
361
|
+
|
|
362
|
+
expect(out.writes).toContain("magus has 12 plugins\n");
|
|
363
|
+
expect(out.writes).toContain("second line\n");
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test("console is released on stop", () => {
|
|
367
|
+
const original = console.log;
|
|
368
|
+
const { live } = capturing();
|
|
369
|
+
live.start(() => ["FRAME"]);
|
|
370
|
+
expect(console.log).not.toBe(original);
|
|
371
|
+
live.stop();
|
|
372
|
+
expect(console.log).toBe(original);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test("console is released while paused, so a subprocess writes directly", () => {
|
|
376
|
+
const original = console.log;
|
|
377
|
+
const { live } = capturing();
|
|
378
|
+
live.start(() => ["FRAME"]);
|
|
379
|
+
live.pause();
|
|
380
|
+
expect(console.log).toBe(original);
|
|
381
|
+
live.resume();
|
|
382
|
+
expect(console.log).not.toBe(original);
|
|
383
|
+
});
|
|
384
|
+
});
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CLI-tools catalogue can no longer emit a bare `pip`, or shell syntax.
|
|
3
|
+
*
|
|
4
|
+
* Two defects lived in this screen, and both were data problems dressed as code
|
|
5
|
+
* problems:
|
|
6
|
+
*
|
|
7
|
+
* 1. **Bare `pip`.** `getUpdateCommand`'s `pip` and `default` arms returned the
|
|
8
|
+
* catalogue's `installCommand` verbatim, and `getUninstallCommand` emitted
|
|
9
|
+
* `pip uninstall -y`. `pip` is frequently a zsh alias rather than an
|
|
10
|
+
* executable, and a machine that manages Python tools with uv or pipx may
|
|
11
|
+
* have no `pip` at all. `services/toolchain.ts` already knew how to answer
|
|
12
|
+
* "how does Python work here"; this screen was not asking it.
|
|
13
|
+
* 2. **Shell syntax in data.** `aider`'s entry was
|
|
14
|
+
* `pip install aider-install && aider-install` — a `&&` chain in a catalogue
|
|
15
|
+
* field that got handed to `/bin/bash`.
|
|
16
|
+
*
|
|
17
|
+
* Both are now unrepresentable: the field is gone and the command is derived.
|
|
18
|
+
* These tests assert that over the whole catalogue rather than over an example,
|
|
19
|
+
* so a new entry cannot reintroduce either.
|
|
20
|
+
*
|
|
21
|
+
* Pure — no React, no network, no PATH dependency. Every Python installer is
|
|
22
|
+
* pinned explicitly, per the convention `toolchain.test.ts` established: the
|
|
23
|
+
* production default is resolved from PATH, so an unpinned assertion would
|
|
24
|
+
* encode whatever happens to be installed on the machine running the suite.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { describe, expect, test } from "bun:test";
|
|
28
|
+
import { cliTools } from "../data/cli-tools.js";
|
|
29
|
+
import {
|
|
30
|
+
type InstallMethod,
|
|
31
|
+
cliToolInstall,
|
|
32
|
+
cliToolUninstall,
|
|
33
|
+
cliToolUpdate,
|
|
34
|
+
} from "../services/cli-tool-commands.js";
|
|
35
|
+
import {
|
|
36
|
+
PYTHON_CLI_INSTALLERS,
|
|
37
|
+
type PythonCliInstaller,
|
|
38
|
+
resolvePythonCliInstaller,
|
|
39
|
+
} from "../services/toolchain.js";
|
|
40
|
+
import { versionFromPypiJson } from "../ui/screens/CliToolsScreen.js";
|
|
41
|
+
import { type Command, formatCommand } from "../utils/command-utils.js";
|
|
42
|
+
|
|
43
|
+
const byProbe = (probe: string): PythonCliInstaller => {
|
|
44
|
+
const found = PYTHON_CLI_INSTALLERS.find((i) => i.probe === probe);
|
|
45
|
+
if (!found) throw new Error(`no python installer with probe ${probe}`);
|
|
46
|
+
return found;
|
|
47
|
+
};
|
|
48
|
+
const PYTHON_PROBES = PYTHON_CLI_INSTALLERS.map((i) => i.probe);
|
|
49
|
+
|
|
50
|
+
/** The methods the resolver models, i.e. the ones that reach toolchain.ts. */
|
|
51
|
+
const RESOLVED_METHODS: InstallMethod[] = ["npm", "bun", "brew", "pip"];
|
|
52
|
+
|
|
53
|
+
describe("the catalogue is well-formed", () => {
|
|
54
|
+
test("it has entries, and each names a package manager and a package", () => {
|
|
55
|
+
// Guard: every assertion below iterates the catalogue, so an empty or
|
|
56
|
+
// malformed one would make them all pass vacuously.
|
|
57
|
+
expect(cliTools.length).toBeGreaterThan(5);
|
|
58
|
+
for (const tool of cliTools) {
|
|
59
|
+
expect(typeof tool.packageName).toBe("string");
|
|
60
|
+
expect(tool.packageName.length).toBeGreaterThan(0);
|
|
61
|
+
expect(["bun", "npm", "pip", "brew", "go"]).toContain(
|
|
62
|
+
tool.packageManager,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("no builder ever emits a bare `pip`", () => {
|
|
69
|
+
test("install: cmd is the resolver's probe, never `pip`", () => {
|
|
70
|
+
for (const tool of cliTools) {
|
|
71
|
+
const cmd = cliToolInstall(tool);
|
|
72
|
+
expect(cmd.cmd).not.toBe("pip");
|
|
73
|
+
if (tool.packageManager === "pip") {
|
|
74
|
+
// Whatever the machine running this resolved to, it must be one of
|
|
75
|
+
// the three modelled installers — and `cmd` must BE that probe,
|
|
76
|
+
// since that is the executable the syscall looks up.
|
|
77
|
+
expect(PYTHON_PROBES).toContain(cmd.cmd);
|
|
78
|
+
expect(cmd.cmd).toBe(resolvePythonCliInstaller().probe);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("update and uninstall: cmd is never `pip`, for any method", () => {
|
|
84
|
+
for (const tool of cliTools) {
|
|
85
|
+
for (const method of RESOLVED_METHODS) {
|
|
86
|
+
for (const cmd of [
|
|
87
|
+
cliToolUpdate(tool, method),
|
|
88
|
+
cliToolUninstall(tool, method),
|
|
89
|
+
]) {
|
|
90
|
+
if (!cmd) continue;
|
|
91
|
+
expect(cmd.cmd).not.toBe("pip");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("each pinned Python installer drives all three of its own commands", () => {
|
|
98
|
+
// Pinned explicitly so this holds on a runner with no uv. The `pip`
|
|
99
|
+
// method must route to the SAME executable for install, update and
|
|
100
|
+
// uninstall — installing with uv and uninstalling with python3 would look
|
|
101
|
+
// like it worked and leave the tool on disk.
|
|
102
|
+
for (const probe of PYTHON_PROBES) {
|
|
103
|
+
const python = byProbe(probe);
|
|
104
|
+
expect(python.install("x").cmd).toBe(probe);
|
|
105
|
+
expect(python.upgrade("x").cmd).toBe(probe);
|
|
106
|
+
expect(python.uninstall("x").cmd).toBe(probe);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("no builder can emit shell syntax", () => {
|
|
112
|
+
test("no argv element of any command contains &&, | or ;", () => {
|
|
113
|
+
// The catalogue's only `&&` chain was aider's installCommand. With the
|
|
114
|
+
// field gone there is nowhere left for shell syntax to enter, and this
|
|
115
|
+
// asserts that over every entry and every method rather than over that
|
|
116
|
+
// one case.
|
|
117
|
+
const all: Command[] = [];
|
|
118
|
+
for (const tool of cliTools) {
|
|
119
|
+
all.push(cliToolInstall(tool));
|
|
120
|
+
for (const method of [
|
|
121
|
+
...RESOLVED_METHODS,
|
|
122
|
+
"pnpm" as const,
|
|
123
|
+
"yarn" as const,
|
|
124
|
+
]) {
|
|
125
|
+
const u = cliToolUpdate(tool, method);
|
|
126
|
+
const r = cliToolUninstall(tool, method);
|
|
127
|
+
if (u) all.push(u);
|
|
128
|
+
if (r) all.push(r);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
expect(all.length).toBeGreaterThan(0);
|
|
132
|
+
|
|
133
|
+
for (const cmd of all) {
|
|
134
|
+
for (const token of [cmd.cmd, ...cmd.args]) {
|
|
135
|
+
expect(token).not.toContain("&&");
|
|
136
|
+
expect(token).not.toContain("|");
|
|
137
|
+
expect(token).not.toContain(";");
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("the detected method is followed, not the catalogue's", () => {
|
|
144
|
+
test("pnpm and yarn keep their own shapes", () => {
|
|
145
|
+
// A tool the catalogue calls npm can still have been installed with
|
|
146
|
+
// pnpm. Updating it as npm would install a second copy beside the first,
|
|
147
|
+
// which is the conflict this screen exists to report.
|
|
148
|
+
const tool = cliTools.find((t) => t.packageManager === "npm")!;
|
|
149
|
+
expect(cliToolUpdate(tool, "pnpm")).toEqual({
|
|
150
|
+
cmd: "pnpm",
|
|
151
|
+
args: ["install", "-g", tool.packageName],
|
|
152
|
+
});
|
|
153
|
+
expect(cliToolUpdate(tool, "yarn")).toEqual({
|
|
154
|
+
cmd: "yarn",
|
|
155
|
+
args: ["global", "add", tool.packageName],
|
|
156
|
+
});
|
|
157
|
+
expect(cliToolUninstall(tool, "pnpm")).toEqual({
|
|
158
|
+
cmd: "pnpm",
|
|
159
|
+
args: ["remove", "-g", tool.packageName],
|
|
160
|
+
});
|
|
161
|
+
expect(cliToolUninstall(tool, "yarn")).toEqual({
|
|
162
|
+
cmd: "yarn",
|
|
163
|
+
args: ["global", "remove", tool.packageName],
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("`unknown` returns null rather than guessing", () => {
|
|
168
|
+
// Preserves the observable behaviour: handleInstall reads
|
|
169
|
+
// `installed && updateCommand ? updateCommand : install…`, so null falls
|
|
170
|
+
// back to a plain install — which is what the old `default:` arm did by
|
|
171
|
+
// returning `tool.installCommand`.
|
|
172
|
+
const tool = cliTools[0]!;
|
|
173
|
+
expect(cliToolUpdate(tool, "unknown")).toBeNull();
|
|
174
|
+
expect(cliToolUninstall(tool, "unknown")).toBeNull();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("brew and go arms are pinned, since no catalogue entry exercises them", () => {
|
|
178
|
+
// Every entry today is bun/npm/pip, so these two ship untouched by real
|
|
179
|
+
// data. `go` has no uninstaller — `go install` writes a binary and
|
|
180
|
+
// records nothing — so null is correct rather than an invented `rm`.
|
|
181
|
+
const tool = cliTools[0]!;
|
|
182
|
+
expect(cliToolUpdate(tool, "brew", "some-formula")).toEqual({
|
|
183
|
+
cmd: "brew",
|
|
184
|
+
args: ["upgrade", "some-formula"],
|
|
185
|
+
});
|
|
186
|
+
expect(cliToolUninstall(tool, "brew", "some-formula")).toEqual({
|
|
187
|
+
cmd: "brew",
|
|
188
|
+
args: ["uninstall", "some-formula"],
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("derived commands still read as they did", () => {
|
|
194
|
+
test("claude renders exactly the literal the catalogue used to carry", () => {
|
|
195
|
+
// The entry whose command SHOULD not change. Its old
|
|
196
|
+
// `installCommand: "npm install -g @anthropic-ai/claude-code"` is now
|
|
197
|
+
// derived from packageManager + packageName, and must render identically.
|
|
198
|
+
const claude = cliTools.find((t) => t.name === "claude")!;
|
|
199
|
+
expect(formatCommand(cliToolInstall(claude))).toBe(
|
|
200
|
+
"npm install -g @anthropic-ai/claude-code",
|
|
201
|
+
);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("aider now installs the package its own fields already named", () => {
|
|
205
|
+
// The one deliberate behaviour change. The old command installed
|
|
206
|
+
// `aider-install`, a bootstrapper whose version nothing here ever read,
|
|
207
|
+
// while `packageName`, `checkCommand` and the PyPI query all said
|
|
208
|
+
// `aider-chat`. The installCommand was the field that was out of step.
|
|
209
|
+
const aider = cliTools.find((t) => t.name === "aider")!;
|
|
210
|
+
expect(aider.packageName).toBe("aider-chat");
|
|
211
|
+
expect(cliToolInstall(aider).args).toContain("aider-chat");
|
|
212
|
+
expect(formatCommand(cliToolInstall(aider))).not.toContain("aider-install");
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe("versionFromPypiJson", () => {
|
|
217
|
+
// The shape of a real https://pypi.org/pypi/<pkg>/json body, trimmed to the
|
|
218
|
+
// two fields that matter. Captured rather than mocked so the parser is
|
|
219
|
+
// tested against what the endpoint actually returns.
|
|
220
|
+
const body = {
|
|
221
|
+
info: { name: "aider-chat", version: "0.86.1" },
|
|
222
|
+
releases: {},
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
test("reads info.version", () => {
|
|
226
|
+
expect(versionFromPypiJson(body)).toBe("0.86.1");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("anything unexpected yields undefined, never a throw", () => {
|
|
230
|
+
// A version check is advisory: it must degrade to "no update badge",
|
|
231
|
+
// never take the screen down. PyPI returning an error object, a 404 body,
|
|
232
|
+
// or nothing at all all land here.
|
|
233
|
+
expect(versionFromPypiJson({})).toBeUndefined();
|
|
234
|
+
expect(versionFromPypiJson({ info: {} })).toBeUndefined();
|
|
235
|
+
expect(versionFromPypiJson({ info: { version: 42 } })).toBeUndefined();
|
|
236
|
+
expect(versionFromPypiJson(null)).toBeUndefined();
|
|
237
|
+
expect(versionFromPypiJson("not json at all")).toBeUndefined();
|
|
238
|
+
});
|
|
239
|
+
});
|