claudeup 5.0.0 → 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.
@@ -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
+ }
@@ -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, { yes, allowAdopt: yes })) ? 0 : 1;
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
- --check Report only, write nothing (exits 1 if anything is
102
- missing or known to be behind)
103
- --yes, -y Skip the confirmation prompt
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