@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.
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/scripts",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
4
  "description": "Checkstack tooling: plugin scaffolding, codegen, and the plugin-pack CLI used by external plugin authors.",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -10,6 +10,11 @@
10
10
  "bin": {
11
11
  "checkstack-scripts": "./src/cli.ts"
12
12
  },
13
+ "exports": {
14
+ "./scaffold": {
15
+ "import": "./src/scaffold/index.ts"
16
+ }
17
+ },
13
18
  "files": [
14
19
  "src",
15
20
  "README.md"
@@ -22,15 +27,20 @@
22
27
  "typecheck": "tsgo -b"
23
28
  },
24
29
  "dependencies": {
25
- "@checkstack/common": "0.10.0",
26
- "inquirer": "^13.4.1",
30
+ "@checkstack/common": "0.12.0",
31
+ "ansi-escapes": "^7.3.0",
27
32
  "handlebars": "^4.7.8",
28
- "tar": "^7.4.3"
33
+ "ink": "^7.0.5",
34
+ "inquirer": "^13.4.1",
35
+ "react": "^19.2.7",
36
+ "tar": "^7.5.16",
37
+ "zod": "^4.4.3"
29
38
  },
30
39
  "devDependencies": {
31
40
  "@checkstack/tsconfig": "0.0.7",
32
- "@types/inquirer": "^8.2.10",
33
41
  "@types/handlebars": "^4.1.0",
42
+ "@types/inquirer": "^8.2.10",
43
+ "@types/react": "^19.2.16",
34
44
  "typescript": "^5.0.0"
35
45
  }
36
46
  }
@@ -1,21 +1,18 @@
1
1
  #!/usr/bin/env bun
2
2
  import inquirer from "inquirer";
3
3
  import path from "node:path";
4
- import { spawnSync } from "node:child_process";
5
4
  import {
6
5
  validatePluginName,
7
6
  pluginExists,
8
7
  packageExists,
9
8
  extractBaseName,
10
9
  } from "../utils/validation";
10
+ import { registerHelpers, prepareTemplateData } from "../utils/template";
11
11
  import {
12
- registerHelpers,
13
- copyTemplate,
14
- prepareTemplateData,
15
- } from "../utils/template";
16
- import { fileURLToPath } from "node:url";
17
-
18
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+ scaffoldPlugin,
13
+ refreshMonorepoReferences,
14
+ type ScaffoldMode,
15
+ } from "../scaffold/scaffold-plugin";
19
16
 
20
17
  interface PluginTypeChoice {
21
18
  name: string;
@@ -191,18 +188,18 @@ export async function createCommand() {
191
188
  `\n📦 Creating ${pluginType} ${locationLabel}: ${templateData.pluginName}`
192
189
  );
193
190
 
194
- const templateDir = path.join(__dirname, "..", "templates", pluginType);
195
- const targetDir = path.join(
191
+ const mode: ScaffoldMode = {
192
+ kind: "monorepo",
196
193
  rootDir,
197
- packageLocation,
198
- templateData.pluginName
199
- );
194
+ location: packageLocation,
195
+ };
200
196
 
201
197
  try {
202
- const createdFiles = copyTemplate({
203
- templateDir,
204
- targetDir,
205
- data: templateData,
198
+ const { targetDir, createdFiles } = await scaffoldPlugin({
199
+ mode,
200
+ baseName: pluginBaseName,
201
+ description,
202
+ pluginType,
206
203
  });
207
204
 
208
205
  console.log(
@@ -222,12 +219,8 @@ export async function createCommand() {
222
219
  // solution tsconfig so the new package is wired into the typecheck
223
220
  // graph. Affects only tsconfig.json files; safe to rerun any time.
224
221
  console.log("\n🔗 Refreshing TypeScript project references...");
225
- const refResult = spawnSync(
226
- "bun",
227
- ["run", "typecheck:references:generate"],
228
- { stdio: "inherit" },
229
- );
230
- if (refResult.status !== 0) {
222
+ const refStatus = refreshMonorepoReferences({ mode });
223
+ if (refStatus !== 0) {
231
224
  console.warn(
232
225
  "⚠️ Failed to refresh references automatically. " +
233
226
  "Run `bun run typecheck:references:generate` manually before typechecking.",
@@ -10,6 +10,11 @@ import {
10
10
  type InstallPackageMetadata,
11
11
  type PluginBundleManifest,
12
12
  } from "@checkstack/common";
13
+ import {
14
+ rewriteWorkspaceVersions,
15
+ type RewritablePackageJson,
16
+ } from "../scaffold/rewrite-workspace-versions";
17
+ import { createWorkspaceMapResolver } from "../scaffold/resolve-versions";
13
18
 
14
19
  /**
15
20
  * `plugin-pack` CLI.
@@ -315,35 +320,19 @@ async function packPackage({
315
320
  }): Promise<string> {
316
321
  const pkgJsonPath = path.join(pkgDir, "package.json");
317
322
  const original = fs.readFileSync(pkgJsonPath, "utf8");
318
- const pkg = JSON.parse(original) as InstallPackageMetadata & {
319
- devDependencies?: Record<string, string>;
320
- peerDependencies?: Record<string, string>;
321
- };
323
+ const pkg = JSON.parse(original) as InstallPackageMetadata &
324
+ RewritablePackageJson;
322
325
 
323
- let rewritten = false;
324
- for (const section of [
325
- "dependencies",
326
- "devDependencies",
327
- "peerDependencies",
328
- ] as const) {
329
- const block = pkg[section];
330
- if (!block) continue;
331
- for (const [name, range] of Object.entries(block)) {
332
- if (range.startsWith("workspace:")) {
333
- const targetDir = workspaceMap.get(name);
334
- if (!targetDir) {
335
- throw new Error(
336
- `Cannot resolve workspace dep '${name}' (declared in '${pkg.name}'). ` +
337
- `Either move the package into the workspace or replace the workspace range with a concrete version.`,
338
- );
339
- }
340
- const targetPkg = readJson<{ version: string }>(
341
- path.join(targetDir, "package.json"),
342
- );
343
- block[name] = `^${targetPkg.version}`;
344
- rewritten = true;
345
- }
346
- }
326
+ const { rewritten, unresolved } = await rewriteWorkspaceVersions({
327
+ pkg,
328
+ resolveVersion: createWorkspaceMapResolver({ workspaceMap }),
329
+ });
330
+ if (unresolved.length > 0) {
331
+ const name = unresolved[0];
332
+ throw new Error(
333
+ `Cannot resolve workspace dep '${name}' (declared in '${pkg.name}'). ` +
334
+ `Either move the package into the workspace or replace the workspace range with a concrete version.`,
335
+ );
347
336
  }
348
337
 
349
338
  try {
@@ -0,0 +1,135 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import React from "react";
3
+ import { renderToString } from "ink";
4
+ import { App } from "./App.tsx";
5
+ import { createFakeSupervisor } from "./fake-supervisor.ts";
6
+ import { computeLayout } from "./layout.ts";
7
+ import type { ScrollbackLine } from "./scrollback.ts";
8
+
9
+ const ALERTS_CAPACITY = 8;
10
+
11
+ /** Strip ANSI SGR sequences so we can measure printable width and content. */
12
+ function stripAnsi(value: string): string {
13
+ // Build the ESC (0x1b) prefix without a literal control char in the source,
14
+ // so we avoid both a control-char regex and any lint suppression.
15
+ const ansiPattern = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
16
+ return value.replace(ansiPattern, "");
17
+ }
18
+
19
+ /**
20
+ * Seed the focused (backend) pane with MANY lines that are each LONGER than the
21
+ * terminal width, so the only way the frame can stay within bounds is the
22
+ * height clamp plus per-line truncation working together.
23
+ */
24
+ function manyLongLines(count: number, columns: number): ScrollbackLine[] {
25
+ const lines: ScrollbackLine[] = [];
26
+ for (let index = 0; index < count; index += 1) {
27
+ lines.push({
28
+ level: "info",
29
+ text: `line-${index}-`.padEnd(columns * 2, "x"),
30
+ });
31
+ }
32
+ return lines;
33
+ }
34
+
35
+ describe("App height clamp (DOM-free render test)", () => {
36
+ const columns = 80;
37
+ // More lines than any tested terminal is tall, each wider than `columns`.
38
+ const seededLineCount = 200;
39
+
40
+ for (const rows of [24, 40]) {
41
+ it(`never overflows ${rows} rows when the focused pane floods output`, () => {
42
+ const supervisor = createFakeSupervisor();
43
+ const frame = renderToString(
44
+ <App
45
+ supervisor={supervisor}
46
+ size={{ rows, columns }}
47
+ initialFocused="backend"
48
+ preloadLines={{ backend: manyLongLines(seededLineCount, columns) }}
49
+ />,
50
+ { columns },
51
+ );
52
+
53
+ const lines = frame.split("\n");
54
+
55
+ // 1. The frame must never be taller than the terminal. This is the hard
56
+ // invariant the clamp guarantees.
57
+ expect(lines.length).toBeLessThanOrEqual(rows);
58
+
59
+ // 2. The clamp fills the terminal exactly (no overflow, no wasted band).
60
+ expect(lines.length).toBe(rows);
61
+
62
+ // 3. The pinned chrome must still be on-screen (not pushed off the bottom).
63
+ const plain = stripAnsi(frame);
64
+ expect(plain).toContain("quit");
65
+ expect(plain).toContain("alerts");
66
+
67
+ // 4. Per-line truncation holds: no rendered row exceeds the width.
68
+ for (const line of lines) {
69
+ expect(stripAnsi(line).length).toBeLessThanOrEqual(columns);
70
+ }
71
+
72
+ // 5. The focused pane shows EXACTLY the computed log-row budget worth of
73
+ // seeded lines. The seeded lines start with "line-<n>-", so counting
74
+ // those rows tells us how many log rows actually rendered. The pre-fix
75
+ // `rows - 16` reserve under-filled this (e.g. 8 rows at rows=24 instead
76
+ // of the correct 15), so this assertion discriminates the fix.
77
+ const expectedLogRows = computeLayout({
78
+ size: { rows, columns },
79
+ alertCount: 0,
80
+ alertCapacity: ALERTS_CAPACITY,
81
+ }).logRows;
82
+ const logRowsRendered = lines.filter((line) =>
83
+ stripAnsi(line).includes("line-"),
84
+ ).length;
85
+ expect(logRowsRendered).toBe(expectedLogRows);
86
+ });
87
+ }
88
+
89
+ it("keeps the footer and alerts visible when alerts fill to capacity and logs flood", () => {
90
+ const supervisor = createFakeSupervisor();
91
+ const seededAlerts = Array.from({ length: 8 }, (_, index) => ({
92
+ source: "backend" as const,
93
+ level: "error" as const,
94
+ text: `alert-${index}-`.padEnd(columns * 2, "y"),
95
+ seq: index,
96
+ }));
97
+ const frame = renderToString(
98
+ <App
99
+ supervisor={supervisor}
100
+ size={{ rows: 24, columns }}
101
+ initialFocused="backend"
102
+ preloadLines={{ backend: manyLongLines(seededLineCount, columns) }}
103
+ initialAlerts={seededAlerts}
104
+ />,
105
+ { columns },
106
+ );
107
+ const lines = frame.split("\n");
108
+ expect(lines.length).toBeLessThanOrEqual(24);
109
+ const plain = stripAnsi(frame);
110
+ expect(plain).toContain("quit");
111
+ expect(plain).toContain("alerts (8)");
112
+ for (const line of lines) {
113
+ expect(stripAnsi(line).length).toBeLessThanOrEqual(columns);
114
+ }
115
+ });
116
+
117
+ it("renders the seeded flood as the visible content of the focused pane", () => {
118
+ const supervisor = createFakeSupervisor();
119
+ const frame = renderToString(
120
+ <App
121
+ supervisor={supervisor}
122
+ size={{ rows: 24, columns }}
123
+ initialFocused="backend"
124
+ preloadLines={{ backend: manyLongLines(seededLineCount, columns) }}
125
+ />,
126
+ { columns },
127
+ );
128
+ // The seam actually seeds the focused pane: at least one of the flooded
129
+ // backend lines is visible (so the clamp is being exercised against real
130
+ // content, not an empty placeholder).
131
+ const plain = stripAnsi(frame);
132
+ expect(plain).toContain("line-");
133
+ expect(plain).not.toContain("Waiting for output...");
134
+ });
135
+ });
@@ -0,0 +1,142 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import React from "react";
3
+ import { renderToString } from "ink";
4
+ import { App, ShutdownScreen } from "./App.tsx";
5
+ import { SPINNER_FRAMES } from "../tui/index.ts";
6
+ import { createFakeSupervisor } from "./fake-supervisor.ts";
7
+ import { runPlainStreaming } from "./plain-runner.ts";
8
+
9
+ describe("App initial frame (DOM-free smoke test)", () => {
10
+ it("renders the chrome without throwing and starts the supervisor", () => {
11
+ const supervisor = createFakeSupervisor();
12
+ const frame = renderToString(<App supervisor={supervisor} />, {
13
+ columns: 100,
14
+ });
15
+
16
+ // App name + footer hints + every process label render in the first frame.
17
+ expect(frame).toContain("checkstack dev");
18
+ expect(frame).toContain("backend");
19
+ expect(frame).toContain("frontend");
20
+ expect(frame).toContain("deps");
21
+ expect(frame).toContain("alerts");
22
+ expect(frame).toContain("quit");
23
+
24
+ // The mount effect kicks off the supervisor.
25
+ expect(supervisor.started).toBe(true);
26
+ });
27
+ });
28
+
29
+ describe("ShutdownScreen", () => {
30
+ it("shows the shutdown heading + a spinner while services stop", () => {
31
+ const frame = renderToString(
32
+ <ShutdownScreen
33
+ width={80}
34
+ height={20}
35
+ frame={0}
36
+ statuses={{ deps: "ready", backend: "starting", frontend: "starting" }}
37
+ />,
38
+ );
39
+ expect(frame).toContain("Shutting down checkstack dev");
40
+ // Long-running services are listed and still spinning (not yet stopped).
41
+ expect(frame).toContain("backend");
42
+ expect(frame).toContain("frontend");
43
+ expect(frame).toContain(SPINNER_FRAMES[0]);
44
+ // The one-shot deps process is left running, so it is not listed.
45
+ expect(frame).not.toContain("✓");
46
+ });
47
+
48
+ it("marks a stopped service with a check", () => {
49
+ const frame = renderToString(
50
+ <ShutdownScreen
51
+ width={80}
52
+ height={20}
53
+ frame={0}
54
+ statuses={{ deps: "ready", backend: "stopped", frontend: "starting" }}
55
+ />,
56
+ );
57
+ expect(frame).toContain("✓");
58
+ expect(frame).toContain("stopped");
59
+ });
60
+
61
+ it("treats a non-zero exit during shutdown as stopped, not an error", () => {
62
+ // We killed the process on purpose; dev servers (e.g. Vite) often exit 143/1
63
+ // on SIGTERM, which the supervisor records as 'errored'. During teardown
64
+ // that is expected - show a check, never a cross.
65
+ const frame = renderToString(
66
+ <ShutdownScreen
67
+ width={80}
68
+ height={20}
69
+ frame={0}
70
+ statuses={{ deps: "ready", backend: "stopped", frontend: "errored" }}
71
+ />,
72
+ );
73
+ expect(frame).not.toContain("✗");
74
+ // Both long-running services are shown as stopped (two checks).
75
+ expect(frame.match(/✓/g) ?? []).toHaveLength(2);
76
+ });
77
+ });
78
+
79
+ describe("runPlainStreaming (non-TTY fallback)", () => {
80
+ it("starts the supervisor and prefixes each line with its source", () => {
81
+ const supervisor = createFakeSupervisor();
82
+ const out: string[] = [];
83
+ runPlainStreaming({
84
+ supervisor,
85
+ write: (text) => out.push(text),
86
+ });
87
+
88
+ expect(supervisor.started).toBe(true);
89
+
90
+ supervisor.emitLine({
91
+ source: "backend",
92
+ level: "info",
93
+ text: "21:02:49 info: hello",
94
+ seq: 1,
95
+ });
96
+ expect(out.join("")).toContain("[backend] 21:02:49 info: hello");
97
+ });
98
+
99
+ it("marks warn/error lines with a leading bang so they stay greppable", () => {
100
+ const supervisor = createFakeSupervisor();
101
+ const out: string[] = [];
102
+ runPlainStreaming({ supervisor, write: (text) => out.push(text) });
103
+
104
+ supervisor.emitLine({
105
+ source: "backend",
106
+ level: "warn",
107
+ text: "sandbox not ready",
108
+ seq: 1,
109
+ });
110
+ expect(out.some((chunk) => chunk.startsWith("!"))).toBe(true);
111
+ });
112
+
113
+ it("emits status transitions", () => {
114
+ const supervisor = createFakeSupervisor();
115
+ const out: string[] = [];
116
+ runPlainStreaming({ supervisor, write: (text) => out.push(text) });
117
+
118
+ supervisor.emitStatus({ id: "frontend", status: "ready" });
119
+ expect(out.join("")).toContain("[frontend] status: ready");
120
+ });
121
+
122
+ it("shuts down the supervisor and invokes the completion callback", async () => {
123
+ const supervisor = createFakeSupervisor();
124
+ const out: string[] = [];
125
+ let completed = false;
126
+ const shutdown = runPlainStreaming({
127
+ supervisor,
128
+ write: (text) => out.push(text),
129
+ onShutdownComplete: () => {
130
+ completed = true;
131
+ },
132
+ });
133
+
134
+ shutdown();
135
+ // Allow the shutdown microtask to resolve.
136
+ await new Promise((resolve) => setTimeout(resolve, 10));
137
+
138
+ expect(supervisor.didShutdown).toBe(true);
139
+ expect(completed).toBe(true);
140
+ expect(out.join("")).toContain("shutting down");
141
+ });
142
+ });