@zerotal/core 1.4.0 → 1.5.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +351 -0
  2. package/package.json +1 -1
  3. package/src/application/Application.ts +107 -9
  4. package/src/application/DevErrorPage.ts +82 -0
  5. package/src/application/diagnostics.ts +111 -0
  6. package/src/command/CommandRunner.ts +82 -1
  7. package/src/command/builtin/AssetsBuildCommand.ts +102 -0
  8. package/src/command/builtin/DeployCommand.ts +315 -0
  9. package/src/command/builtin/DevCommand.ts +88 -0
  10. package/src/command/builtin/DoctorCommand.ts +97 -0
  11. package/src/command/builtin/MakeCommandCommand.ts +2 -0
  12. package/src/command/builtin/RouteTypesCommand.ts +56 -0
  13. package/src/command/builtin/ServeCommand.ts +232 -44
  14. package/src/command/builtin/index.ts +5 -0
  15. package/src/command/scaffold/zerotal.ts.txt +2 -10
  16. package/src/config/AppConfig.ts +109 -2
  17. package/src/config/DeployConfig.ts +71 -0
  18. package/src/config/index.ts +2 -0
  19. package/src/config/registry.ts +1 -0
  20. package/src/container/Container.ts +3 -3
  21. package/src/container/inject.ts +3 -2
  22. package/src/context/RequestContext.ts +60 -0
  23. package/src/contracts/session.ts +18 -3
  24. package/src/dev/BuildCache.ts +312 -0
  25. package/src/dev/CssPlugins.ts +93 -7
  26. package/src/dev/DevBuildHook.ts +14 -1
  27. package/src/dev/DevDeck.ts +549 -0
  28. package/src/dev/DevOrchestrator.ts +166 -31
  29. package/src/dev/DevProcess.ts +221 -0
  30. package/src/dev/DevReloadMiddleware.ts +1 -1
  31. package/src/dev/DevSupervisor.ts +363 -0
  32. package/src/dev/bootBuild.ts +94 -0
  33. package/src/dev/index.ts +24 -0
  34. package/src/dev/startDevMode.ts +145 -0
  35. package/src/doctor/AppDoctor.ts +399 -0
  36. package/src/doctor/TransportProbe.ts +169 -0
  37. package/src/events/Emitter.ts +4 -3
  38. package/src/facade/facades/App.ts +10 -2
  39. package/src/helpers/index.ts +23 -1
  40. package/src/helpers/response.ts +18 -8
  41. package/src/http/Uri.ts +7 -3
  42. package/src/http/originGuard.ts +1 -1
  43. package/src/http/url.ts +10 -4
  44. package/src/index.ts +43 -0
  45. package/src/lock/LockManager.ts +190 -14
  46. package/src/lock/drivers/LockDriver.ts +11 -0
  47. package/src/lock/drivers/MemoryLockDriver.ts +21 -1
  48. package/src/lock/drivers/RedisLockDriver.ts +64 -8
  49. package/src/lock/drivers/SqliteLockDriver.ts +13 -0
  50. package/src/lock/errors.ts +26 -0
  51. package/src/lock/facades/Lock.ts +30 -5
  52. package/src/lock/index.ts +2 -2
  53. package/src/macros/config.macro.ts +2 -0
  54. package/src/provider/ServiceProvider.ts +40 -0
  55. package/src/router/Router.ts +111 -13
  56. package/src/router/registry.ts +123 -0
  57. package/src/router/routeTypes.ts +132 -0
  58. package/src/support/classRef.ts +27 -0
  59. package/src/support/env.ts +69 -2
  60. package/src/support/unroutedRoutes.ts +37 -0
@@ -0,0 +1,549 @@
1
+ /**
2
+ * The Deck — a deck of tabs, one card per dev process.
3
+ *
4
+ * Two renderers sit behind one interface. **Stream** interleaves every process's
5
+ * output as `[label] line`, plain text, no escape codes; **tabs** draws a real
6
+ * terminal UI with a tab bar and per-process scrollback. Stream is not a
7
+ * degraded fallback bolted on afterwards — it is the honest base case, the thing
8
+ * you want in a log file or CI, and it is chosen automatically whenever stdout
9
+ * is not a TTY. Tabs is a layer on top of it.
10
+ *
11
+ * ## Why we built this rather than pulling one in
12
+ *
13
+ * `@zerotal/core` carries exactly one external runtime dependency. A terminal
14
+ * multiplexer off npm would be the second, in the package every other one
15
+ * depends on, to draw a box. Bun ships every primitive this needs — column width
16
+ * that understands escapes (`Bun.stringWidth`), slicing that does not cut one in
17
+ * half (`Bun.sliceAnsi`), terminal size and resize events, and raw stdin.
18
+ *
19
+ * ## The terminal must survive us
20
+ *
21
+ * Raw mode plus the alternate screen left on is the classic TUI bug: the shell
22
+ * comes back with no echo, no line editing, and no scrollback. The restore is
23
+ * therefore registered *before* raw mode is entered, and it runs on every exit
24
+ * path there is — `q`, a signal, and an uncaught throw. It is also idempotent,
25
+ * because several of those can happen at once.
26
+ */
27
+ import type { OutputWriter } from "../command/OutputWriter.ts";
28
+ import type { DevProcessColor } from "./DevProcess.ts";
29
+ import type { DevProcessStatus } from "./DevSupervisor.ts";
30
+
31
+ /** What the dev command drives, whichever renderer is behind it. */
32
+ export interface Deck {
33
+ /** Draw the initial frame for these processes. */
34
+ start(statuses: DevProcessStatus[]): void;
35
+ /** One line of output from a process. */
36
+ line(name: string, text: string, stream: "stdout" | "stderr"): void;
37
+ /** A process changed state. */
38
+ state(status: DevProcessStatus): void;
39
+ /** A message from dev mode itself, not from any one process. */
40
+ notice(text: string): void;
41
+ /** Restore the terminal. Idempotent, and safe to call from a signal handler. */
42
+ stop(): void;
43
+ }
44
+
45
+ export interface DeckOptions {
46
+ writer: OutputWriter;
47
+ /** Restart the named process — bound to `r`. */
48
+ onRestart: (name: string) => void;
49
+ /** The user asked to quit — bound to `q`. */
50
+ onQuit: () => void;
51
+ /** Force a renderer. Defaults to tabs on a TTY, stream otherwise. */
52
+ mode?: "tabs" | "stream";
53
+ /** Overridable for tests. Defaults to `process.stdout`/`process.stdin`. */
54
+ stdout?: DeckStdout;
55
+ stdin?: DeckStdin;
56
+ }
57
+
58
+ /** The bits of `process.stdout` the deck reads. */
59
+ export interface DeckStdout {
60
+ isTTY?: boolean;
61
+ columns?: number;
62
+ rows?: number;
63
+ on?(event: "resize", listener: () => void): unknown;
64
+ off?(event: "resize", listener: () => void): unknown;
65
+ }
66
+
67
+ /** The bits of `process.stdin` the deck reads. */
68
+ export interface DeckStdin {
69
+ isTTY?: boolean;
70
+ setRawMode?(raw: boolean): unknown;
71
+ on?(event: "data", listener: (chunk: Buffer | string) => void): unknown;
72
+ off?(event: "data", listener: (chunk: Buffer | string) => void): unknown;
73
+ resume?(): unknown;
74
+ pause?(): unknown;
75
+ }
76
+
77
+ /**
78
+ * Pick a renderer.
79
+ *
80
+ * Tabs needs both halves of a terminal: somewhere to draw *and* somewhere to
81
+ * read keys from. A process with a TTY on stdout but not stdin (some CI
82
+ * runners, `zt dev < /dev/null`) would render a tab bar nobody could ever
83
+ * switch, so it gets stream instead.
84
+ */
85
+ export function createDeck(options: DeckOptions): Deck {
86
+ const stdout = options.stdout ?? process.stdout;
87
+ const stdin = options.stdin ?? process.stdin;
88
+ const wantsTabs = options.mode ? options.mode === "tabs" : Boolean(stdout.isTTY && stdin.isTTY);
89
+ return wantsTabs
90
+ ? new TabsDeck(options, stdout, stdin)
91
+ : new StreamDeck(options.writer, Boolean(stdout.isTTY));
92
+ }
93
+
94
+ // ── Stream mode ───────────────────────────────────────────────────────────────
95
+
96
+ /**
97
+ * Interleaved, prefixed, one line at a time.
98
+ *
99
+ * Colour is applied only when stdout is a terminal. Piped to a file this emits
100
+ * nothing but text, which is the point — a log with escape codes in it is a log
101
+ * you have to clean before you can read it.
102
+ */
103
+ export class StreamDeck implements Deck {
104
+ private _width = 0;
105
+ private readonly _colors = new Map<string, DevProcessColor>();
106
+
107
+ constructor(
108
+ private readonly _writer: OutputWriter,
109
+ private readonly _color: boolean,
110
+ ) {}
111
+
112
+ start(statuses: DevProcessStatus[]): void {
113
+ for (const status of statuses) this._colors.set(status.name, status.color);
114
+ // Pad every prefix to the same width so the output reads as columns rather
115
+ // than a ragged left edge that shifts whenever a different process speaks.
116
+ this._width = Math.max(0, ...statuses.map((status) => status.label.length));
117
+ for (const status of statuses) this.state(status);
118
+ }
119
+
120
+ line(name: string, text: string, stream: "stdout" | "stderr"): void {
121
+ const prefix = this._prefix(name);
122
+ // Strip the *child's* colour too when this is not a terminal. A process
123
+ // spawned with a pipe often keeps colouring anyway (Zerotal's own logger
124
+ // does), and a log file full of escape codes is one you have to clean before
125
+ // you can read it. On a real terminal it passes through untouched — there it
126
+ // is information, not noise.
127
+ const body = this._color ? text : _stripAnsi(text);
128
+ this._writer.writeLine(
129
+ `${prefix} ${this._color && stream === "stderr" ? _paint(body, "red") : body}`,
130
+ );
131
+ }
132
+
133
+ state(status: DevProcessStatus): void {
134
+ this._colors.set(status.name, status.color);
135
+ if (status.state === "starting" || status.state === "running") return;
136
+ const detail = status.exitCode === undefined ? "" : ` (exit ${status.exitCode})`;
137
+ this._writer.writeLine(`${this._prefix(status.name)} ${status.state}${detail}`);
138
+ }
139
+
140
+ notice(text: string): void {
141
+ this._writer.writeLine(`${this._pad("zerotal")} ${text}`);
142
+ }
143
+
144
+ stop(): void {
145
+ // Nothing was taken over, so there is nothing to give back.
146
+ }
147
+
148
+ private _prefix(name: string): string {
149
+ const label = this._pad(name);
150
+ const color = this._colors.get(name);
151
+ return this._color && color ? _paint(label, color) : label;
152
+ }
153
+
154
+ private _pad(label: string): string {
155
+ return `[${label.padEnd(this._width)}]`;
156
+ }
157
+ }
158
+
159
+ // ── Tabs mode ─────────────────────────────────────────────────────────────────
160
+
161
+ /** Lines kept per process. Beyond this the oldest are dropped. */
162
+ const SCROLLBACK = 5_000;
163
+ /** Repaints are coalesced to roughly one animation frame. */
164
+ const REPAINT_MS = 16;
165
+
166
+ /** One process's card in the deck. */
167
+ interface Card {
168
+ status: DevProcessStatus;
169
+ /** Ring buffer of rendered lines, oldest first. */
170
+ lines: string[];
171
+ /** Lines scrolled up from the bottom. 0 means pinned to the newest line. */
172
+ scroll: number;
173
+ }
174
+
175
+ export class TabsDeck implements Deck {
176
+ private readonly _cards: Card[] = [];
177
+ private readonly _index = new Map<string, Card>();
178
+ private _focused = 0;
179
+ private _search = "";
180
+ private _searching = false;
181
+ private _timestamps = false;
182
+ private _streaming = false;
183
+ private _repaintTimer: ReturnType<typeof setTimeout> | undefined;
184
+ private _restored = false;
185
+ private _stream: StreamDeck | undefined;
186
+
187
+ private readonly _onData = (chunk: Buffer | string): void => this._key(chunk.toString());
188
+ private readonly _onResize = (): void => this._paint();
189
+ private readonly _onExit = (): void => this.stop();
190
+
191
+ constructor(
192
+ private readonly _options: DeckOptions,
193
+ private readonly _stdout: DeckStdout,
194
+ private readonly _stdin: DeckStdin,
195
+ ) {}
196
+
197
+ start(statuses: DevProcessStatus[]): void {
198
+ for (const status of statuses) {
199
+ const card: Card = { status, lines: [], scroll: 0 };
200
+ this._cards.push(card);
201
+ this._index.set(status.name, card);
202
+ }
203
+
204
+ // Registered *before* the terminal is taken over, so a throw between here
205
+ // and the first paint still gives the shell back.
206
+ process.on("exit", this._onExit);
207
+ process.on("uncaughtException", this._onExit);
208
+ process.on("unhandledRejection", this._onExit);
209
+
210
+ this._write(ALT_SCREEN_ON + CURSOR_HIDE);
211
+ this._stdin.setRawMode?.(true);
212
+ this._stdin.resume?.();
213
+ this._stdin.on?.("data", this._onData);
214
+ this._stdout.on?.("resize", this._onResize);
215
+
216
+ this._paint();
217
+ }
218
+
219
+ line(name: string, text: string, stream: "stdout" | "stderr"): void {
220
+ const card = this._index.get(name);
221
+ if (!card) return;
222
+
223
+ const stamp = _clock();
224
+ const body = stream === "stderr" ? _paint(text, "red") : text;
225
+ card.lines.push(`${stamp}${body}`);
226
+ if (card.lines.length > SCROLLBACK) card.lines.shift();
227
+
228
+ // A tab the user is not looking at still buffers; only the visible one costs
229
+ // a repaint. In stream mode every line prints regardless of focus.
230
+ if (this._streaming) this._stream?.line(name, text, stream);
231
+ else if (this._cards[this._focused] === card) this._schedulePaint();
232
+ }
233
+
234
+ state(status: DevProcessStatus): void {
235
+ const card = this._index.get(status.name);
236
+ if (!card) return;
237
+ card.status = status;
238
+ if (this._streaming) this._stream?.state(status);
239
+ else this._schedulePaint();
240
+ }
241
+
242
+ notice(text: string): void {
243
+ if (this._streaming) {
244
+ this._stream?.notice(text);
245
+ return;
246
+ }
247
+ const card = this._cards[this._focused];
248
+ if (!card) return;
249
+ card.lines.push(`${_clock()}${_paint(text, "yellow")}`);
250
+ this._schedulePaint();
251
+ }
252
+
253
+ stop(): void {
254
+ if (this._restored) return;
255
+ this._restored = true;
256
+
257
+ // Dropped here rather than left as one-shots: a deck that has given the
258
+ // terminal back has no business still holding process-wide handlers, and in
259
+ // a long-lived process (or a test suite) they would accumulate.
260
+ process.off("exit", this._onExit);
261
+ process.off("uncaughtException", this._onExit);
262
+ process.off("unhandledRejection", this._onExit);
263
+
264
+ this._stdin.off?.("data", this._onData);
265
+ this._stdout.off?.("resize", this._onResize);
266
+ try {
267
+ this._stdin.setRawMode?.(false);
268
+ } catch {
269
+ // stdin may already be closed during shutdown; the alt-screen exit below
270
+ // is the part that actually matters to the user's shell.
271
+ }
272
+ this._stdin.pause?.();
273
+ if (this._repaintTimer) clearTimeout(this._repaintTimer);
274
+ this._write(CURSOR_SHOW + ALT_SCREEN_OFF);
275
+ }
276
+
277
+ // ── Input ──────────────────────────────────────────────────────────────────
278
+
279
+ private _key(sequence: string): void {
280
+ if (this._searching) {
281
+ this._searchKey(sequence);
282
+ return;
283
+ }
284
+
285
+ switch (sequence) {
286
+ case "q":
287
+ case "\x03": // Ctrl-C — the deck owns the terminal, so it owns the quit.
288
+ this._options.onQuit();
289
+ return;
290
+ case "r": {
291
+ const card = this._cards[this._focused];
292
+ if (card) this._options.onRestart(card.status.name);
293
+ return;
294
+ }
295
+ case "c": {
296
+ const card = this._cards[this._focused];
297
+ if (card) {
298
+ card.lines.length = 0;
299
+ card.scroll = 0;
300
+ }
301
+ break;
302
+ }
303
+ case "/":
304
+ this._searching = true;
305
+ this._search = "";
306
+ break;
307
+ case "s":
308
+ this._toggleStream();
309
+ return;
310
+ case "t":
311
+ this._timestamps = !this._timestamps;
312
+ break;
313
+ case "\t":
314
+ case ARROW_RIGHT:
315
+ this._focus(this._focused + 1);
316
+ break;
317
+ case ARROW_LEFT:
318
+ this._focus(this._focused - 1);
319
+ break;
320
+ case PAGE_UP:
321
+ this._scroll(this._bodyHeight());
322
+ break;
323
+ case PAGE_DOWN:
324
+ this._scroll(-this._bodyHeight());
325
+ break;
326
+ default: {
327
+ if (sequence >= "1" && sequence <= "9") this._focus(Number(sequence) - 1);
328
+ else return;
329
+ }
330
+ }
331
+
332
+ this._paint();
333
+ }
334
+
335
+ private _searchKey(sequence: string): void {
336
+ if (sequence === "\r" || sequence === "\n" || sequence === "\x1b") {
337
+ // Enter keeps the filter and hands the keyboard back; Escape drops it.
338
+ this._searching = false;
339
+ if (sequence === "\x1b") this._search = "";
340
+ } else if (sequence === "\x7f" || sequence === "\b") {
341
+ this._search = this._search.slice(0, -1);
342
+ } else if (sequence === "\x03") {
343
+ this._searching = false;
344
+ this._search = "";
345
+ } else if (!sequence.startsWith("\x1b")) {
346
+ this._search += sequence;
347
+ }
348
+
349
+ const card = this._cards[this._focused];
350
+ if (card) card.scroll = 0;
351
+ this._paint();
352
+ }
353
+
354
+ private _focus(index: number): void {
355
+ if (this._cards.length === 0) return;
356
+ const count = this._cards.length;
357
+ this._focused = ((index % count) + count) % count;
358
+ this._search = "";
359
+ }
360
+
361
+ private _scroll(by: number): void {
362
+ const card = this._cards[this._focused];
363
+ if (!card) return;
364
+ const total = this._visibleLines(card).length;
365
+ const max = Math.max(0, total - this._bodyHeight());
366
+ card.scroll = Math.min(max, Math.max(0, card.scroll + by));
367
+ }
368
+
369
+ /**
370
+ * Hand the terminal back and print plainly from here on.
371
+ *
372
+ * The buffered scrollback is deliberately *not* replayed: the user pressed `s`
373
+ * to watch what happens next, and dumping several thousand buffered lines
374
+ * first would bury it.
375
+ */
376
+ private _toggleStream(): void {
377
+ this._streaming = !this._streaming;
378
+ if (this._streaming) {
379
+ this.stop();
380
+ this._restored = false; // stop() may still need to run again on quit.
381
+ this._stream = new StreamDeck(this._options.writer, Boolean(this._stdout.isTTY));
382
+ this._stream.start(this._cards.map((card) => card.status));
383
+ this._stream.notice("stream mode — press Ctrl-C to quit");
384
+ return;
385
+ }
386
+ this._stream = undefined as StreamDeck | undefined;
387
+ this._write(ALT_SCREEN_ON + CURSOR_HIDE);
388
+ this._stdin.setRawMode?.(true);
389
+ this._paint();
390
+ }
391
+
392
+ // ── Rendering ──────────────────────────────────────────────────────────────
393
+
394
+ private _schedulePaint(): void {
395
+ if (this._repaintTimer) return;
396
+ this._repaintTimer = setTimeout(() => {
397
+ this._repaintTimer = undefined as ReturnType<typeof setTimeout> | undefined;
398
+ this._paint();
399
+ }, REPAINT_MS);
400
+ }
401
+
402
+ private _columns(): number {
403
+ return Math.max(20, this._stdout.columns ?? 80);
404
+ }
405
+
406
+ private _rows(): number {
407
+ return Math.max(6, this._stdout.rows ?? 24);
408
+ }
409
+
410
+ /** Rows available for process output: everything but the tab bar, rule, and footer. */
411
+ private _bodyHeight(): number {
412
+ return Math.max(1, this._rows() - 3);
413
+ }
414
+
415
+ private _paint(): void {
416
+ if (this._streaming || this._restored) return;
417
+
418
+ const width = this._columns();
419
+ const rows: string[] = [this._tabBar(width), _paint("─".repeat(width), "dim")];
420
+
421
+ const card = this._cards[this._focused];
422
+ const height = this._bodyHeight();
423
+ if (card) {
424
+ const lines = this._visibleLines(card);
425
+ const end = Math.max(0, lines.length - card.scroll);
426
+ const window = lines.slice(Math.max(0, end - height), end);
427
+ for (const line of window) rows.push(_fit(line, width));
428
+ for (let i = window.length; i < height; i++) rows.push("");
429
+ }
430
+
431
+ rows.push(this._footer(width));
432
+
433
+ // One write, not one per row: a repaint split across writes tears visibly on
434
+ // a slow terminal. `\x1b[K` clears each line's tail so a shorter line does
435
+ // not leave the previous frame's characters behind it.
436
+ this._write(CURSOR_HOME + rows.map((row) => row + CLEAR_LINE).join("\r\n") + CLEAR_BELOW);
437
+ }
438
+
439
+ private _tabBar(width: number): string {
440
+ const cells = this._cards.map((card, index) => {
441
+ const glyph = STATE_GLYPH[card.status.state];
442
+ const text = ` ${index + 1} ${card.status.label} ${glyph} `;
443
+ if (index === this._focused) return _invert(_paint(text, card.status.color));
444
+ return _paint(text, card.status.color);
445
+ });
446
+ return _fit(cells.join(_paint("│", "dim")), width);
447
+ }
448
+
449
+ private _footer(width: number): string {
450
+ if (this._searching) return _fit(_paint(`/${this._search}`, "yellow"), width);
451
+
452
+ const card = this._cards[this._focused];
453
+ const scrolled = card && card.scroll > 0 ? ` ↑${card.scroll}` : "";
454
+ const filtered = this._search ? ` /${this._search}` : "";
455
+ const keys =
456
+ "1-9 tab · ←/→ cycle · r restart · c clear · / search · t time · s stream · q quit";
457
+ return _fit(_paint(keys + filtered + scrolled, "dim"), width);
458
+ }
459
+
460
+ /** The focused card's lines, with the search filter and timestamp toggle applied. */
461
+ private _visibleLines(card: Card): string[] {
462
+ const needle = this._search.toLowerCase();
463
+ const out: string[] = [];
464
+ for (const entry of card.lines) {
465
+ const split = entry.indexOf("");
466
+ const stamp = entry.slice(0, split);
467
+ const body = entry.slice(split + 1);
468
+ if (needle && !body.toLowerCase().includes(needle)) continue;
469
+ out.push(this._timestamps ? `${_paint(stamp, "dim")} ${body}` : body);
470
+ }
471
+ return out;
472
+ }
473
+
474
+ private _write(text: string): void {
475
+ this._options.writer.write(text);
476
+ }
477
+ }
478
+
479
+ // ── Terminal primitives ───────────────────────────────────────────────────────
480
+
481
+ const ALT_SCREEN_ON = "\x1b[?1049h";
482
+ const ALT_SCREEN_OFF = "\x1b[?1049l";
483
+ const CURSOR_HIDE = "\x1b[?25l";
484
+ const CURSOR_SHOW = "\x1b[?25h";
485
+ const CURSOR_HOME = "\x1b[H";
486
+ const CLEAR_LINE = "\x1b[K";
487
+ const CLEAR_BELOW = "\x1b[J";
488
+
489
+ const ARROW_LEFT = "\x1b[D";
490
+ const ARROW_RIGHT = "\x1b[C";
491
+ const PAGE_UP = "\x1b[5~";
492
+ const PAGE_DOWN = "\x1b[6~";
493
+
494
+ const STATE_GLYPH: Record<DevProcessStatus["state"], string> = {
495
+ starting: "◌",
496
+ running: "●",
497
+ restarting: "◍",
498
+ exited: "○",
499
+ parked: "✗",
500
+ };
501
+
502
+ const ANSI: Record<DevProcessColor | "dim", string> = {
503
+ cyan: "36",
504
+ magenta: "35",
505
+ yellow: "33",
506
+ green: "32",
507
+ blue: "34",
508
+ red: "31",
509
+ dim: "2",
510
+ };
511
+
512
+ /**
513
+ * Remove SGR escape sequences, for output going somewhere that cannot render
514
+ * them. Deliberately narrow: colour and style only, so a sequence that means
515
+ * something structural is left alone rather than half-understood.
516
+ */
517
+ // eslint-disable-next-line no-control-regex -- the escape byte is the pattern.
518
+ const _SGR = /\x1b\[[0-9;]*m/g;
519
+
520
+ function _stripAnsi(text: string): string {
521
+ return text.replace(_SGR, "");
522
+ }
523
+
524
+ function _paint(text: string, color: DevProcessColor | "dim"): string {
525
+ return `\x1b[${ANSI[color]}m${text}\x1b[0m`;
526
+ }
527
+
528
+ function _invert(text: string): string {
529
+ return `\x1b[7m${text}\x1b[0m`;
530
+ }
531
+
532
+ /**
533
+ * Cut a styled string to `width` columns.
534
+ *
535
+ * `Bun.stringWidth` measures what the terminal will actually show — escape
536
+ * sequences are zero-width, and a CJK character or an emoji is two columns — and
537
+ * `Bun.sliceAnsi` cuts without severing an escape sequence, which is what turns
538
+ * the rest of the screen a colour it was never meant to be.
539
+ */
540
+ function _fit(text: string, width: number): string {
541
+ return Bun.stringWidth(text) <= width ? text : Bun.sliceAnsi(text, 0, width);
542
+ }
543
+
544
+ /** `HH:MM:SS` for the timestamp gutter. */
545
+ function _clock(): string {
546
+ const now = new Date();
547
+ const pad = (value: number): string => String(value).padStart(2, "0");
548
+ return `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
549
+ }