@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,94 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { detectLogLevel, isAlertLevel } from "./log-level.ts";
|
|
3
|
+
|
|
4
|
+
const ESC = String.fromCharCode(27);
|
|
5
|
+
|
|
6
|
+
describe("detectLogLevel", () => {
|
|
7
|
+
it("detects info from the winston dev format", () => {
|
|
8
|
+
expect(detectLogLevel({ line: "21:02:49 info: server started" })).toBe(
|
|
9
|
+
"info",
|
|
10
|
+
);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("detects warn from the winston dev format", () => {
|
|
14
|
+
expect(
|
|
15
|
+
detectLogLevel({ line: "21:02:49 warn: sandbox not ready" }),
|
|
16
|
+
).toBe("warn");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("detects error from the winston dev format", () => {
|
|
20
|
+
expect(detectLogLevel({ line: "09:00:01 error: boom" })).toBe("error");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("detects debug from the winston dev format", () => {
|
|
24
|
+
expect(detectLogLevel({ line: "09:00:01 debug: noisy detail" })).toBe(
|
|
25
|
+
"debug",
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("detects the level even when winston colorize wraps it in ANSI codes", () => {
|
|
30
|
+
// winston colorize() output wraps the level token in SGR color codes.
|
|
31
|
+
const line = `21:02:49 ${ESC}[32minfo${ESC}[39m: server started`;
|
|
32
|
+
expect(detectLogLevel({ line })).toBe("info");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("detects a colorized warn level", () => {
|
|
36
|
+
const line = `21:02:49 ${ESC}[33mwarn${ESC}[39m: heads up`;
|
|
37
|
+
expect(detectLogLevel({ line })).toBe("warn");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("treats winston verbose/silly/http as debug-tier", () => {
|
|
41
|
+
expect(detectLogLevel({ line: "21:02:49 verbose: trace" })).toBe("debug");
|
|
42
|
+
expect(detectLogLevel({ line: "21:02:49 silly: trace" })).toBe("debug");
|
|
43
|
+
expect(detectLogLevel({ line: "21:02:49 http: GET /" })).toBe("debug");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("recognizes a bare level token without a leading timestamp", () => {
|
|
47
|
+
expect(detectLogLevel({ line: "error: vite failed to start" })).toBe(
|
|
48
|
+
"error",
|
|
49
|
+
);
|
|
50
|
+
expect(detectLogLevel({ line: "WARN: deprecated option" })).toBe("warn");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("recognizes bracketed levels like [ERROR]", () => {
|
|
54
|
+
expect(detectLogLevel({ line: "[ERROR] something broke" })).toBe("error");
|
|
55
|
+
expect(detectLogLevel({ line: "VITE v8.0.0 [warning] foo" })).toBe(
|
|
56
|
+
"warn",
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("classifies vite errors that surface as the word Error", () => {
|
|
61
|
+
expect(
|
|
62
|
+
detectLogLevel({ line: "Error: Failed to resolve import" }),
|
|
63
|
+
).toBe("error");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("defaults to info for plain unprefixed output", () => {
|
|
67
|
+
expect(detectLogLevel({ line: "Local: http://localhost:5173/" })).toBe(
|
|
68
|
+
"info",
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("defaults to info for an empty line", () => {
|
|
73
|
+
expect(detectLogLevel({ line: "" })).toBe("info");
|
|
74
|
+
expect(detectLogLevel({ line: " " })).toBe("info");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("does not misread the substring 'error' inside a word", () => {
|
|
78
|
+
expect(
|
|
79
|
+
detectLogLevel({ line: "21:02:49 info: cleared error cache" }),
|
|
80
|
+
).toBe("info");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("isAlertLevel", () => {
|
|
85
|
+
it("flags warn and error as alert levels", () => {
|
|
86
|
+
expect(isAlertLevel("warn")).toBe(true);
|
|
87
|
+
expect(isAlertLevel("error")).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("does not flag info or debug", () => {
|
|
91
|
+
expect(isAlertLevel("info")).toBe(false);
|
|
92
|
+
expect(isAlertLevel("debug")).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { stripAnsi } from "./text.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Normalized log level used across the dev runner UI. The dev processes emit
|
|
5
|
+
* several formats (winston for the backend, vite/bun for the frontend), so we
|
|
6
|
+
* collapse every variant into these four buckets for coloring and alerting.
|
|
7
|
+
*/
|
|
8
|
+
export type LogLevel = "debug" | "info" | "warn" | "error";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Map a raw level token (any case, any source format) to a normalized level.
|
|
12
|
+
* Winston's lower tiers (verbose/silly/http) collapse to "debug"; "warning"
|
|
13
|
+
* collapses to "warn"; "fatal" / "err" collapse to "error".
|
|
14
|
+
*/
|
|
15
|
+
function normalizeToken(token: string): LogLevel | undefined {
|
|
16
|
+
switch (token.toLowerCase()) {
|
|
17
|
+
case "error":
|
|
18
|
+
case "err":
|
|
19
|
+
case "fatal": {
|
|
20
|
+
return "error";
|
|
21
|
+
}
|
|
22
|
+
case "warn":
|
|
23
|
+
case "warning": {
|
|
24
|
+
return "warn";
|
|
25
|
+
}
|
|
26
|
+
case "info":
|
|
27
|
+
case "log":
|
|
28
|
+
case "notice": {
|
|
29
|
+
return "info";
|
|
30
|
+
}
|
|
31
|
+
case "debug":
|
|
32
|
+
case "verbose":
|
|
33
|
+
case "silly":
|
|
34
|
+
case "http":
|
|
35
|
+
case "trace": {
|
|
36
|
+
return "debug";
|
|
37
|
+
}
|
|
38
|
+
default: {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// `21:02:49 info: ...` (winston dev format) - optional timestamp, then a level
|
|
45
|
+
// token, then a colon. The level word is matched as a whole token.
|
|
46
|
+
const WINSTON_FORMAT =
|
|
47
|
+
/^(?:\d{2}:\d{2}:\d{2}\s+)?([A-Za-z]+):\s/;
|
|
48
|
+
|
|
49
|
+
// Bracketed level tag, e.g. `[ERROR]`, `VITE [warning]`.
|
|
50
|
+
const BRACKET_FORMAT = /\[([A-Za-z]+)\]/;
|
|
51
|
+
|
|
52
|
+
// Leading capitalized `Error:` that vite/node print for thrown errors.
|
|
53
|
+
const LEADING_ERROR = /^(?:uncaught\s+)?error\b/i;
|
|
54
|
+
|
|
55
|
+
export interface DetectLogLevelInput {
|
|
56
|
+
/** A single raw output line (may still contain ANSI color codes). */
|
|
57
|
+
line: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Classify a single raw output line into a normalized {@link LogLevel}.
|
|
62
|
+
*
|
|
63
|
+
* Strategy (first match wins):
|
|
64
|
+
* 1. Winston dev format `HH:mm:ss <level>:` or a bare `<level>:` prefix.
|
|
65
|
+
* 2. A bracketed `[LEVEL]` tag anywhere in the line.
|
|
66
|
+
* 3. A leading `Error:` (vite/node thrown errors).
|
|
67
|
+
* 4. Fallback to "info" so ordinary stdout stays readable.
|
|
68
|
+
*/
|
|
69
|
+
export function detectLogLevel({ line }: DetectLogLevelInput): LogLevel {
|
|
70
|
+
const plain = stripAnsi(line).trim();
|
|
71
|
+
if (plain.length === 0) {
|
|
72
|
+
return "info";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const winstonMatch = WINSTON_FORMAT.exec(plain);
|
|
76
|
+
if (winstonMatch) {
|
|
77
|
+
const level = normalizeToken(winstonMatch[1] ?? "");
|
|
78
|
+
if (level) {
|
|
79
|
+
return level;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const bracketMatch = BRACKET_FORMAT.exec(plain);
|
|
84
|
+
if (bracketMatch) {
|
|
85
|
+
const level = normalizeToken(bracketMatch[1] ?? "");
|
|
86
|
+
if (level) {
|
|
87
|
+
return level;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (LEADING_ERROR.test(plain)) {
|
|
92
|
+
return "error";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return "info";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Whether a level should surface in the pinned Alerts panel. Only warnings and
|
|
100
|
+
* errors are aggregated so the panel stays signal, not noise.
|
|
101
|
+
*/
|
|
102
|
+
export function isAlertLevel(level: LogLevel): boolean {
|
|
103
|
+
return level === "warn" || level === "error";
|
|
104
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
import { isAlertLevel } from "./log-level.ts";
|
|
3
|
+
import type { Supervisor } from "./supervisor.ts";
|
|
4
|
+
|
|
5
|
+
export interface RunPlainStreamingInput {
|
|
6
|
+
supervisor: Supervisor;
|
|
7
|
+
/** Sink for output (defaults to process.stdout). Injectable for smoke tests. */
|
|
8
|
+
write?: (text: string) => void;
|
|
9
|
+
/**
|
|
10
|
+
* Called after a clean shutdown completes (e.g. to exit the process). The
|
|
11
|
+
* entry point owns the actual `process.exit`; this module stays exit-free so
|
|
12
|
+
* it can be smoke-tested without tearing down the test runner.
|
|
13
|
+
*/
|
|
14
|
+
onShutdownComplete?: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Non-TTY fallback: stream every process's output to stdout with a source
|
|
19
|
+
* prefix, never entering ink's raw mode. Used when piped or in CI so the runner
|
|
20
|
+
* degrades gracefully instead of crashing. Alerts (warn/error) are marked with
|
|
21
|
+
* a leading bang so they remain greppable even without the pinned panel.
|
|
22
|
+
*
|
|
23
|
+
* Returns the registered SIGINT/SIGTERM handler so callers (tests) can trigger
|
|
24
|
+
* a clean shutdown deterministically.
|
|
25
|
+
*/
|
|
26
|
+
export function runPlainStreaming({
|
|
27
|
+
supervisor,
|
|
28
|
+
write = (text: string): void => {
|
|
29
|
+
process.stdout.write(text);
|
|
30
|
+
},
|
|
31
|
+
onShutdownComplete,
|
|
32
|
+
}: RunPlainStreamingInput): () => void {
|
|
33
|
+
supervisor.onLine((line) => {
|
|
34
|
+
const marker = isAlertLevel(line.level) ? "!" : " ";
|
|
35
|
+
write(`${marker}[${line.source}] ${line.text}\n`);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
supervisor.onStatus(({ id, status }) => {
|
|
39
|
+
write(` [${id}] status: ${status}\n`);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
let shuttingDown = false;
|
|
43
|
+
const shutdown = (): void => {
|
|
44
|
+
if (shuttingDown) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
shuttingDown = true;
|
|
48
|
+
write("\n[dev-tui] shutting down backend + frontend (leaving docker deps up)\n");
|
|
49
|
+
void supervisor.shutdown().then(() => {
|
|
50
|
+
onShutdownComplete?.();
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
process.on("SIGINT", shutdown);
|
|
55
|
+
process.on("SIGTERM", shutdown);
|
|
56
|
+
|
|
57
|
+
supervisor.start();
|
|
58
|
+
|
|
59
|
+
return shutdown;
|
|
60
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { PROCESS_DEFS } from "./process-config.ts";
|
|
3
|
+
|
|
4
|
+
describe("PROCESS_DEFS", () => {
|
|
5
|
+
it("never spawns a long-running task via bun --filter", () => {
|
|
6
|
+
// `bun run --filter` buffers + frames output through its task dashboard
|
|
7
|
+
// instead of streaming raw lines, which defeats the per-line level
|
|
8
|
+
// detection that feeds the Alerts panel. Each watcher must run its own
|
|
9
|
+
// package's dev script directly.
|
|
10
|
+
for (const def of PROCESS_DEFS) {
|
|
11
|
+
expect(def.args).not.toContain("--filter");
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("runs backend + frontend dev directly in their package dirs", () => {
|
|
16
|
+
const byId = Object.fromEntries(PROCESS_DEFS.map((def) => [def.id, def]));
|
|
17
|
+
|
|
18
|
+
expect(byId.backend?.command).toBe("bun");
|
|
19
|
+
expect(byId.backend?.args).toEqual(["run", "dev"]);
|
|
20
|
+
expect(byId.backend?.cwd).toBe("core/backend");
|
|
21
|
+
expect(byId.backend?.oneShot).toBe(false);
|
|
22
|
+
|
|
23
|
+
expect(byId.frontend?.command).toBe("bun");
|
|
24
|
+
expect(byId.frontend?.args).toEqual(["run", "dev"]);
|
|
25
|
+
expect(byId.frontend?.cwd).toBe("core/frontend");
|
|
26
|
+
expect(byId.frontend?.oneShot).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("runs deps as a one-shot docker task at the repo root", () => {
|
|
30
|
+
const deps = PROCESS_DEFS.find((def) => def.id === "deps");
|
|
31
|
+
expect(deps?.command).toBe("docker");
|
|
32
|
+
expect(deps?.args).toEqual([
|
|
33
|
+
"compose",
|
|
34
|
+
"-f",
|
|
35
|
+
"docker-compose-dev.yml",
|
|
36
|
+
"up",
|
|
37
|
+
"-d",
|
|
38
|
+
]);
|
|
39
|
+
expect(deps?.cwd).toBeUndefined();
|
|
40
|
+
expect(deps?.oneShot).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { ProcessId } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
/** Static definition of one supervised process. */
|
|
4
|
+
export interface ProcessDef {
|
|
5
|
+
readonly id: ProcessId;
|
|
6
|
+
/** Human-readable name shown in the sidebar. */
|
|
7
|
+
readonly label: string;
|
|
8
|
+
/** Executable to spawn. */
|
|
9
|
+
readonly command: string;
|
|
10
|
+
/** Arguments passed to the executable. */
|
|
11
|
+
readonly args: readonly string[];
|
|
12
|
+
/**
|
|
13
|
+
* Working directory for the process, RELATIVE to the runner's root cwd (the
|
|
14
|
+
* repo root). Undefined runs at the root. Each long-running dev task runs
|
|
15
|
+
* directly in its own package dir so it streams that package's RAW output
|
|
16
|
+
* (e.g. winston `HH:MM:SS warn:` lines) - never through bun's `--filter`
|
|
17
|
+
* multi-task runner, which buffers + frames output and would defeat the
|
|
18
|
+
* per-line level detection feeding the Alerts panel.
|
|
19
|
+
*/
|
|
20
|
+
readonly cwd?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Whether this is a one-shot task (docker compose up -d) vs a long-running
|
|
23
|
+
* watcher. One-shot tasks become "ready" on a zero exit code; long-running
|
|
24
|
+
* ones become "ready" on a readiness marker and "stopped"/"errored" on exit.
|
|
25
|
+
*/
|
|
26
|
+
readonly oneShot: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The three supervised processes, in sidebar order. The runner spawns each
|
|
31
|
+
* command DIRECTLY (no `bun run --filter`): deps via docker at the repo root,
|
|
32
|
+
* and each dev watcher via its own package's `dev` script in that package's
|
|
33
|
+
* dir. Running the package script directly streams its raw output, which the
|
|
34
|
+
* per-line level detection (and the Alerts panel) depends on; bun's `--filter`
|
|
35
|
+
* runner instead buffers/frames output through its task dashboard.
|
|
36
|
+
*/
|
|
37
|
+
export const PROCESS_DEFS: readonly ProcessDef[] = [
|
|
38
|
+
{
|
|
39
|
+
id: "deps",
|
|
40
|
+
label: "deps (docker)",
|
|
41
|
+
command: "docker",
|
|
42
|
+
args: ["compose", "-f", "docker-compose-dev.yml", "up", "-d"],
|
|
43
|
+
oneShot: true,
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: "backend",
|
|
47
|
+
label: "backend",
|
|
48
|
+
command: "bun",
|
|
49
|
+
args: ["run", "dev"],
|
|
50
|
+
cwd: "core/backend",
|
|
51
|
+
oneShot: false,
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: "frontend",
|
|
55
|
+
label: "frontend",
|
|
56
|
+
command: "bun",
|
|
57
|
+
args: ["run", "dev"],
|
|
58
|
+
cwd: "core/frontend",
|
|
59
|
+
oneShot: false,
|
|
60
|
+
},
|
|
61
|
+
];
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { matchesReady, READY_PATTERNS } from "./readiness.ts";
|
|
3
|
+
|
|
4
|
+
describe("matchesReady", () => {
|
|
5
|
+
it("treats a vite Local URL as ready for the frontend", () => {
|
|
6
|
+
expect(
|
|
7
|
+
matchesReady({
|
|
8
|
+
id: "frontend",
|
|
9
|
+
line: " ➜ Local: http://localhost:5173/",
|
|
10
|
+
}),
|
|
11
|
+
).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("treats vite 'ready in' as ready for the frontend", () => {
|
|
15
|
+
expect(
|
|
16
|
+
matchesReady({ id: "frontend", line: "VITE v8.0.0 ready in 312 ms" }),
|
|
17
|
+
).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("treats a backend listening message as ready", () => {
|
|
21
|
+
expect(
|
|
22
|
+
matchesReady({
|
|
23
|
+
id: "backend",
|
|
24
|
+
line: "21:02:49 info: server listening on http://localhost:3000",
|
|
25
|
+
}),
|
|
26
|
+
).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("treats a backend 'ready' message as ready", () => {
|
|
30
|
+
expect(
|
|
31
|
+
matchesReady({ id: "backend", line: "21:02:49 info: backend ready" }),
|
|
32
|
+
).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("does not mark ordinary output as ready", () => {
|
|
36
|
+
expect(
|
|
37
|
+
matchesReady({ id: "backend", line: "21:02:49 info: loading plugin" }),
|
|
38
|
+
).toBe(false);
|
|
39
|
+
expect(
|
|
40
|
+
matchesReady({ id: "frontend", line: "transforming module foo.ts" }),
|
|
41
|
+
).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("matches case-insensitively", () => {
|
|
45
|
+
expect(
|
|
46
|
+
matchesReady({ id: "backend", line: "Server LISTENING on 3000" }),
|
|
47
|
+
).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("has a pattern set for every long-running process", () => {
|
|
51
|
+
expect(READY_PATTERNS.backend.length).toBeGreaterThan(0);
|
|
52
|
+
expect(READY_PATTERNS.frontend.length).toBeGreaterThan(0);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { stripAnsi } from "./text.ts";
|
|
2
|
+
import type { ProcessId } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Readiness detection for the long-running dev processes. A process starts in
|
|
6
|
+
* "starting" and flips to "ready" the first time one of its readiness markers
|
|
7
|
+
* appears in its output. Patterns are kept conservative so ordinary log lines
|
|
8
|
+
* do not trigger a false "ready".
|
|
9
|
+
*
|
|
10
|
+
* `deps` (docker compose) has no streaming readiness marker - its readiness is
|
|
11
|
+
* driven by the compose command's exit code, not by matching a line - so it has
|
|
12
|
+
* an empty pattern set here.
|
|
13
|
+
*/
|
|
14
|
+
export const READY_PATTERNS: Readonly<Record<ProcessId, readonly RegExp[]>> = {
|
|
15
|
+
deps: [],
|
|
16
|
+
backend: [
|
|
17
|
+
// winston: "... info: server listening on http://localhost:3000"
|
|
18
|
+
/\blistening\b/i,
|
|
19
|
+
// a generic "<something> ready" banner
|
|
20
|
+
/\bready\b/i,
|
|
21
|
+
// a bound HTTP url is a strong readiness signal
|
|
22
|
+
/https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0):\d+/i,
|
|
23
|
+
],
|
|
24
|
+
frontend: [
|
|
25
|
+
// vite dev server: " ➜ Local: http://localhost:5173/"
|
|
26
|
+
/\blocal:\s*https?:\/\//i,
|
|
27
|
+
// vite: "ready in 312 ms"
|
|
28
|
+
/\bready in\b/i,
|
|
29
|
+
],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export interface MatchesReadyInput {
|
|
33
|
+
id: ProcessId;
|
|
34
|
+
/** A single raw output line (ANSI is stripped internally). */
|
|
35
|
+
line: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Whether a given output line indicates the named process has become ready.
|
|
40
|
+
*/
|
|
41
|
+
export function matchesReady({ id, line }: MatchesReadyInput): boolean {
|
|
42
|
+
const plain = stripAnsi(line);
|
|
43
|
+
return READY_PATTERNS[id].some((pattern) => pattern.test(plain));
|
|
44
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { createScrollback } from "./scrollback.ts";
|
|
3
|
+
|
|
4
|
+
describe("createScrollback", () => {
|
|
5
|
+
it("stores appended lines in order", () => {
|
|
6
|
+
const sb = createScrollback({ capacity: 10 });
|
|
7
|
+
sb.append({ text: "first", level: "info" });
|
|
8
|
+
sb.append({ text: "second", level: "warn" });
|
|
9
|
+
expect(sb.lines().map((entry) => entry.text)).toEqual(["first", "second"]);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("caps at capacity, dropping the oldest lines", () => {
|
|
13
|
+
const sb = createScrollback({ capacity: 3 });
|
|
14
|
+
for (let i = 1; i <= 5; i++) {
|
|
15
|
+
sb.append({ text: `l${i}`, level: "info" });
|
|
16
|
+
}
|
|
17
|
+
expect(sb.lines().map((entry) => entry.text)).toEqual(["l3", "l4", "l5"]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("reports the total number of lines ever appended", () => {
|
|
21
|
+
const sb = createScrollback({ capacity: 2 });
|
|
22
|
+
sb.append({ text: "a", level: "info" });
|
|
23
|
+
sb.append({ text: "b", level: "info" });
|
|
24
|
+
sb.append({ text: "c", level: "info" });
|
|
25
|
+
expect(sb.size()).toBe(2);
|
|
26
|
+
expect(sb.totalAppended()).toBe(3);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("returns a windowed slice for the viewport, tailing by default", () => {
|
|
30
|
+
const sb = createScrollback({ capacity: 100 });
|
|
31
|
+
for (let i = 1; i <= 10; i++) {
|
|
32
|
+
sb.append({ text: `l${i}`, level: "info" });
|
|
33
|
+
}
|
|
34
|
+
// tail window of height 3 -> last 3 lines
|
|
35
|
+
const view = sb.window({ height: 3, scrollOffset: 0 });
|
|
36
|
+
expect(view.map((entry) => entry.text)).toEqual(["l8", "l9", "l10"]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("scrolls back by the given offset", () => {
|
|
40
|
+
const sb = createScrollback({ capacity: 100 });
|
|
41
|
+
for (let i = 1; i <= 10; i++) {
|
|
42
|
+
sb.append({ text: `l${i}`, level: "info" });
|
|
43
|
+
}
|
|
44
|
+
const view = sb.window({ height: 3, scrollOffset: 2 });
|
|
45
|
+
expect(view.map((entry) => entry.text)).toEqual(["l6", "l7", "l8"]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("clamps a scroll offset that exceeds the history", () => {
|
|
49
|
+
const sb = createScrollback({ capacity: 100 });
|
|
50
|
+
for (let i = 1; i <= 5; i++) {
|
|
51
|
+
sb.append({ text: `l${i}`, level: "info" });
|
|
52
|
+
}
|
|
53
|
+
const view = sb.window({ height: 3, scrollOffset: 999 });
|
|
54
|
+
expect(view.map((entry) => entry.text)).toEqual(["l1", "l2", "l3"]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("returns all lines when the viewport is taller than the history", () => {
|
|
58
|
+
const sb = createScrollback({ capacity: 100 });
|
|
59
|
+
sb.append({ text: "only", level: "warn" });
|
|
60
|
+
const view = sb.window({ height: 10, scrollOffset: 0 });
|
|
61
|
+
expect(view.map((entry) => entry.text)).toEqual(["only"]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("rejects a non-positive capacity", () => {
|
|
65
|
+
expect(() => createScrollback({ capacity: 0 })).toThrow();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("clampScrollOffset", () => {
|
|
70
|
+
it("is exercised indirectly via window()", () => {
|
|
71
|
+
const sb = createScrollback({ capacity: 100 });
|
|
72
|
+
for (let i = 1; i <= 4; i++) {
|
|
73
|
+
sb.append({ text: `l${i}`, level: "info" });
|
|
74
|
+
}
|
|
75
|
+
// maxOffset = size(4) - height(2) = 2
|
|
76
|
+
expect(
|
|
77
|
+
sb.window({ height: 2, scrollOffset: 2 }).map((entry) => entry.text),
|
|
78
|
+
).toEqual(["l1", "l2"]);
|
|
79
|
+
expect(
|
|
80
|
+
sb.window({ height: 2, scrollOffset: 3 }).map((entry) => entry.text),
|
|
81
|
+
).toEqual(["l1", "l2"]);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { LogLevel } from "./log-level.ts";
|
|
2
|
+
|
|
3
|
+
/** One stored output line with its detected level (for coloring). */
|
|
4
|
+
export interface ScrollbackLine {
|
|
5
|
+
readonly text: string;
|
|
6
|
+
readonly level: LogLevel;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ScrollbackWindowInput {
|
|
10
|
+
/** Number of visible rows in the main pane. */
|
|
11
|
+
height: number;
|
|
12
|
+
/**
|
|
13
|
+
* How many lines above the tail to scroll. 0 = follow the newest output;
|
|
14
|
+
* larger values move the window towards older history (clamped).
|
|
15
|
+
*/
|
|
16
|
+
scrollOffset: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface Scrollback {
|
|
20
|
+
append(line: ScrollbackLine): void;
|
|
21
|
+
/** All retained lines, oldest-first (defensive copy). */
|
|
22
|
+
lines(): ScrollbackLine[];
|
|
23
|
+
/** Number of lines currently retained (<= capacity). */
|
|
24
|
+
size(): number;
|
|
25
|
+
/** Number of lines ever appended (for an overflow indicator). */
|
|
26
|
+
totalAppended(): number;
|
|
27
|
+
/** The slice of lines visible for a given viewport height and scroll offset. */
|
|
28
|
+
window(input: ScrollbackWindowInput): ScrollbackLine[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface CreateScrollbackInput {
|
|
32
|
+
/** Maximum number of lines to retain per process. */
|
|
33
|
+
capacity: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Bounded scrollback buffer for one process's output. Keeps the last
|
|
38
|
+
* `capacity` lines and exposes a `window()` that follows the tail by default
|
|
39
|
+
* and supports scrolling back through retained history.
|
|
40
|
+
*/
|
|
41
|
+
export function createScrollback({
|
|
42
|
+
capacity,
|
|
43
|
+
}: CreateScrollbackInput): Scrollback {
|
|
44
|
+
if (!Number.isInteger(capacity) || capacity <= 0) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`Scrollback capacity must be a positive integer, got ${capacity}`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let buffer: ScrollbackLine[] = [];
|
|
51
|
+
let total = 0;
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
append(line: ScrollbackLine): void {
|
|
55
|
+
buffer.push(line);
|
|
56
|
+
total += 1;
|
|
57
|
+
if (buffer.length > capacity) {
|
|
58
|
+
buffer = buffer.slice(buffer.length - capacity);
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
lines(): ScrollbackLine[] {
|
|
62
|
+
return [...buffer];
|
|
63
|
+
},
|
|
64
|
+
size(): number {
|
|
65
|
+
return buffer.length;
|
|
66
|
+
},
|
|
67
|
+
totalAppended(): number {
|
|
68
|
+
return total;
|
|
69
|
+
},
|
|
70
|
+
window({ height, scrollOffset }: ScrollbackWindowInput): ScrollbackLine[] {
|
|
71
|
+
const visibleHeight = Math.max(1, Math.floor(height));
|
|
72
|
+
if (buffer.length <= visibleHeight) {
|
|
73
|
+
return [...buffer];
|
|
74
|
+
}
|
|
75
|
+
const maxOffset = buffer.length - visibleHeight;
|
|
76
|
+
const offset = Math.min(Math.max(0, Math.floor(scrollOffset)), maxOffset);
|
|
77
|
+
const end = buffer.length - offset;
|
|
78
|
+
const start = end - visibleHeight;
|
|
79
|
+
return buffer.slice(start, end);
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|