claudeup 5.0.1 → 6.0.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/src/__tests__/cli-ansi.test.ts +297 -0
- package/src/__tests__/cli-apply-seams.test.ts +233 -0
- package/src/__tests__/cli-live.test.ts +384 -0
- package/src/__tests__/cli-update-view.test.ts +286 -0
- package/src/__tests__/gitignore-fixer.test.ts +20 -1
- package/src/__tests__/marketplace-refresh.test.ts +63 -0
- package/src/cli/ansi.ts +512 -0
- package/src/cli/bootstrap.ts +0 -6
- package/src/cli/install.ts +4 -1
- package/src/cli/live.ts +374 -0
- package/src/cli/profile.ts +1 -1
- package/src/cli/router.ts +7 -3
- package/src/cli/update-view.ts +438 -0
- package/src/cli/update.ts +457 -129
- package/src/services/doctor-bins.ts +9 -1
- package/src/services/marketplace-refresh.ts +39 -1
package/src/cli/live.ts
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* live.ts — an in-place animated block that leaves its record in scrollback.
|
|
3
|
+
*
|
|
4
|
+
* `claudeup update` spends most of its wall clock inside three calls that print
|
|
5
|
+
* nothing: git pulls over the network, a catalog read, then serial binary
|
|
6
|
+
* probes. Then the apply loop prints one line per plugin, each only once that
|
|
7
|
+
* plugin has finished installing. The terminal sat frozen for the slow parts,
|
|
8
|
+
* which reads as a hang.
|
|
9
|
+
*
|
|
10
|
+
* This is the fix, and it is deliberately NOT a renderer. Two channels:
|
|
11
|
+
*
|
|
12
|
+
* - `note()` writes a PERMANENT line. It is the record, it scrolls, and it is
|
|
13
|
+
* printed in every mode including a pipe. Anything a user might paste into
|
|
14
|
+
* a bug report goes here.
|
|
15
|
+
* - `start(render)` drives a TRANSIENT block under that record, repainted on
|
|
16
|
+
* a timer so a spinner keeps moving while an await is outstanding. It is
|
|
17
|
+
* erased before the next note and again on `stop()`.
|
|
18
|
+
*
|
|
19
|
+
* The split is what lets the same command be both animated and pipeable. In a
|
|
20
|
+
* pipe, in CI, or under NO_COLOR the transient block is simply never painted;
|
|
21
|
+
* the notes still print, in order, exactly as before.
|
|
22
|
+
*
|
|
23
|
+
* ## Why the region hijacks `console`
|
|
24
|
+
*
|
|
25
|
+
* Anything printed while the region is live must go through `note()`. A bare
|
|
26
|
+
* `console.log` lands inside the block, and the next repaint's cursor-up count
|
|
27
|
+
* is then short by however many rows it wrote — so the erase starts partway
|
|
28
|
+
* INTO the block, leaves orphaned rows on screen, and scrolls the message that
|
|
29
|
+
* caused it out of reach.
|
|
30
|
+
*
|
|
31
|
+
* That was documented as a rule and it was not enough. `getAvailablePlugins`
|
|
32
|
+
* reaches `plugin-manager.ts`'s stale-marketplace advisory through two call
|
|
33
|
+
* layers, and it printed with a bare `console.log` from inside the catalog
|
|
34
|
+
* step: the advisory was erased unread and the frame smeared, every time a
|
|
35
|
+
* marketplace clone was stale. A rule that has to hold across a call graph this
|
|
36
|
+
* file does not own is not a rule, it is a hope.
|
|
37
|
+
*
|
|
38
|
+
* So the region CAPTURES `console` for as long as it is painting, and routes
|
|
39
|
+
* every write through the same erase-write-repaint path `note()` uses. The
|
|
40
|
+
* invariant is now a mechanism. Streams are preserved: `log`/`info` keep going
|
|
41
|
+
* to stdout and `warn`/`error` to stderr, because stderr is legitimately
|
|
42
|
+
* redirected on its own and silently moving it to stdout would break that.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import { format } from "node:util";
|
|
46
|
+
import { colorEnabled, truncate, width } from "./ansi.js";
|
|
47
|
+
|
|
48
|
+
const HIDE_CURSOR = "\x1b[?25l";
|
|
49
|
+
const SHOW_CURSOR = "\x1b[?25h";
|
|
50
|
+
/** Erase from the cursor to the end of the display. */
|
|
51
|
+
const ERASE_BELOW = "\x1b[0J";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Cursor visibility and a patched `console` are process-global state, so
|
|
55
|
+
* restoring them is too.
|
|
56
|
+
*
|
|
57
|
+
* A region killed by Ctrl+C or a crash without this leaves the user typing
|
|
58
|
+
* blind in their shell — a genuinely hostile failure they have to fix with
|
|
59
|
+
* `reset`. Registered once, on first use, and never removed.
|
|
60
|
+
*/
|
|
61
|
+
let restoreHooked = false;
|
|
62
|
+
const liveRegions = new Set<LiveRegion>();
|
|
63
|
+
|
|
64
|
+
function hookRestore(): void {
|
|
65
|
+
if (restoreHooked) return;
|
|
66
|
+
restoreHooked = true;
|
|
67
|
+
const restore = () => {
|
|
68
|
+
for (const region of liveRegions) region.forceRestore();
|
|
69
|
+
};
|
|
70
|
+
process.on("exit", restore);
|
|
71
|
+
// 128 + signum is the shell convention: SIGINT 130, SIGTERM 143, SIGHUP 129.
|
|
72
|
+
// Reporting 130 for all three told a CI runner "interrupted" when it had
|
|
73
|
+
// actually sent SIGTERM.
|
|
74
|
+
for (const [signal, signum] of [
|
|
75
|
+
["SIGINT", 2],
|
|
76
|
+
["SIGTERM", 15],
|
|
77
|
+
["SIGHUP", 1],
|
|
78
|
+
] as const) {
|
|
79
|
+
process.on(signal, () => {
|
|
80
|
+
restore();
|
|
81
|
+
process.exit(128 + signum);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface LiveOptions {
|
|
87
|
+
stream?: NodeJS.WriteStream;
|
|
88
|
+
/** Stderr sink for captured `console.warn`/`console.error`. */
|
|
89
|
+
errorStream?: NodeJS.WriteStream;
|
|
90
|
+
/** Repaints per second. 12 reads as motion without strobing the spinner. */
|
|
91
|
+
fps?: number;
|
|
92
|
+
/** Force the transient block on or off. Default: TTY and colour capable. */
|
|
93
|
+
animate?: boolean;
|
|
94
|
+
/**
|
|
95
|
+
* Capture `console` while painting. On by default — see the file header.
|
|
96
|
+
* Off only for tests that need to assert on the real console.
|
|
97
|
+
*/
|
|
98
|
+
captureConsole?: boolean;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A frame, rebuilt from scratch each repaint. `tick` advances once per frame. */
|
|
102
|
+
export type RenderFrame = (tick: number) => string[];
|
|
103
|
+
|
|
104
|
+
type ConsoleMethod = "log" | "info" | "warn" | "error";
|
|
105
|
+
|
|
106
|
+
export class LiveRegion {
|
|
107
|
+
private readonly stream: NodeJS.WriteStream;
|
|
108
|
+
private readonly errorStream: NodeJS.WriteStream;
|
|
109
|
+
private readonly interval: number;
|
|
110
|
+
private readonly wantsConsoleCapture: boolean;
|
|
111
|
+
/** Rows the transient block currently occupies. 0 means nothing is painted. */
|
|
112
|
+
private painted = 0;
|
|
113
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
114
|
+
private render: RenderFrame | null = null;
|
|
115
|
+
private tick = 0;
|
|
116
|
+
private cursorHidden = false;
|
|
117
|
+
/** True between `pause()` and `resume()`: something else owns the terminal. */
|
|
118
|
+
private paused = false;
|
|
119
|
+
private restoreConsole: (() => void) | null = null;
|
|
120
|
+
private onResize: (() => void) | null = null;
|
|
121
|
+
|
|
122
|
+
/** Whether the transient block is painted at all. */
|
|
123
|
+
readonly animated: boolean;
|
|
124
|
+
|
|
125
|
+
constructor(opts: LiveOptions = {}) {
|
|
126
|
+
this.stream = opts.stream ?? process.stdout;
|
|
127
|
+
this.errorStream = opts.errorStream ?? process.stderr;
|
|
128
|
+
this.interval = Math.max(1, Math.round(1000 / (opts.fps ?? 12)));
|
|
129
|
+
this.wantsConsoleCapture = opts.captureConsole ?? true;
|
|
130
|
+
this.animated =
|
|
131
|
+
opts.animate ?? (this.stream.isTTY === true && colorEnabled());
|
|
132
|
+
liveRegions.add(this);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Begin animating `render`. Replaces any frame already running. */
|
|
136
|
+
start(render: RenderFrame): void {
|
|
137
|
+
this.render = render;
|
|
138
|
+
if (!this.animated) return;
|
|
139
|
+
this.hideCursor();
|
|
140
|
+
this.captureConsole();
|
|
141
|
+
this.watchResize();
|
|
142
|
+
this.paint();
|
|
143
|
+
this.startTimer();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Repaint now rather than waiting for the next tick, after a state change. */
|
|
147
|
+
refresh(): void {
|
|
148
|
+
if (this.animated && this.render && !this.paused) this.paint();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Write permanent lines above the block: the scrollback record.
|
|
153
|
+
*
|
|
154
|
+
* Erase, write, repaint — in that order. Writing first would put the line
|
|
155
|
+
* below the block, where the next repaint's cursor-up arithmetic would
|
|
156
|
+
* count it as part of the frame and start eating the record.
|
|
157
|
+
*/
|
|
158
|
+
note(...lines: string[]): void {
|
|
159
|
+
this.writeThrough(this.stream, lines);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The same, on stderr — for captured `console.warn` / `console.error`. */
|
|
163
|
+
noteError(...lines: string[]): void {
|
|
164
|
+
this.writeThrough(this.errorStream, lines);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Hand the terminal to something else — a readline prompt, or a subprocess
|
|
169
|
+
* inheriting stdio. Both move the cursor themselves, so the block has to be
|
|
170
|
+
* gone and the cursor visible before either starts.
|
|
171
|
+
*
|
|
172
|
+
* `console` goes back to normal here too: whatever now owns the terminal is
|
|
173
|
+
* writing to it directly, so routing through a region that is not painting
|
|
174
|
+
* would reorder its output.
|
|
175
|
+
*/
|
|
176
|
+
pause(): void {
|
|
177
|
+
this.paused = true;
|
|
178
|
+
this.stopTimer();
|
|
179
|
+
this.erase();
|
|
180
|
+
this.releaseConsole();
|
|
181
|
+
this.showCursor();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Take the terminal back after `pause()`. */
|
|
185
|
+
resume(): void {
|
|
186
|
+
this.paused = false;
|
|
187
|
+
if (!this.animated || !this.render) return;
|
|
188
|
+
this.hideCursor();
|
|
189
|
+
this.captureConsole();
|
|
190
|
+
this.paint();
|
|
191
|
+
this.startTimer();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Stop animating. `final` is written as a permanent record — the transient
|
|
196
|
+
* frame is erased first, so the last thing on screen is the record and not a
|
|
197
|
+
* spinner frozen mid-rotation.
|
|
198
|
+
*/
|
|
199
|
+
stop(final: string[] = []): void {
|
|
200
|
+
this.stopTimer();
|
|
201
|
+
this.erase();
|
|
202
|
+
this.render = null;
|
|
203
|
+
this.paused = false;
|
|
204
|
+
this.releaseConsole();
|
|
205
|
+
this.unwatchResize();
|
|
206
|
+
for (const line of final) this.stream.write(`${line}\n`);
|
|
207
|
+
this.showCursor();
|
|
208
|
+
liveRegions.delete(this);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Exit hook only: put global state back without touching anything else. */
|
|
212
|
+
forceRestore(): void {
|
|
213
|
+
this.releaseConsole();
|
|
214
|
+
this.showCursor();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// -- internals -------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
private startTimer(): void {
|
|
220
|
+
if (this.timer) return;
|
|
221
|
+
this.timer = setInterval(() => {
|
|
222
|
+
this.tick++;
|
|
223
|
+
this.paint();
|
|
224
|
+
}, this.interval);
|
|
225
|
+
// Never let the repaint timer be the reason the process stays alive.
|
|
226
|
+
this.timer.unref?.();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private stopTimer(): void {
|
|
230
|
+
if (this.timer) {
|
|
231
|
+
clearInterval(this.timer);
|
|
232
|
+
this.timer = null;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Erase, write the lines to `to`, repaint. The one safe write order. */
|
|
237
|
+
private writeThrough(to: NodeJS.WriteStream, lines: string[]): void {
|
|
238
|
+
if (lines.length === 0) return;
|
|
239
|
+
this.erase();
|
|
240
|
+
for (const line of lines) to.write(`${line}\n`);
|
|
241
|
+
// Never repaint while paused: something else owns the terminal, and a
|
|
242
|
+
// frame drawn under its output is both corrupt and in the way.
|
|
243
|
+
if (this.animated && this.render && !this.paused) this.paint();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Route `console` through this region for as long as it is painting.
|
|
248
|
+
*
|
|
249
|
+
* Multi-line and format-string calls are handled by `util.format`, then
|
|
250
|
+
* split, so `console.log("a\nb")` becomes two permanent rows rather than one
|
|
251
|
+
* row containing a newline the erase arithmetic cannot see.
|
|
252
|
+
*/
|
|
253
|
+
private captureConsole(): void {
|
|
254
|
+
if (this.restoreConsole || !this.animated || !this.wantsConsoleCapture)
|
|
255
|
+
return;
|
|
256
|
+
const original: Record<ConsoleMethod, (...args: unknown[]) => void> = {
|
|
257
|
+
log: console.log,
|
|
258
|
+
info: console.info,
|
|
259
|
+
warn: console.warn,
|
|
260
|
+
error: console.error,
|
|
261
|
+
};
|
|
262
|
+
const route =
|
|
263
|
+
(method: ConsoleMethod, toStderr: boolean) =>
|
|
264
|
+
(...args: unknown[]) => {
|
|
265
|
+
if (this.paused || !this.render) {
|
|
266
|
+
original[method](...args);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
const lines = format(...args).split("\n");
|
|
270
|
+
if (toStderr) this.noteError(...lines);
|
|
271
|
+
else this.note(...lines);
|
|
272
|
+
};
|
|
273
|
+
console.log = route("log", false);
|
|
274
|
+
console.info = route("info", false);
|
|
275
|
+
console.warn = route("warn", true);
|
|
276
|
+
console.error = route("error", true);
|
|
277
|
+
this.restoreConsole = () => {
|
|
278
|
+
console.log = original.log;
|
|
279
|
+
console.info = original.info;
|
|
280
|
+
console.warn = original.warn;
|
|
281
|
+
console.error = original.error;
|
|
282
|
+
this.restoreConsole = null;
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private releaseConsole(): void {
|
|
287
|
+
this.restoreConsole?.();
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* A resize invalidates `painted`.
|
|
292
|
+
*
|
|
293
|
+
* `painted` is a row count measured at the OLD width, so after a narrowing
|
|
294
|
+
* the block occupies more rows than it says and the cursor-up erase lands
|
|
295
|
+
* inside it. There is no way to recover the true count, so the old frame is
|
|
296
|
+
* abandoned where it stands — one stale block left in scrollback, once —
|
|
297
|
+
* rather than mis-erased on every subsequent frame.
|
|
298
|
+
*/
|
|
299
|
+
private watchResize(): void {
|
|
300
|
+
if (this.onResize || typeof this.stream.on !== "function") return;
|
|
301
|
+
this.onResize = () => {
|
|
302
|
+
this.painted = 0;
|
|
303
|
+
if (this.animated && this.render && !this.paused) this.paint();
|
|
304
|
+
};
|
|
305
|
+
this.stream.on("resize", this.onResize);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private unwatchResize(): void {
|
|
309
|
+
if (!this.onResize) return;
|
|
310
|
+
this.stream.off?.("resize", this.onResize);
|
|
311
|
+
this.onResize = null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private hideCursor(): void {
|
|
315
|
+
if (this.cursorHidden || !this.animated) return;
|
|
316
|
+
this.stream.write(HIDE_CURSOR);
|
|
317
|
+
this.cursorHidden = true;
|
|
318
|
+
// Only now is there global state worth restoring on the way out.
|
|
319
|
+
hookRestore();
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
private showCursor(): void {
|
|
323
|
+
if (!this.cursorHidden) return;
|
|
324
|
+
this.stream.write(SHOW_CURSOR);
|
|
325
|
+
this.cursorHidden = false;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private erase(): void {
|
|
329
|
+
if (this.painted === 0) return;
|
|
330
|
+
this.stream.write(`\x1b[${this.painted}A${ERASE_BELOW}`);
|
|
331
|
+
this.painted = 0;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Repaint the block.
|
|
336
|
+
*
|
|
337
|
+
* Two clamps, both load-bearing. Lines are clipped one column short of the
|
|
338
|
+
* terminal width, because a line that reaches the last column auto-wraps in
|
|
339
|
+
* most terminals and then occupies two rows while `painted` still counts it
|
|
340
|
+
* as one — the block smears a row further down the screen every frame. And
|
|
341
|
+
* the block is capped one row short of the terminal height, because a block
|
|
342
|
+
* taller than the viewport scrolls the terminal and moves the rows the next
|
|
343
|
+
* cursor-up was aiming at.
|
|
344
|
+
*/
|
|
345
|
+
private paint(): void {
|
|
346
|
+
if (!this.animated || !this.render) return;
|
|
347
|
+
const cols = Math.max(20, this.stream.columns ?? 80);
|
|
348
|
+
const rows = Math.max(4, this.stream.rows ?? 24);
|
|
349
|
+
let lines = this.render(this.tick).map((l) =>
|
|
350
|
+
width(l) > cols - 1 ? truncate(l, cols - 1) : l,
|
|
351
|
+
);
|
|
352
|
+
if (lines.length > rows - 1) lines = lines.slice(0, rows - 1);
|
|
353
|
+
this.erase();
|
|
354
|
+
if (lines.length === 0) return;
|
|
355
|
+
this.stream.write(`${lines.join("\n")}\n`);
|
|
356
|
+
this.painted = lines.length;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Run `work` with the terminal handed back — for readline and for subprocesses
|
|
362
|
+
* that inherit stdio. Always resumes, including when `work` throws.
|
|
363
|
+
*/
|
|
364
|
+
export async function withPaused<T>(
|
|
365
|
+
region: LiveRegion,
|
|
366
|
+
work: () => Promise<T>,
|
|
367
|
+
): Promise<T> {
|
|
368
|
+
region.pause();
|
|
369
|
+
try {
|
|
370
|
+
return await work();
|
|
371
|
+
} finally {
|
|
372
|
+
region.resume();
|
|
373
|
+
}
|
|
374
|
+
}
|
package/src/cli/profile.ts
CHANGED
|
@@ -72,7 +72,7 @@ export async function initProfile(
|
|
|
72
72
|
}
|
|
73
73
|
// `profile init` exists to create the manifest, so it is the one caller that
|
|
74
74
|
// may do so unattended.
|
|
75
|
-
return (await ensureManifest(projectPath, {
|
|
75
|
+
return (await ensureManifest(projectPath, { allowAdopt: yes })) ? 0 : 1;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
export async function listProfiles(projectPath: string): Promise<number> {
|
package/src/cli/router.ts
CHANGED
|
@@ -98,9 +98,13 @@ without one is offered adoption on first run. See docs/team-configuration.md.
|
|
|
98
98
|
behind the version its marketplace publishes. Only the
|
|
99
99
|
newest version is installable, so a pin naming an older
|
|
100
100
|
one is reported, not chased.
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
101
|
+
APPLIES WITHOUT ASKING. It can only move forward — there
|
|
102
|
+
is no downgrade and no delete — so the plan is printed as
|
|
103
|
+
it runs rather than gated behind a prompt. This includes
|
|
104
|
+
running a profile's CLI-tool installers (brew, bun).
|
|
105
|
+
--dry-run Show the plan and stop. Writes nothing, exits 0.
|
|
106
|
+
--check Report only, write nothing. Exits 1 if anything is
|
|
107
|
+
missing or known to be behind — the CI gate.
|
|
104
108
|
profile list Show every profile; ● marks the active one
|
|
105
109
|
profile show <n> Print a profile's fully resolved closure
|
|
106
110
|
profile switch <n> Repoint the active profile — offline, no reinstall
|