@checkstack/scripts 0.3.3 → 0.4.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 +15 -5
- package/src/commands/create.ts +16 -23
- package/src/commands/plugin-pack.ts +17 -28
- package/src/dev-tui/App.render.test.tsx +135 -0
- package/src/dev-tui/App.smoke.test.tsx +142 -0
- package/src/dev-tui/App.tsx +522 -0
- package/src/dev-tui/alert-buffer.test.ts +62 -0
- package/src/dev-tui/alert-buffer.ts +51 -0
- package/src/dev-tui/alt-screen.test.ts +66 -0
- package/src/dev-tui/alt-screen.ts +65 -0
- package/src/dev-tui/cli.tsx +89 -0
- package/src/dev-tui/fake-supervisor.ts +76 -0
- package/src/dev-tui/graceful-shutdown.test.ts +61 -0
- package/src/dev-tui/graceful-shutdown.ts +32 -0
- package/src/dev-tui/kill-tree.test.ts +47 -0
- package/src/dev-tui/kill-tree.ts +64 -0
- package/src/dev-tui/layout.test.ts +89 -0
- package/src/dev-tui/layout.ts +126 -0
- package/src/dev-tui/log-level.test.ts +94 -0
- package/src/dev-tui/log-level.ts +104 -0
- package/src/dev-tui/plain-runner.ts +60 -0
- package/src/dev-tui/process-config.test.ts +42 -0
- package/src/dev-tui/process-config.ts +61 -0
- package/src/dev-tui/readiness.test.ts +54 -0
- package/src/dev-tui/readiness.ts +44 -0
- package/src/dev-tui/scrollback.test.ts +83 -0
- package/src/dev-tui/scrollback.ts +82 -0
- package/src/dev-tui/supervisor.ts +231 -0
- package/src/dev-tui/text.test.ts +72 -0
- package/src/dev-tui/text.ts +101 -0
- package/src/dev-tui/types.ts +29 -0
- package/src/scaffold/index.ts +22 -0
- package/src/scaffold/resolve-versions.test.ts +49 -0
- package/src/scaffold/resolve-versions.ts +55 -0
- package/src/scaffold/rewrite-workspace-versions.test.ts +102 -0
- package/src/scaffold/rewrite-workspace-versions.ts +111 -0
- package/src/scaffold/scaffold-plugin.test.ts +209 -0
- package/src/scaffold/scaffold-plugin.ts +309 -0
- package/src/templates/backend/.changeset/initial.md.hbs +1 -1
- package/src/templates/backend/drizzle/0000_init.sql +7 -0
- package/src/templates/backend/drizzle/meta/0000_snapshot.json +65 -0
- package/src/templates/backend/drizzle/meta/_journal.json +13 -0
- package/src/templates/backend/drizzle.config.ts.hbs +5 -1
- package/src/templates/backend/package.json.hbs +7 -3
- package/src/templates/backend/src/index.ts.hbs +1 -1
- package/src/templates/backend/src/router.ts.hbs +1 -1
- package/src/templates/backend/src/service.ts.hbs +1 -1
- package/src/templates/common/.changeset/initial.md.hbs +1 -1
- package/src/templates/common/README.md.hbs +28 -11
- package/src/templates/common/package.json.hbs +1 -1
- package/src/templates/common/src/plugin-metadata.ts.hbs +1 -1
- package/src/templates/frontend/.changeset/initial.md.hbs +1 -1
- package/src/templates/frontend/package.json.hbs +2 -2
- package/src/templates/frontend/src/api.ts.hbs +2 -2
- package/src/templates/frontend/src/components/{{pluginNamePascal}}ListPage.tsx.hbs +1 -1
- package/src/templates/frontend/src/index.tsx.hbs +10 -4
- package/src/templates/standalone-root/.changeset/config.json.hbs +11 -0
- package/src/templates/standalone-root/.changeset/initial.md.hbs +9 -0
- package/src/templates/standalone-root/README.md.hbs +75 -0
- package/src/templates/standalone-root/eslint.config.mjs.hbs +37 -0
- package/src/templates/standalone-root/package.json.hbs +27 -0
- package/src/templates/standalone-root/tsconfig.json.hbs +13 -0
- package/src/templates.test.ts +20 -0
- package/src/tui/components.test.tsx +28 -0
- package/src/tui/components.tsx +159 -0
- package/src/tui/index.ts +31 -0
- package/src/tui/theme.test.ts +54 -0
- package/src/tui/theme.ts +60 -0
- 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
|
+
});
|