@checkstack/scripts 0.3.4 → 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,65 @@
|
|
|
1
|
+
import ansiEscapes from "ansi-escapes";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minimal writable sink the alternate-screen controller writes its escape
|
|
5
|
+
* sequences to. `process.stdout` satisfies this; tests pass a fake to capture
|
|
6
|
+
* the exact bytes written without touching a real terminal.
|
|
7
|
+
*/
|
|
8
|
+
export interface AltScreenStream {
|
|
9
|
+
write(data: string): boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface AltScreen {
|
|
13
|
+
/** Switch the terminal into the alternate screen buffer (idempotent). */
|
|
14
|
+
enter(): void;
|
|
15
|
+
/** Restore the terminal's main screen buffer + cursor (idempotent). */
|
|
16
|
+
leave(): void;
|
|
17
|
+
/** Whether the alternate buffer is currently active. */
|
|
18
|
+
readonly isActive: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface CreateAltScreenInput {
|
|
22
|
+
/** Where to write the control sequences (defaults are wired by the caller). */
|
|
23
|
+
stream: AltScreenStream;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Controls the terminal's alternate screen buffer, the same mechanism vim and
|
|
28
|
+
* htop use: the TUI owns a clean, terminal-sized viewport that never scrolls
|
|
29
|
+
* the user's shell and leaves no artifacts on exit.
|
|
30
|
+
*
|
|
31
|
+
* `enter()` switches to the alternate buffer and hides the cursor; `leave()`
|
|
32
|
+
* shows the cursor and switches back to the main buffer. Both are idempotent so
|
|
33
|
+
* the controller can be invoked from multiple exit paths (`q`, SIGINT/SIGTERM,
|
|
34
|
+
* a thrown error, normal unmount) without double-toggling or getting the
|
|
35
|
+
* terminal stuck in the alternate buffer.
|
|
36
|
+
*
|
|
37
|
+
* The escape sequences come from `ansi-escapes` (already an ink dependency):
|
|
38
|
+
* `enterAlternativeScreen` is CSI `?1049h`, `exitAlternativeScreen` is CSI
|
|
39
|
+
* `?1049l`.
|
|
40
|
+
*/
|
|
41
|
+
export function createAltScreen({ stream }: CreateAltScreenInput): AltScreen {
|
|
42
|
+
let active = false;
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
enter(): void {
|
|
46
|
+
if (active) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
active = true;
|
|
50
|
+
stream.write(ansiEscapes.enterAlternativeScreen);
|
|
51
|
+
stream.write(ansiEscapes.cursorHide);
|
|
52
|
+
},
|
|
53
|
+
leave(): void {
|
|
54
|
+
if (!active) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
active = false;
|
|
58
|
+
stream.write(ansiEscapes.cursorShow);
|
|
59
|
+
stream.write(ansiEscapes.exitAlternativeScreen);
|
|
60
|
+
},
|
|
61
|
+
get isActive(): boolean {
|
|
62
|
+
return active;
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import React from "react";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
import { render } from "ink";
|
|
5
|
+
import { App } from "./App.tsx";
|
|
6
|
+
import { createAltScreen } from "./alt-screen.ts";
|
|
7
|
+
import { createSupervisor } from "./supervisor.ts";
|
|
8
|
+
import { runPlainStreaming } from "./plain-runner.ts";
|
|
9
|
+
import { createGracefulShutdown } from "./graceful-shutdown.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Entry point for the opt-in dev runner (`dev:tui`). On an interactive terminal
|
|
13
|
+
* it renders the ink TUI inside the ALTERNATE SCREEN BUFFER (like vim/htop) so
|
|
14
|
+
* the runner owns a clean, terminal-sized viewport: it never scrolls the user's
|
|
15
|
+
* shell and leaves no scrollback artifacts. When stdout is not a TTY (piped,
|
|
16
|
+
* redirected, CI) it degrades to a plain prefixed-stream runner that does NOT
|
|
17
|
+
* touch the alternate screen, so it never crashes ink's raw-mode requirements.
|
|
18
|
+
*/
|
|
19
|
+
function main(): void {
|
|
20
|
+
const cwd = process.cwd();
|
|
21
|
+
const isInteractive = Boolean(process.stdout.isTTY && process.stdin.isTTY);
|
|
22
|
+
|
|
23
|
+
const supervisor = createSupervisor({ cwd });
|
|
24
|
+
|
|
25
|
+
if (!isInteractive) {
|
|
26
|
+
runPlainStreaming({
|
|
27
|
+
supervisor,
|
|
28
|
+
onShutdownComplete: () => {
|
|
29
|
+
process.exit(0);
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const altScreen = createAltScreen({ stream: process.stdout });
|
|
36
|
+
|
|
37
|
+
// Guarantee the terminal is restored on EVERY exit path: normal unmount, `q`
|
|
38
|
+
// / Ctrl-C (handled inside the App, which unmounts), an uncaught error, and
|
|
39
|
+
// the OS signals. `leave()` is idempotent, so registering it on several paths
|
|
40
|
+
// is safe and the terminal can never get stuck in the alternate buffer.
|
|
41
|
+
let restored = false;
|
|
42
|
+
const restore = (): void => {
|
|
43
|
+
if (restored) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
restored = true;
|
|
47
|
+
altScreen.leave();
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Every GRACEFUL exit path - ink unmount (`q` or Ctrl-C), SIGINT, SIGTERM -
|
|
51
|
+
// funnels through one handler that tears the supervised children down BEFORE
|
|
52
|
+
// the process dies, so Ctrl-C can never leave a stale backend bound to its
|
|
53
|
+
// port. The shared handler shuts down exactly once (an unmount racing a
|
|
54
|
+
// signal won't double-kill), and always restores the terminal + exits.
|
|
55
|
+
const gracefulExit = createGracefulShutdown({
|
|
56
|
+
shutdown: () => supervisor.shutdown(),
|
|
57
|
+
finalize: () => {
|
|
58
|
+
restore();
|
|
59
|
+
process.exit(0);
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
const onUncaught = (error: unknown): void => {
|
|
63
|
+
restore();
|
|
64
|
+
// Surface the failure on the (now restored) main screen before exiting.
|
|
65
|
+
console.error(error);
|
|
66
|
+
process.exit(1);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
process.once("SIGINT", gracefulExit);
|
|
70
|
+
process.once("SIGTERM", gracefulExit);
|
|
71
|
+
process.once("uncaughtException", onUncaught);
|
|
72
|
+
process.once("unhandledRejection", onUncaught);
|
|
73
|
+
// `exit` is the final backstop: even if a path above is missed, the terminal
|
|
74
|
+
// is restored before the process is gone. It must be synchronous.
|
|
75
|
+
process.once("exit", restore);
|
|
76
|
+
|
|
77
|
+
altScreen.enter();
|
|
78
|
+
|
|
79
|
+
// `exitOnCtrlC: false`: ink must NOT exit on its own when Ctrl-C is pressed.
|
|
80
|
+
// The App's input handler maps Ctrl-C to the same quit() as `q` (which
|
|
81
|
+
// unmounts); letting ink auto-exit would tear the UI down and exit the
|
|
82
|
+
// process before the shutdown above runs, orphaning the backend.
|
|
83
|
+
const instance = render(<App supervisor={supervisor} onQuit={gracefulExit} />, {
|
|
84
|
+
exitOnCtrlC: false,
|
|
85
|
+
});
|
|
86
|
+
void instance.waitUntilExit().then(gracefulExit).catch(onUncaught);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
main();
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { CapturedLine, Supervisor } from "./supervisor.ts";
|
|
2
|
+
import type { ProcessId, ProcessStatus } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
export interface FakeSupervisor extends Supervisor {
|
|
5
|
+
/** Push a captured line to all line listeners (test helper). */
|
|
6
|
+
emitLine(line: CapturedLine): void;
|
|
7
|
+
/** Push a status change to all status listeners (test helper). */
|
|
8
|
+
emitStatus(input: { id: ProcessId; status: ProcessStatus }): void;
|
|
9
|
+
/** Whether {@link Supervisor.start} was called. */
|
|
10
|
+
readonly started: boolean;
|
|
11
|
+
/** Ids passed to {@link Supervisor.restart}, in order. */
|
|
12
|
+
readonly restarted: readonly ProcessId[];
|
|
13
|
+
/** Whether {@link Supervisor.shutdown} was called. */
|
|
14
|
+
readonly didShutdown: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* In-memory Supervisor stand-in for smoke tests. Spawns nothing: it lets a test
|
|
19
|
+
* drive line/status events deterministically and records lifecycle calls, so we
|
|
20
|
+
* can exercise the UI and the plain runner without real child processes.
|
|
21
|
+
*/
|
|
22
|
+
export function createFakeSupervisor(): FakeSupervisor {
|
|
23
|
+
const lineListeners: Array<(line: CapturedLine) => void> = [];
|
|
24
|
+
const statusListeners: Array<
|
|
25
|
+
(input: { id: ProcessId; status: ProcessStatus }) => void
|
|
26
|
+
> = [];
|
|
27
|
+
const statuses = new Map<ProcessId, ProcessStatus>([
|
|
28
|
+
["deps", "starting"],
|
|
29
|
+
["backend", "starting"],
|
|
30
|
+
["frontend", "starting"],
|
|
31
|
+
]);
|
|
32
|
+
const restarted: ProcessId[] = [];
|
|
33
|
+
let started = false;
|
|
34
|
+
let didShutdown = false;
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
start(): void {
|
|
38
|
+
started = true;
|
|
39
|
+
},
|
|
40
|
+
restart(id: ProcessId): void {
|
|
41
|
+
restarted.push(id);
|
|
42
|
+
},
|
|
43
|
+
statusOf(id: ProcessId): ProcessStatus {
|
|
44
|
+
return statuses.get(id) ?? "stopped";
|
|
45
|
+
},
|
|
46
|
+
onLine(listener): void {
|
|
47
|
+
lineListeners.push(listener);
|
|
48
|
+
},
|
|
49
|
+
onStatus(listener): void {
|
|
50
|
+
statusListeners.push(listener);
|
|
51
|
+
},
|
|
52
|
+
async shutdown(): Promise<void> {
|
|
53
|
+
didShutdown = true;
|
|
54
|
+
},
|
|
55
|
+
emitLine(line: CapturedLine): void {
|
|
56
|
+
for (const listener of lineListeners) {
|
|
57
|
+
listener(line);
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
emitStatus(input: { id: ProcessId; status: ProcessStatus }): void {
|
|
61
|
+
statuses.set(input.id, input.status);
|
|
62
|
+
for (const listener of statusListeners) {
|
|
63
|
+
listener(input);
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
get started(): boolean {
|
|
67
|
+
return started;
|
|
68
|
+
},
|
|
69
|
+
get restarted(): readonly ProcessId[] {
|
|
70
|
+
return restarted;
|
|
71
|
+
},
|
|
72
|
+
get didShutdown(): boolean {
|
|
73
|
+
return didShutdown;
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { createGracefulShutdown } from "./graceful-shutdown.ts";
|
|
3
|
+
|
|
4
|
+
const tick = () => new Promise((resolve) => setTimeout(resolve, 5));
|
|
5
|
+
|
|
6
|
+
describe("createGracefulShutdown", () => {
|
|
7
|
+
it("runs shutdown to completion BEFORE finalize (no stale children on exit)", async () => {
|
|
8
|
+
const order: string[] = [];
|
|
9
|
+
const handler = createGracefulShutdown({
|
|
10
|
+
shutdown: async () => {
|
|
11
|
+
await tick();
|
|
12
|
+
order.push("shutdown");
|
|
13
|
+
},
|
|
14
|
+
finalize: () => order.push("finalize"),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
handler();
|
|
18
|
+
await tick();
|
|
19
|
+
await tick();
|
|
20
|
+
|
|
21
|
+
expect(order).toEqual(["shutdown", "finalize"]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("is idempotent: repeated triggers (unmount + signal) shut down once", async () => {
|
|
25
|
+
let shutdowns = 0;
|
|
26
|
+
let finalizes = 0;
|
|
27
|
+
const handler = createGracefulShutdown({
|
|
28
|
+
shutdown: async () => {
|
|
29
|
+
shutdowns += 1;
|
|
30
|
+
},
|
|
31
|
+
finalize: () => {
|
|
32
|
+
finalizes += 1;
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
handler();
|
|
37
|
+
handler();
|
|
38
|
+
handler();
|
|
39
|
+
await tick();
|
|
40
|
+
|
|
41
|
+
expect(shutdowns).toBe(1);
|
|
42
|
+
expect(finalizes).toBe(1);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("still finalizes when shutdown rejects (terminal restored, process exits)", async () => {
|
|
46
|
+
let finalized = false;
|
|
47
|
+
const handler = createGracefulShutdown({
|
|
48
|
+
shutdown: async () => {
|
|
49
|
+
throw new Error("kill failed");
|
|
50
|
+
},
|
|
51
|
+
finalize: () => {
|
|
52
|
+
finalized = true;
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
handler();
|
|
57
|
+
await tick();
|
|
58
|
+
|
|
59
|
+
expect(finalized).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a one-shot graceful-exit handler shared by every exit path of the dev
|
|
3
|
+
* runner (ink unmount via `q` / Ctrl-C, and external SIGINT / SIGTERM).
|
|
4
|
+
*
|
|
5
|
+
* The bug this guards against: pressing Ctrl-C (or receiving a signal) used to
|
|
6
|
+
* terminate the runner WITHOUT tearing down the supervised children, leaving a
|
|
7
|
+
* stale backend bound to its port. Routing every exit through this helper
|
|
8
|
+
* guarantees `shutdown()` runs to completion BEFORE the process is finalized,
|
|
9
|
+
* and the `started` latch makes it idempotent so two near-simultaneous triggers
|
|
10
|
+
* (e.g. an unmount and a signal) shut down only once. `finalize` still runs even
|
|
11
|
+
* if `shutdown` rejects, so the terminal is always restored and the process
|
|
12
|
+
* always exits.
|
|
13
|
+
*/
|
|
14
|
+
export function createGracefulShutdown(args: {
|
|
15
|
+
shutdown: () => Promise<void>;
|
|
16
|
+
finalize: () => void;
|
|
17
|
+
}): () => void {
|
|
18
|
+
const { shutdown, finalize } = args;
|
|
19
|
+
let started = false;
|
|
20
|
+
return () => {
|
|
21
|
+
if (started) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
started = true;
|
|
25
|
+
// `finally` runs `finalize` whether shutdown resolves or rejects; the
|
|
26
|
+
// trailing `catch` swallows a rejected shutdown so it never surfaces as an
|
|
27
|
+
// unhandled rejection (the terminal is still restored and we still exit).
|
|
28
|
+
void shutdown()
|
|
29
|
+
.finally(finalize)
|
|
30
|
+
.catch(() => {});
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { planKill } from "./kill-tree.ts";
|
|
3
|
+
|
|
4
|
+
describe("planKill", () => {
|
|
5
|
+
it("uses taskkill with the tree flag on Windows", () => {
|
|
6
|
+
const plan = planKill({ platform: "win32", pid: 1234, signal: "SIGTERM" });
|
|
7
|
+
expect(plan.kind).toBe("spawn");
|
|
8
|
+
if (plan.kind !== "spawn") {
|
|
9
|
+
throw new Error("expected spawn plan");
|
|
10
|
+
}
|
|
11
|
+
expect(plan.command).toBe("taskkill");
|
|
12
|
+
expect(plan.args).toEqual(["/pid", "1234", "/T", "/F"]);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("signals the negative pid (process group) on Linux", () => {
|
|
16
|
+
const plan = planKill({ platform: "linux", pid: 4321, signal: "SIGTERM" });
|
|
17
|
+
expect(plan.kind).toBe("signal");
|
|
18
|
+
if (plan.kind !== "signal") {
|
|
19
|
+
throw new Error("expected signal plan");
|
|
20
|
+
}
|
|
21
|
+
expect(plan.target).toBe(-4321);
|
|
22
|
+
expect(plan.signal).toBe("SIGTERM");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("signals the negative pid on macOS", () => {
|
|
26
|
+
const plan = planKill({ platform: "darwin", pid: 99, signal: "SIGKILL" });
|
|
27
|
+
expect(plan.kind).toBe("signal");
|
|
28
|
+
if (plan.kind !== "signal") {
|
|
29
|
+
throw new Error("expected signal plan");
|
|
30
|
+
}
|
|
31
|
+
expect(plan.target).toBe(-99);
|
|
32
|
+
expect(plan.signal).toBe("SIGKILL");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("defaults to SIGTERM when no signal is supplied", () => {
|
|
36
|
+
const plan = planKill({ platform: "linux", pid: 7 });
|
|
37
|
+
if (plan.kind !== "signal") {
|
|
38
|
+
throw new Error("expected signal plan");
|
|
39
|
+
}
|
|
40
|
+
expect(plan.signal).toBe("SIGTERM");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("rejects a non-positive pid", () => {
|
|
44
|
+
expect(() => planKill({ platform: "linux", pid: 0 })).toThrow();
|
|
45
|
+
expect(() => planKill({ platform: "linux", pid: -5 })).toThrow();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform process-tree termination planning. Pure logic so it can be
|
|
3
|
+
* unit-tested without spawning anything: it computes WHAT to run/signal, and
|
|
4
|
+
* the supervisor executes the plan.
|
|
5
|
+
*
|
|
6
|
+
* - POSIX (linux/darwin): children are spawned with `detached: true`, which
|
|
7
|
+
* puts each in its own process group. Signaling the negative pid delivers
|
|
8
|
+
* the signal to the whole group, so `bun --watch` and its grandchildren die
|
|
9
|
+
* too (no orphans).
|
|
10
|
+
* - Windows: there are no process groups; `taskkill /T` walks and kills the
|
|
11
|
+
* whole tree by pid.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type KillSignal = "SIGTERM" | "SIGKILL" | "SIGINT";
|
|
15
|
+
|
|
16
|
+
export type KillPlan =
|
|
17
|
+
| {
|
|
18
|
+
readonly kind: "signal";
|
|
19
|
+
/** Negative pid to target the process group. */
|
|
20
|
+
readonly target: number;
|
|
21
|
+
readonly signal: KillSignal;
|
|
22
|
+
}
|
|
23
|
+
| {
|
|
24
|
+
readonly kind: "spawn";
|
|
25
|
+
readonly command: string;
|
|
26
|
+
readonly args: readonly string[];
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export interface PlanKillInput {
|
|
30
|
+
/** `process.platform` value. */
|
|
31
|
+
platform: NodeJS.Platform;
|
|
32
|
+
/** The child's pid (positive). */
|
|
33
|
+
pid: number;
|
|
34
|
+
/** Signal to send on POSIX; ignored on Windows (taskkill always force-kills). */
|
|
35
|
+
signal?: KillSignal;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Compute the platform-appropriate way to kill a child process and its entire
|
|
40
|
+
* descendant tree.
|
|
41
|
+
*/
|
|
42
|
+
export function planKill({
|
|
43
|
+
platform,
|
|
44
|
+
pid,
|
|
45
|
+
signal = "SIGTERM",
|
|
46
|
+
}: PlanKillInput): KillPlan {
|
|
47
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
48
|
+
throw new Error(`planKill requires a positive integer pid, got ${pid}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (platform === "win32") {
|
|
52
|
+
return {
|
|
53
|
+
kind: "spawn",
|
|
54
|
+
command: "taskkill",
|
|
55
|
+
args: ["/pid", String(pid), "/T", "/F"],
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
kind: "signal",
|
|
61
|
+
target: -pid,
|
|
62
|
+
signal,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { computeLayout } from "./layout.ts";
|
|
3
|
+
|
|
4
|
+
const ALERT_CAPACITY = 8;
|
|
5
|
+
|
|
6
|
+
describe("computeLayout", () => {
|
|
7
|
+
it("fills the terminal exactly when it is tall enough", () => {
|
|
8
|
+
const layout = computeLayout({
|
|
9
|
+
size: { rows: 24, columns: 80 },
|
|
10
|
+
alertCount: 0,
|
|
11
|
+
alertCapacity: ALERT_CAPACITY,
|
|
12
|
+
});
|
|
13
|
+
// Chrome = status(1) + main panel(3) + alerts panel(3) + footer(1) = 8.
|
|
14
|
+
// Content budget = 24 - 8 = 16, split as 1 alert row + 15 log rows.
|
|
15
|
+
expect(layout.totalRows).toBe(24);
|
|
16
|
+
expect(layout.logRows + layout.alertRows).toBe(16);
|
|
17
|
+
expect(layout.alertRows).toBe(1);
|
|
18
|
+
expect(layout.logRows).toBe(15);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("never reports more rows than the terminal has, for any alert count", () => {
|
|
22
|
+
for (const rows of [10, 18, 24, 40, 60]) {
|
|
23
|
+
for (let alertCount = 0; alertCount <= ALERT_CAPACITY + 4; alertCount += 1) {
|
|
24
|
+
const layout = computeLayout({
|
|
25
|
+
size: { rows, columns: 80 },
|
|
26
|
+
alertCount,
|
|
27
|
+
alertCapacity: ALERT_CAPACITY,
|
|
28
|
+
});
|
|
29
|
+
expect(layout.totalRows).toBeLessThanOrEqual(rows);
|
|
30
|
+
expect(layout.logRows).toBeGreaterThanOrEqual(1);
|
|
31
|
+
expect(layout.alertRows).toBeGreaterThanOrEqual(1);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("clamps the alerts list to the buffer capacity", () => {
|
|
37
|
+
const layout = computeLayout({
|
|
38
|
+
size: { rows: 60, columns: 80 },
|
|
39
|
+
alertCount: 100,
|
|
40
|
+
alertCapacity: ALERT_CAPACITY,
|
|
41
|
+
});
|
|
42
|
+
expect(layout.alertRows).toBe(ALERT_CAPACITY);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("grows the alerts pane with the alert count up to capacity", () => {
|
|
46
|
+
const three = computeLayout({
|
|
47
|
+
size: { rows: 40, columns: 80 },
|
|
48
|
+
alertCount: 3,
|
|
49
|
+
alertCapacity: ALERT_CAPACITY,
|
|
50
|
+
});
|
|
51
|
+
expect(three.alertRows).toBe(3);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("keeps at least one log and one alert row on a tiny terminal", () => {
|
|
55
|
+
const layout = computeLayout({
|
|
56
|
+
size: { rows: 4, columns: 80 },
|
|
57
|
+
alertCount: 5,
|
|
58
|
+
alertCapacity: ALERT_CAPACITY,
|
|
59
|
+
});
|
|
60
|
+
expect(layout.logRows).toBe(1);
|
|
61
|
+
expect(layout.alertRows).toBe(1);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("uses more of the terminal than the pre-fix constant-16 reserve did", () => {
|
|
65
|
+
// The old code computed mainHeight = max(3, rows - 16) regardless of how
|
|
66
|
+
// many alerts were queued, wasting up to 8 rows when alerts were sparse.
|
|
67
|
+
// This locks in that the derived reserve never under-fills the terminal.
|
|
68
|
+
for (const rows of [24, 40, 60]) {
|
|
69
|
+
const oldMainHeight = Math.max(3, rows - 16);
|
|
70
|
+
const layout = computeLayout({
|
|
71
|
+
size: { rows, columns: 80 },
|
|
72
|
+
alertCount: 0,
|
|
73
|
+
alertCapacity: ALERT_CAPACITY,
|
|
74
|
+
});
|
|
75
|
+
expect(layout.logRows).toBeGreaterThan(oldMainHeight);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("protects the log pane minimum when alerts would otherwise consume the budget", () => {
|
|
80
|
+
// rows=18 -> budget 10. With 8 alerts wanted, log must keep >= 1 row.
|
|
81
|
+
const layout = computeLayout({
|
|
82
|
+
size: { rows: 18, columns: 80 },
|
|
83
|
+
alertCount: ALERT_CAPACITY,
|
|
84
|
+
alertCapacity: ALERT_CAPACITY,
|
|
85
|
+
});
|
|
86
|
+
expect(layout.logRows).toBeGreaterThanOrEqual(1);
|
|
87
|
+
expect(layout.totalRows).toBeLessThanOrEqual(18);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure layout math for the dev-runner TUI. Computes how many rows the focused
|
|
3
|
+
* log pane and the alerts list may use so the rendered frame is provably never
|
|
4
|
+
* taller than the terminal.
|
|
5
|
+
*
|
|
6
|
+
* The reserve is derived from the ACTUAL component structure rather than a
|
|
7
|
+
* magic number, so adding/removing chrome stays honest:
|
|
8
|
+
*
|
|
9
|
+
* row component
|
|
10
|
+
* --- ----------------------------------------------------------------
|
|
11
|
+
* 1 status bar (single padded row)
|
|
12
|
+
* 1 main pane: round-border top edge
|
|
13
|
+
* 1 main pane: panel title row
|
|
14
|
+
* N main pane: log content rows <- LOG_ROWS (clamped)
|
|
15
|
+
* 1 main pane: round-border bottom edge
|
|
16
|
+
* 1 alerts pane: round-border top edge
|
|
17
|
+
* 1 alerts pane: panel title row
|
|
18
|
+
* M alerts pane: alert content rows <- ALERT_ROWS (clamped)
|
|
19
|
+
* 1 alerts pane: round-border bottom edge
|
|
20
|
+
* 1 footer key hints (single row)
|
|
21
|
+
*
|
|
22
|
+
* A round-bordered Panel costs 2 rows of border plus 1 title row, so each panel
|
|
23
|
+
* adds 3 rows of chrome around its content. The sidebar shares the main pane's
|
|
24
|
+
* row band, so it never contributes additional vertical rows.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** Injectable terminal dimensions (overrides ink's `useStdout`). */
|
|
28
|
+
export interface TerminalSize {
|
|
29
|
+
readonly rows: number;
|
|
30
|
+
readonly columns: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Fallback size when no TTY size is available (matches ink's own default). */
|
|
34
|
+
export const DEFAULT_TERMINAL_SIZE: TerminalSize = { rows: 24, columns: 80 };
|
|
35
|
+
|
|
36
|
+
/** Fixed sidebar width so its labels truncate instead of wrapping. */
|
|
37
|
+
export const SIDEBAR_WIDTH = 22;
|
|
38
|
+
|
|
39
|
+
/** Single padded status row at the top of the frame. */
|
|
40
|
+
const STATUS_BAR_ROWS = 1;
|
|
41
|
+
|
|
42
|
+
/** Single footer row of key hints at the bottom of the frame. */
|
|
43
|
+
const FOOTER_ROWS = 1;
|
|
44
|
+
|
|
45
|
+
/** A round-bordered Panel with a title costs 2 border rows + 1 title row. */
|
|
46
|
+
const PANEL_CHROME_ROWS = 3;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The alerts pane always renders at least one content row: either the queued
|
|
50
|
+
* alerts (1..capacity) or the single "No warnings or errors yet." placeholder.
|
|
51
|
+
*/
|
|
52
|
+
const MIN_ALERT_ROWS = 1;
|
|
53
|
+
|
|
54
|
+
/** The main log pane must keep at least one visible content row. */
|
|
55
|
+
const MIN_LOG_ROWS = 1;
|
|
56
|
+
|
|
57
|
+
export interface ComputeLayoutInput {
|
|
58
|
+
/** Terminal dimensions the frame must fit within. */
|
|
59
|
+
size: TerminalSize;
|
|
60
|
+
/** How many alerts are queued (0 renders the placeholder row). */
|
|
61
|
+
alertCount: number;
|
|
62
|
+
/** Hard cap on alert rows (the alert buffer capacity). */
|
|
63
|
+
alertCapacity: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface DevTuiLayout {
|
|
67
|
+
/** Rows the focused log pane content may use (>= 1). */
|
|
68
|
+
readonly logRows: number;
|
|
69
|
+
/** Rows the alerts list may use (>= 1, <= capacity). */
|
|
70
|
+
readonly alertRows: number;
|
|
71
|
+
/** Total rows the rendered frame occupies (provably <= size.rows). */
|
|
72
|
+
readonly totalRows: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Distribute the terminal's rows across the fixed chrome, the alerts list, and
|
|
77
|
+
* the focused log pane. Alerts are clamped first (they are pinned and bounded
|
|
78
|
+
* by capacity); the log pane then absorbs whatever vertical space remains. Both
|
|
79
|
+
* are floored at one row so a tiny terminal still renders a coherent frame.
|
|
80
|
+
*
|
|
81
|
+
* Guarantee: `logRows + alertRows + chrome === min(totalRows, size.rows)` when
|
|
82
|
+
* the terminal is tall enough, and never exceeds `size.rows`.
|
|
83
|
+
*/
|
|
84
|
+
export function computeLayout({
|
|
85
|
+
size,
|
|
86
|
+
alertCount,
|
|
87
|
+
alertCapacity,
|
|
88
|
+
}: ComputeLayoutInput): DevTuiLayout {
|
|
89
|
+
const terminalRows = Math.max(1, Math.floor(size.rows));
|
|
90
|
+
|
|
91
|
+
// Chrome that is always present regardless of content height.
|
|
92
|
+
const fixedChrome =
|
|
93
|
+
STATUS_BAR_ROWS + PANEL_CHROME_ROWS + PANEL_CHROME_ROWS + FOOTER_ROWS;
|
|
94
|
+
|
|
95
|
+
// Alerts content the pane wants to show (placeholder counts as one row),
|
|
96
|
+
// capped by the buffer capacity so a flood can never grow the pane.
|
|
97
|
+
const cappedCapacity = Math.max(MIN_ALERT_ROWS, Math.floor(alertCapacity));
|
|
98
|
+
const desiredAlertRows = Math.max(MIN_ALERT_ROWS, Math.floor(alertCount));
|
|
99
|
+
const wantedAlertRows = Math.min(desiredAlertRows, cappedCapacity);
|
|
100
|
+
|
|
101
|
+
// Rows left for the variable content after fixed chrome.
|
|
102
|
+
const contentBudget = terminalRows - fixedChrome;
|
|
103
|
+
|
|
104
|
+
if (contentBudget < MIN_LOG_ROWS + MIN_ALERT_ROWS) {
|
|
105
|
+
// Terminal is too short to honor every minimum. Give each variable region
|
|
106
|
+
// its single-row minimum and accept that a genuinely tiny terminal cannot
|
|
107
|
+
// show full chrome; the totalRows we report still reflects exactly what the
|
|
108
|
+
// components occupy so callers can reason about it.
|
|
109
|
+
return {
|
|
110
|
+
logRows: MIN_LOG_ROWS,
|
|
111
|
+
alertRows: MIN_ALERT_ROWS,
|
|
112
|
+
totalRows: fixedChrome + MIN_LOG_ROWS + MIN_ALERT_ROWS,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Alerts take what they want, but never so much that the log pane drops below
|
|
117
|
+
// its minimum.
|
|
118
|
+
const alertRows = Math.min(wantedAlertRows, contentBudget - MIN_LOG_ROWS);
|
|
119
|
+
const logRows = contentBudget - alertRows;
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
logRows,
|
|
123
|
+
alertRows,
|
|
124
|
+
totalRows: fixedChrome + logRows + alertRows,
|
|
125
|
+
};
|
|
126
|
+
}
|