@checkstack/scripts 0.3.4 → 0.4.1

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 (69) hide show
  1. package/package.json +15 -5
  2. package/src/commands/create.ts +16 -23
  3. package/src/commands/plugin-pack.ts +17 -28
  4. package/src/dev-tui/App.render.test.tsx +135 -0
  5. package/src/dev-tui/App.smoke.test.tsx +142 -0
  6. package/src/dev-tui/App.tsx +522 -0
  7. package/src/dev-tui/alert-buffer.test.ts +62 -0
  8. package/src/dev-tui/alert-buffer.ts +51 -0
  9. package/src/dev-tui/alt-screen.test.ts +66 -0
  10. package/src/dev-tui/alt-screen.ts +65 -0
  11. package/src/dev-tui/cli.tsx +89 -0
  12. package/src/dev-tui/fake-supervisor.ts +76 -0
  13. package/src/dev-tui/graceful-shutdown.test.ts +61 -0
  14. package/src/dev-tui/graceful-shutdown.ts +32 -0
  15. package/src/dev-tui/kill-tree.test.ts +47 -0
  16. package/src/dev-tui/kill-tree.ts +64 -0
  17. package/src/dev-tui/layout.test.ts +89 -0
  18. package/src/dev-tui/layout.ts +126 -0
  19. package/src/dev-tui/log-level.test.ts +94 -0
  20. package/src/dev-tui/log-level.ts +104 -0
  21. package/src/dev-tui/plain-runner.ts +60 -0
  22. package/src/dev-tui/process-config.test.ts +42 -0
  23. package/src/dev-tui/process-config.ts +61 -0
  24. package/src/dev-tui/readiness.test.ts +54 -0
  25. package/src/dev-tui/readiness.ts +44 -0
  26. package/src/dev-tui/scrollback.test.ts +83 -0
  27. package/src/dev-tui/scrollback.ts +82 -0
  28. package/src/dev-tui/supervisor.ts +231 -0
  29. package/src/dev-tui/text.test.ts +72 -0
  30. package/src/dev-tui/text.ts +101 -0
  31. package/src/dev-tui/types.ts +29 -0
  32. package/src/scaffold/index.ts +22 -0
  33. package/src/scaffold/resolve-versions.test.ts +49 -0
  34. package/src/scaffold/resolve-versions.ts +55 -0
  35. package/src/scaffold/rewrite-workspace-versions.test.ts +102 -0
  36. package/src/scaffold/rewrite-workspace-versions.ts +111 -0
  37. package/src/scaffold/scaffold-plugin.test.ts +209 -0
  38. package/src/scaffold/scaffold-plugin.ts +309 -0
  39. package/src/templates/backend/.changeset/initial.md.hbs +1 -1
  40. package/src/templates/backend/drizzle/0000_init.sql +7 -0
  41. package/src/templates/backend/drizzle/meta/0000_snapshot.json +65 -0
  42. package/src/templates/backend/drizzle/meta/_journal.json +13 -0
  43. package/src/templates/backend/drizzle.config.ts.hbs +5 -1
  44. package/src/templates/backend/package.json.hbs +7 -3
  45. package/src/templates/backend/src/index.ts.hbs +1 -1
  46. package/src/templates/backend/src/router.ts.hbs +1 -1
  47. package/src/templates/backend/src/service.ts.hbs +1 -1
  48. package/src/templates/common/.changeset/initial.md.hbs +1 -1
  49. package/src/templates/common/README.md.hbs +28 -11
  50. package/src/templates/common/package.json.hbs +1 -1
  51. package/src/templates/common/src/plugin-metadata.ts.hbs +1 -1
  52. package/src/templates/frontend/.changeset/initial.md.hbs +1 -1
  53. package/src/templates/frontend/package.json.hbs +2 -2
  54. package/src/templates/frontend/src/api.ts.hbs +2 -2
  55. package/src/templates/frontend/src/components/{{pluginNamePascal}}ListPage.tsx.hbs +1 -1
  56. package/src/templates/frontend/src/index.tsx.hbs +10 -4
  57. package/src/templates/standalone-root/.changeset/config.json.hbs +11 -0
  58. package/src/templates/standalone-root/.changeset/initial.md.hbs +9 -0
  59. package/src/templates/standalone-root/README.md.hbs +75 -0
  60. package/src/templates/standalone-root/eslint.config.mjs.hbs +37 -0
  61. package/src/templates/standalone-root/package.json.hbs +27 -0
  62. package/src/templates/standalone-root/tsconfig.json.hbs +13 -0
  63. package/src/templates.test.ts +20 -0
  64. package/src/tui/components.test.tsx +28 -0
  65. package/src/tui/components.tsx +159 -0
  66. package/src/tui/index.ts +31 -0
  67. package/src/tui/theme.test.ts +54 -0
  68. package/src/tui/theme.ts +60 -0
  69. package/src/utils/template.ts +42 -0
@@ -0,0 +1,522 @@
1
+ import React, { useEffect, useRef, useState } from "react";
2
+ import { Box, Text, useApp, useInput, useStdout } from "ink";
3
+ import {
4
+ defaultTheme,
5
+ KeyHints,
6
+ LevelText,
7
+ Panel,
8
+ Spinner,
9
+ StatusDot,
10
+ } from "../tui/index.ts";
11
+ import type { KeyHint } from "../tui/index.ts";
12
+ import { createAlertBuffer } from "./alert-buffer.ts";
13
+ import { createScrollback } from "./scrollback.ts";
14
+ import type { ScrollbackLine } from "./scrollback.ts";
15
+ import { isAlertLevel } from "./log-level.ts";
16
+ import { PROCESS_DEFS } from "./process-config.ts";
17
+ import {
18
+ computeLayout,
19
+ DEFAULT_TERMINAL_SIZE,
20
+ SIDEBAR_WIDTH,
21
+ } from "./layout.ts";
22
+ import type { TerminalSize } from "./layout.ts";
23
+ import type { Supervisor } from "./supervisor.ts";
24
+ import type { AlertEntry, ProcessId, ProcessStatus } from "./types.ts";
25
+
26
+ const SCROLLBACK_CAPACITY = 5000;
27
+ const ALERTS_CAPACITY = 8;
28
+
29
+ const KEY_HINTS: readonly KeyHint[] = [
30
+ { keys: "Tab/←→", label: "switch" },
31
+ { keys: "↑↓/PgUp/PgDn", label: "scroll" },
32
+ { keys: "r", label: "restart" },
33
+ { keys: "q/^C", label: "quit" },
34
+ ];
35
+
36
+ export interface AppProps {
37
+ supervisor: Supervisor;
38
+ /**
39
+ * Override the terminal size instead of reading ink's `useStdout`. Used by
40
+ * tests to render at a fixed, deterministic viewport; in production the
41
+ * effect below tracks the live stdout size (and its resize events).
42
+ */
43
+ size?: TerminalSize;
44
+ /**
45
+ * Preload a process's scrollback before the first render. `useStdout`-driven
46
+ * state updates and supervisor events do not flush during ink's one-shot
47
+ * `renderToString`, so tests seed the focused pane this way to exercise the
48
+ * height clamp against many long lines deterministically.
49
+ */
50
+ preloadLines?: Partial<Record<ProcessId, readonly ScrollbackLine[]>>;
51
+ /**
52
+ * Override which process is focused on first render. Defaults to the first
53
+ * process def; tests set this to render a flooded pane as the visible one.
54
+ */
55
+ initialFocused?: ProcessId;
56
+ /**
57
+ * Preload the pinned alerts list before the first render. Like
58
+ * `preloadLines`, this lets a one-shot render exercise the alerts clamp
59
+ * (which otherwise only fills via supervisor events that never flush during
60
+ * `renderToString`).
61
+ */
62
+ initialAlerts?: readonly AlertEntry[];
63
+ /**
64
+ * Called when the user quits (`q` / Ctrl-C). The runner passes its graceful
65
+ * shutdown here (kill children -> restore terminal -> exit); the App keeps the
66
+ * shutdown screen on screen for the whole kill window. Falls back to ink's
67
+ * `exit()` when omitted (tests render the App without a runner).
68
+ */
69
+ onQuit?: () => void;
70
+ }
71
+
72
+ /**
73
+ * The main dev-runner TUI. Subscribes to the supervisor's line/status events,
74
+ * keeps a per-process scrollback and a shared alert ring buffer, and renders
75
+ * the status bar, sidebar, main output pane, pinned alerts panel, and footer.
76
+ *
77
+ * All log-processing logic lives in the tested pure modules; this component is
78
+ * a thin renderer over them plus ink layout.
79
+ */
80
+ export function App({
81
+ supervisor,
82
+ size,
83
+ preloadLines,
84
+ initialFocused,
85
+ initialAlerts,
86
+ onQuit,
87
+ }: AppProps): React.ReactElement {
88
+ const { exit } = useApp();
89
+ const { stdout } = useStdout();
90
+
91
+ // Per-process scrollback buffers (stable across renders). Seeded once from
92
+ // `preloadLines` so a test (or a warm restart) can render with history.
93
+ const scrollbacks = useRef(
94
+ new Map(
95
+ PROCESS_DEFS.map((def) => {
96
+ const scrollback = createScrollback({ capacity: SCROLLBACK_CAPACITY });
97
+ for (const line of preloadLines?.[def.id] ?? []) {
98
+ scrollback.append(line);
99
+ }
100
+ return [def.id, scrollback];
101
+ }),
102
+ ),
103
+ );
104
+ const alerts = useRef(createAlertBuffer({ capacity: ALERTS_CAPACITY }));
105
+
106
+ const [focused, setFocused] = useState<ProcessId>(
107
+ initialFocused ?? PROCESS_DEFS[0]?.id ?? "deps",
108
+ );
109
+ const [scrollOffset, setScrollOffset] = useState(0);
110
+ const [statuses, setStatuses] = useState<Record<ProcessId, ProcessStatus>>({
111
+ deps: "starting",
112
+ backend: "starting",
113
+ frontend: "starting",
114
+ });
115
+ const [unread, setUnread] = useState<Record<ProcessId, number>>({
116
+ deps: 0,
117
+ backend: 0,
118
+ frontend: 0,
119
+ });
120
+ const [alertList, setAlertList] = useState<readonly AlertEntry[]>(
121
+ initialAlerts ?? [],
122
+ );
123
+ // A monotonically increasing tick to force re-render when scrollback changes.
124
+ const [, setTick] = useState(0);
125
+
126
+ // Shutdown overlay: once the user quits we swap the whole UI for an animated
127
+ // "shutting down" screen and keep it on screen while the runner kills the
128
+ // children. `spinnerFrame` advances on a timer only while shutting down.
129
+ const [shuttingDown, setShuttingDown] = useState(false);
130
+ const [spinnerFrame, setSpinnerFrame] = useState(0);
131
+
132
+ useEffect(() => {
133
+ if (!shuttingDown) {
134
+ return;
135
+ }
136
+ const interval = setInterval(() => {
137
+ setSpinnerFrame((frame) => frame + 1);
138
+ }, 80);
139
+ return () => clearInterval(interval);
140
+ }, [shuttingDown]);
141
+
142
+ // The live terminal size. An explicit `size` prop wins (tests); otherwise we
143
+ // seed from ink's stdout and track resize events so the clamp follows the
144
+ // terminal as it grows or shrinks.
145
+ const [liveSize, setLiveSize] = useState<TerminalSize>(() => ({
146
+ rows: size?.rows ?? stdout?.rows ?? DEFAULT_TERMINAL_SIZE.rows,
147
+ columns: size?.columns ?? stdout?.columns ?? DEFAULT_TERMINAL_SIZE.columns,
148
+ }));
149
+
150
+ useEffect(() => {
151
+ if (size !== undefined || stdout === undefined) {
152
+ return;
153
+ }
154
+ const onResize = (): void => {
155
+ setLiveSize({
156
+ rows: stdout.rows ?? DEFAULT_TERMINAL_SIZE.rows,
157
+ columns: stdout.columns ?? DEFAULT_TERMINAL_SIZE.columns,
158
+ });
159
+ };
160
+ // Sync once in case the size changed between the initial state and mount.
161
+ onResize();
162
+ stdout.on("resize", onResize);
163
+ return () => {
164
+ stdout.off("resize", onResize);
165
+ };
166
+ }, [size, stdout]);
167
+
168
+ const terminalSize: TerminalSize = size ?? liveSize;
169
+
170
+ const focusedRef = useRef(focused);
171
+ focusedRef.current = focused;
172
+
173
+ useEffect(() => {
174
+ supervisor.onLine((line) => {
175
+ scrollbacks.current.get(line.source)?.append({
176
+ text: line.text,
177
+ level: line.level,
178
+ });
179
+ if (isAlertLevel(line.level)) {
180
+ alerts.current.push({
181
+ source: line.source,
182
+ level: line.level,
183
+ text: line.text,
184
+ seq: line.seq,
185
+ });
186
+ setAlertList(alerts.current.list());
187
+ if (line.source !== focusedRef.current) {
188
+ setUnread((prev) => ({
189
+ ...prev,
190
+ [line.source]: prev[line.source] + 1,
191
+ }));
192
+ }
193
+ }
194
+ setTick((value) => value + 1);
195
+ });
196
+
197
+ supervisor.onStatus(({ id, status }) => {
198
+ setStatuses((prev) => ({ ...prev, [id]: status }));
199
+ });
200
+
201
+ supervisor.start();
202
+ // Supervisor lives for the lifetime of the app; subscriptions are not torn
203
+ // down here because unmount means process exit.
204
+ }, [supervisor]);
205
+
206
+ const quit = (): void => {
207
+ // Show the shutdown screen, then run teardown. The runner passes
208
+ // `onQuit` = its graceful shutdown (kill children -> restore -> exit), so the
209
+ // animation stays on screen for the whole kill window. Ctrl-C routes here
210
+ // too (ink's exitOnCtrlC is disabled), so it behaves exactly like `q`.
211
+ setShuttingDown(true);
212
+ (onQuit ?? exit)();
213
+ };
214
+
215
+ const switchTo = (id: ProcessId): void => {
216
+ setFocused(id);
217
+ setScrollOffset(0);
218
+ setUnread((prev) => ({ ...prev, [id]: 0 }));
219
+ };
220
+
221
+ useInput((input, key) => {
222
+ if (input === "q" || (key.ctrl && input === "c")) {
223
+ quit();
224
+ return;
225
+ }
226
+ if (input === "r") {
227
+ supervisor.restart(focusedRef.current);
228
+ return;
229
+ }
230
+
231
+ const order = PROCESS_DEFS.map((def) => def.id);
232
+ const currentIndex = order.indexOf(focusedRef.current);
233
+
234
+ if (key.tab || key.rightArrow) {
235
+ const next = order[(currentIndex + 1) % order.length] ?? order[0];
236
+ switchTo(next);
237
+ return;
238
+ }
239
+ if (key.leftArrow) {
240
+ const next =
241
+ order[(currentIndex - 1 + order.length) % order.length] ?? order[0];
242
+ switchTo(next);
243
+ return;
244
+ }
245
+
246
+ if (key.upArrow) {
247
+ setScrollOffset((offset) => offset + 1);
248
+ return;
249
+ }
250
+ if (key.downArrow) {
251
+ setScrollOffset((offset) => Math.max(0, offset - 1));
252
+ return;
253
+ }
254
+ if (key.pageUp) {
255
+ setScrollOffset((offset) => offset + 10);
256
+ return;
257
+ }
258
+ if (key.pageDown) {
259
+ setScrollOffset((offset) => Math.max(0, offset - 10));
260
+ }
261
+ });
262
+
263
+ // Derive the exact row budget from the real component structure (status bar,
264
+ // both bordered panels, footer) so the frame is provably <= terminal rows.
265
+ const layout = computeLayout({
266
+ size: terminalSize,
267
+ alertCount: alertList.length,
268
+ alertCapacity: ALERTS_CAPACITY,
269
+ });
270
+
271
+ // While quitting, replace the whole UI with the animated shutdown screen so
272
+ // the user sees the services being torn down (instead of a frozen frame or a
273
+ // blank terminal) for the kill window.
274
+ if (shuttingDown) {
275
+ return (
276
+ <ShutdownScreen
277
+ width={terminalSize.columns}
278
+ height={layout.totalRows}
279
+ frame={spinnerFrame}
280
+ statuses={statuses}
281
+ />
282
+ );
283
+ }
284
+
285
+ // Recomputed every render: the `tick` state bumps on each new line, and the
286
+ // scrollback buffers mutate in place, so memoizing would risk showing stale
287
+ // output. The window slice is a cheap O(height) operation.
288
+ const focusedScrollback = scrollbacks.current.get(focused);
289
+ const visibleLines =
290
+ focusedScrollback?.window({
291
+ height: layout.logRows,
292
+ scrollOffset,
293
+ }) ?? [];
294
+
295
+ // Clamp the alerts list to its computed budget (newest-first already), so the
296
+ // pinned pane can never push the footer off-screen.
297
+ const visibleAlerts = alertList.slice(0, layout.alertRows);
298
+
299
+ const following = scrollOffset === 0;
300
+
301
+ return (
302
+ // Hard-clamp the whole app to the terminal height and clip overflow, so a
303
+ // chatty pane (e.g. backend) can never push the alerts panel or footer
304
+ // off-screen. The middle row flexes to fill and clips; the alerts+footer
305
+ // block is pinned (flexShrink 0).
306
+ <Box
307
+ flexDirection="column"
308
+ width={terminalSize.columns}
309
+ height={layout.totalRows}
310
+ overflow="hidden"
311
+ >
312
+ <StatusBar statuses={statuses} />
313
+ <Box flexDirection="row" flexGrow={1} overflow="hidden">
314
+ <Sidebar focused={focused} statuses={statuses} unread={unread} />
315
+ <Box
316
+ flexDirection="column"
317
+ flexGrow={1}
318
+ marginLeft={1}
319
+ overflow="hidden"
320
+ >
321
+ <Panel
322
+ title={`${labelFor(focused)} ${following ? "(tail)" : "(scrolled)"}`}
323
+ flexGrow={1}
324
+ >
325
+ {visibleLines.length === 0 ? (
326
+ <Text color={defaultTheme.chrome.dim}>Waiting for output...</Text>
327
+ ) : (
328
+ visibleLines.map((entry, index) => (
329
+ <LevelText
330
+ key={`${focused}-${index}`}
331
+ level={entry.level}
332
+ wrap="truncate"
333
+ >
334
+ {entry.text || " "}
335
+ </LevelText>
336
+ ))
337
+ )}
338
+ </Panel>
339
+ </Box>
340
+ </Box>
341
+ <Box flexDirection="column" flexShrink={0}>
342
+ <AlertsPanel alerts={visibleAlerts} />
343
+ <Box paddingX={1}>
344
+ <KeyHints hints={KEY_HINTS} />
345
+ </Box>
346
+ </Box>
347
+ </Box>
348
+ );
349
+ }
350
+
351
+ function labelFor(id: ProcessId): string {
352
+ return PROCESS_DEFS.find((def) => def.id === id)?.label ?? id;
353
+ }
354
+
355
+ export interface ShutdownScreenProps {
356
+ width: number;
357
+ height: number;
358
+ frame: number;
359
+ statuses: Record<ProcessId, ProcessStatus>;
360
+ }
361
+
362
+ /**
363
+ * Centered "shutting down" overlay shown while the runner kills the children.
364
+ * Only the long-running services (not the one-shot deps, which are left up) are
365
+ * listed; each shows a spinner until it exits, then a green check.
366
+ *
367
+ * A service counts as stopped once its child has exited — `"stopped"`
368
+ * (signal/clean) OR `"errored"` (non-zero exit). We intentionally KILLED these
369
+ * processes, so a non-zero exit code (dev servers like Vite commonly exit 143/1
370
+ * on SIGTERM) is an expected part of teardown, not a failure: show a check, not
371
+ * a cross, so the user isn't told a normal shutdown went wrong.
372
+ */
373
+ export function ShutdownScreen({
374
+ width,
375
+ height,
376
+ frame,
377
+ statuses,
378
+ }: ShutdownScreenProps): React.ReactElement {
379
+ const longRunning = PROCESS_DEFS.filter((def) => !def.oneShot);
380
+ return (
381
+ <Box
382
+ width={width}
383
+ height={height}
384
+ flexDirection="column"
385
+ alignItems="center"
386
+ justifyContent="center"
387
+ >
388
+ <Box marginBottom={1}>
389
+ <Spinner frame={frame} />
390
+ <Text bold color={defaultTheme.chrome.accent}>
391
+ {" "}
392
+ Shutting down checkstack dev
393
+ </Text>
394
+ </Box>
395
+ <Box flexDirection="column">
396
+ {longRunning.map((def) => {
397
+ const status = statuses[def.id];
398
+ const exited = status === "stopped" || status === "errored";
399
+ return (
400
+ <Box key={def.id}>
401
+ {exited ? (
402
+ <Text color={defaultTheme.status.ready}>✓</Text>
403
+ ) : (
404
+ <Spinner frame={frame} color={defaultTheme.chrome.dim} />
405
+ )}
406
+ <Text color={defaultTheme.chrome.dim}>
407
+ {" "}
408
+ {def.label}
409
+ {exited ? " stopped" : "…"}
410
+ </Text>
411
+ </Box>
412
+ );
413
+ })}
414
+ </Box>
415
+ </Box>
416
+ );
417
+ }
418
+
419
+ interface StatusBarProps {
420
+ statuses: Record<ProcessId, ProcessStatus>;
421
+ }
422
+
423
+ function StatusBar({ statuses }: StatusBarProps): React.ReactElement {
424
+ return (
425
+ <Box paddingX={1} justifyContent="space-between">
426
+ <Text bold color={defaultTheme.chrome.accent}>
427
+ checkstack dev
428
+ </Text>
429
+ <Box>
430
+ {PROCESS_DEFS.map((def, index) => (
431
+ <Box key={def.id} marginLeft={index === 0 ? 0 : 2}>
432
+ <StatusDot status={statuses[def.id]} />
433
+ <Text color={defaultTheme.chrome.dim}> {def.id}</Text>
434
+ </Box>
435
+ ))}
436
+ </Box>
437
+ </Box>
438
+ );
439
+ }
440
+
441
+ interface SidebarProps {
442
+ focused: ProcessId;
443
+ statuses: Record<ProcessId, ProcessStatus>;
444
+ unread: Record<ProcessId, number>;
445
+ }
446
+
447
+ function Sidebar({
448
+ focused,
449
+ statuses,
450
+ unread,
451
+ }: SidebarProps): React.ReactElement {
452
+ return (
453
+ <Box flexDirection="column" width={SIDEBAR_WIDTH} flexShrink={0}>
454
+ <Panel title="processes">
455
+ {PROCESS_DEFS.map((def) => {
456
+ const isFocused = def.id === focused;
457
+ const badge = unread[def.id];
458
+ return (
459
+ <Box key={def.id} justifyContent="space-between">
460
+ <Box flexShrink={1}>
461
+ <Text
462
+ color={
463
+ isFocused
464
+ ? defaultTheme.chrome.accent
465
+ : defaultTheme.chrome.text
466
+ }
467
+ bold={isFocused}
468
+ >
469
+ {isFocused ? "› " : " "}
470
+ </Text>
471
+ <StatusDot status={statuses[def.id]} />
472
+ <Text
473
+ color={
474
+ isFocused
475
+ ? defaultTheme.chrome.accent
476
+ : defaultTheme.chrome.text
477
+ }
478
+ bold={isFocused}
479
+ wrap="truncate"
480
+ >
481
+ {" "}
482
+ {def.label}
483
+ </Text>
484
+ </Box>
485
+ {badge > 0 ? (
486
+ <Text color={defaultTheme.level.error} bold>
487
+ {" "}
488
+ {badge}
489
+ </Text>
490
+ ) : null}
491
+ </Box>
492
+ );
493
+ })}
494
+ </Panel>
495
+ </Box>
496
+ );
497
+ }
498
+
499
+ interface AlertsPanelProps {
500
+ alerts: readonly AlertEntry[];
501
+ }
502
+
503
+ function AlertsPanel({ alerts }: AlertsPanelProps): React.ReactElement {
504
+ return (
505
+ <Panel title={`alerts (${alerts.length})`} borderColor={defaultTheme.level.warn}>
506
+ {alerts.length === 0 ? (
507
+ <Text color={defaultTheme.chrome.dim}>
508
+ No warnings or errors yet.
509
+ </Text>
510
+ ) : (
511
+ alerts.map((alert) => (
512
+ <Box key={alert.seq}>
513
+ <Text color={defaultTheme.chrome.dim}>[{alert.source}] </Text>
514
+ <LevelText level={alert.level} wrap="truncate">
515
+ {alert.text}
516
+ </LevelText>
517
+ </Box>
518
+ ))
519
+ )}
520
+ </Panel>
521
+ );
522
+ }
@@ -0,0 +1,62 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { createAlertBuffer } from "./alert-buffer.ts";
3
+
4
+ describe("createAlertBuffer", () => {
5
+ it("keeps only warn/error entries, newest first", () => {
6
+ const buf = createAlertBuffer({ capacity: 8 });
7
+ buf.push({ source: "backend", level: "info", text: "ignored", seq: 1 });
8
+ buf.push({ source: "backend", level: "warn", text: "first", seq: 2 });
9
+ buf.push({ source: "frontend", level: "error", text: "second", seq: 3 });
10
+
11
+ const list = buf.list();
12
+ expect(list).toHaveLength(2);
13
+ expect(list[0]?.text).toBe("second");
14
+ expect(list[1]?.text).toBe("first");
15
+ });
16
+
17
+ it("ignores debug entries", () => {
18
+ const buf = createAlertBuffer({ capacity: 8 });
19
+ buf.push({ source: "backend", level: "debug", text: "noise", seq: 1 });
20
+ expect(buf.list()).toHaveLength(0);
21
+ });
22
+
23
+ it("caps the buffer at the given capacity, dropping the oldest", () => {
24
+ const buf = createAlertBuffer({ capacity: 3 });
25
+ for (let i = 1; i <= 5; i++) {
26
+ buf.push({ source: "backend", level: "warn", text: `w${i}`, seq: i });
27
+ }
28
+ const list = buf.list();
29
+ expect(list).toHaveLength(3);
30
+ // newest first: w5, w4, w3 (w1 and w2 dropped)
31
+ expect(list.map((entry) => entry.text)).toEqual(["w5", "w4", "w3"]);
32
+ });
33
+
34
+ it("returns a defensive copy that callers cannot mutate", () => {
35
+ const buf = createAlertBuffer({ capacity: 4 });
36
+ buf.push({ source: "backend", level: "error", text: "boom", seq: 1 });
37
+ const list = buf.list();
38
+ list.pop();
39
+ expect(buf.list()).toHaveLength(1);
40
+ });
41
+
42
+ it("preserves source and level on stored entries", () => {
43
+ const buf = createAlertBuffer({ capacity: 4 });
44
+ buf.push({ source: "frontend", level: "error", text: "bad", seq: 7 });
45
+ const entry = buf.list()[0];
46
+ expect(entry?.source).toBe("frontend");
47
+ expect(entry?.level).toBe("error");
48
+ expect(entry?.seq).toBe(7);
49
+ });
50
+
51
+ it("rejects a non-positive capacity", () => {
52
+ expect(() => createAlertBuffer({ capacity: 0 })).toThrow();
53
+ expect(() => createAlertBuffer({ capacity: -3 })).toThrow();
54
+ });
55
+
56
+ it("clear() empties the buffer", () => {
57
+ const buf = createAlertBuffer({ capacity: 4 });
58
+ buf.push({ source: "backend", level: "warn", text: "w", seq: 1 });
59
+ buf.clear();
60
+ expect(buf.list()).toHaveLength(0);
61
+ });
62
+ });
@@ -0,0 +1,51 @@
1
+ import { isAlertLevel } from "./log-level.ts";
2
+ import type { AlertEntry } from "./types.ts";
3
+
4
+ export interface AlertBuffer {
5
+ /** Add an entry; non-alert levels (info/debug) are silently ignored. */
6
+ push(entry: AlertEntry): void;
7
+ /** Current alerts, newest first, as a defensive copy. */
8
+ list(): AlertEntry[];
9
+ /** Drop all stored alerts. */
10
+ clear(): void;
11
+ }
12
+
13
+ export interface CreateAlertBufferInput {
14
+ /** Maximum number of alerts to retain. Oldest are dropped past this. */
15
+ capacity: number;
16
+ }
17
+
18
+ /**
19
+ * Bounded ring buffer that retains only the most recent warn/error entries
20
+ * across every process, newest-first and capped at `capacity`. This is what
21
+ * keeps the script-sandbox readiness banner and any error pinned in the Alerts
22
+ * panel instead of scrolling out of view.
23
+ */
24
+ export function createAlertBuffer({
25
+ capacity,
26
+ }: CreateAlertBufferInput): AlertBuffer {
27
+ if (!Number.isInteger(capacity) || capacity <= 0) {
28
+ throw new Error(`Alert buffer capacity must be a positive integer, got ${capacity}`);
29
+ }
30
+
31
+ // Stored oldest-first; `list()` reverses to newest-first.
32
+ let entries: AlertEntry[] = [];
33
+
34
+ return {
35
+ push(entry: AlertEntry): void {
36
+ if (!isAlertLevel(entry.level)) {
37
+ return;
38
+ }
39
+ entries.push(entry);
40
+ if (entries.length > capacity) {
41
+ entries = entries.slice(entries.length - capacity);
42
+ }
43
+ },
44
+ list(): AlertEntry[] {
45
+ return entries.toReversed();
46
+ },
47
+ clear(): void {
48
+ entries = [];
49
+ },
50
+ };
51
+ }
@@ -0,0 +1,66 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { createAltScreen } from "./alt-screen.ts";
3
+ import type { AltScreenStream } from "./alt-screen.ts";
4
+
5
+ const ENTER = "[?1049h";
6
+ const EXIT = "[?1049l";
7
+
8
+ function createCaptureStream(): AltScreenStream & { written: string[] } {
9
+ const written: string[] = [];
10
+ return {
11
+ written,
12
+ write(data: string): boolean {
13
+ written.push(data);
14
+ return true;
15
+ },
16
+ };
17
+ }
18
+
19
+ describe("createAltScreen", () => {
20
+ it("writes the alternate-buffer enter sequence on enter()", () => {
21
+ const stream = createCaptureStream();
22
+ const alt = createAltScreen({ stream });
23
+ alt.enter();
24
+ expect(stream.written.join("")).toContain(ENTER);
25
+ expect(alt.isActive).toBe(true);
26
+ });
27
+
28
+ it("writes the alternate-buffer exit sequence on leave()", () => {
29
+ const stream = createCaptureStream();
30
+ const alt = createAltScreen({ stream });
31
+ alt.enter();
32
+ alt.leave();
33
+ expect(stream.written.join("")).toContain(EXIT);
34
+ expect(alt.isActive).toBe(false);
35
+ });
36
+
37
+ it("always restores: every enter is matched by an exit sequence", () => {
38
+ const stream = createCaptureStream();
39
+ const alt = createAltScreen({ stream });
40
+ alt.enter();
41
+ alt.leave();
42
+ const joined = stream.written.join("");
43
+ expect(joined.indexOf(ENTER)).toBeLessThan(joined.indexOf(EXIT));
44
+ });
45
+
46
+ it("is idempotent: repeated enter()/leave() do not double-toggle", () => {
47
+ const stream = createCaptureStream();
48
+ const alt = createAltScreen({ stream });
49
+ alt.enter();
50
+ alt.enter();
51
+ alt.leave();
52
+ alt.leave();
53
+ const enters = stream.written.filter((chunk) => chunk === ENTER).length;
54
+ const exits = stream.written.filter((chunk) => chunk === EXIT).length;
55
+ expect(enters).toBe(1);
56
+ expect(exits).toBe(1);
57
+ });
58
+
59
+ it("does nothing on leave() if never entered (no stray exit sequence)", () => {
60
+ const stream = createCaptureStream();
61
+ const alt = createAltScreen({ stream });
62
+ alt.leave();
63
+ expect(stream.written.join("")).not.toContain(EXIT);
64
+ expect(alt.isActive).toBe(false);
65
+ });
66
+ });