@checkstack/scripts 0.1.2 → 0.2.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.
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Backend child-process lifecycle (spawn → restart-on-watch →
3
+ * graceful-shutdown), extracted behind injectable seams so we can drive
4
+ * it from unit tests without launching real processes.
5
+ *
6
+ * Three injectable adapters:
7
+ *
8
+ * - `spawner`: `spawn` the child and return a handle exposing `kill`
9
+ * + an `onExit` hook. Real impl wraps `node:child_process.spawn`;
10
+ * test impls use `EventEmitter`-backed fakes.
11
+ * - `setHardKillTimer` / `clearHardKillTimer`: scheduling for the
12
+ * SIGTERM-→-SIGKILL escalation.
13
+ * - `onLog`: where to send human-readable status lines. Tests collect
14
+ * them into an array; real impl uses `console.log`.
15
+ *
16
+ * The lifecycle's *behavior* is fully captured by these primitives:
17
+ * tests that drive them confirm the same code paths the real runtime
18
+ * exercises.
19
+ */
20
+
21
+ export interface ChildHandle {
22
+ kill(signal: NodeJS.Signals): void;
23
+ onExit(handler: (code: number | null, signal: NodeJS.Signals | null) => void): void;
24
+ }
25
+
26
+ export interface Spawner {
27
+ spawn(input: {
28
+ command: string;
29
+ args: string[];
30
+ env: Record<string, string | undefined>;
31
+ }): ChildHandle;
32
+ }
33
+
34
+ export interface LifecycleHooks {
35
+ /** Called when the child exits naturally (not as part of a restart). */
36
+ onNaturalExit: (code: number | null) => void;
37
+ }
38
+
39
+ export interface BackendLifecycleOptions {
40
+ spawnArgs: { command: string; args: string[]; env: Record<string, string | undefined> };
41
+ spawner: Spawner;
42
+ hooks: LifecycleHooks;
43
+ /** Inject log sink. Defaults to `console.log`. */
44
+ onLog?: (line: string) => void;
45
+ /** Schedule a SIGKILL fallback if SIGTERM doesn't bring the child down. */
46
+ setHardKillTimer?: (fn: () => void, ms: number) => unknown;
47
+ clearHardKillTimer?: (handle: unknown) => void;
48
+ hardKillDelayMs?: number;
49
+ }
50
+
51
+ export interface BackendLifecycle {
52
+ /** Start the child for the first time. */
53
+ start: () => void;
54
+ /** Trigger a restart (SIGTERM + respawn). Idempotent during a restart. */
55
+ restart: () => void;
56
+ /** Forward a shutdown signal to the child. Marks the lifecycle as done. */
57
+ shutdown: (signal: NodeJS.Signals) => void;
58
+ }
59
+
60
+ export function createBackendLifecycle({
61
+ spawnArgs,
62
+ spawner,
63
+ hooks,
64
+ onLog = (line) => console.log(line),
65
+ setHardKillTimer = (fn, ms) => setTimeout(fn, ms),
66
+ clearHardKillTimer = (h) => clearTimeout(h as ReturnType<typeof setTimeout>),
67
+ hardKillDelayMs = 5000,
68
+ }: BackendLifecycleOptions): BackendLifecycle {
69
+ let child: ChildHandle | undefined;
70
+ let restarting = false;
71
+ let shuttingDown = false;
72
+ let hardKillHandle: unknown;
73
+
74
+ const start = () => {
75
+ if (child) return; // start() must be called only once outside a restart
76
+ onLog(`▶ Starting backend (${spawnArgs.command} ${spawnArgs.args.join(" ")})`);
77
+ child = spawner.spawn(spawnArgs);
78
+ child.onExit((code, signal) => {
79
+ child = undefined;
80
+ // The hard-kill timer is no longer relevant once the child is
81
+ // gone — drop it before we possibly schedule another one.
82
+ if (hardKillHandle !== undefined) {
83
+ clearHardKillTimer(hardKillHandle);
84
+ hardKillHandle = undefined;
85
+ }
86
+ if (shuttingDown) return;
87
+ if (restarting) {
88
+ restarting = false;
89
+ // Recursive call into start() is safe: child is undefined here
90
+ // and the function early-returns when it's not.
91
+ spawnAfterRestart();
92
+ return;
93
+ }
94
+ onLog(
95
+ `Backend exited (code=${code ?? "null"}, signal=${signal ?? "none"})`,
96
+ );
97
+ hooks.onNaturalExit(code);
98
+ });
99
+ };
100
+
101
+ // Same as `start()` but issued during a restart, so logging differs
102
+ // and the early-return is bypassed (child has just been cleared).
103
+ const spawnAfterRestart = () => {
104
+ onLog(`▶ Starting backend (${spawnArgs.command} ${spawnArgs.args.join(" ")})`);
105
+ child = spawner.spawn(spawnArgs);
106
+ child.onExit((code, signal) => {
107
+ child = undefined;
108
+ if (hardKillHandle !== undefined) {
109
+ clearHardKillTimer(hardKillHandle);
110
+ hardKillHandle = undefined;
111
+ }
112
+ if (shuttingDown) return;
113
+ if (restarting) {
114
+ restarting = false;
115
+ spawnAfterRestart();
116
+ return;
117
+ }
118
+ onLog(
119
+ `Backend exited (code=${code ?? "null"}, signal=${signal ?? "none"})`,
120
+ );
121
+ hooks.onNaturalExit(code);
122
+ });
123
+ };
124
+
125
+ const restart = () => {
126
+ if (!child || restarting || shuttingDown) return;
127
+ restarting = true;
128
+ onLog("♻ Plugin source changed — restarting backend...");
129
+ child.kill("SIGTERM");
130
+ // Hard-kill fallback: if SIGTERM doesn't bring the child down in
131
+ // `hardKillDelayMs`, escalate. Otherwise the dev loop hangs forever
132
+ // when a plugin's cleanup handler is buggy.
133
+ hardKillHandle = setHardKillTimer(() => {
134
+ if (child && restarting) {
135
+ child.kill("SIGKILL");
136
+ }
137
+ }, hardKillDelayMs);
138
+ };
139
+
140
+ const shutdown = (signal: NodeJS.Signals) => {
141
+ shuttingDown = true;
142
+ if (child) child.kill(signal);
143
+ };
144
+
145
+ return { start, restart, shutdown };
146
+ }
147
+
148
+ /**
149
+ * Real `node:child_process.spawn`-backed Spawner. Production code uses
150
+ * this; tests inject a fake.
151
+ */
152
+ export function nodeSpawner({
153
+ spawnFn,
154
+ }: {
155
+ spawnFn: typeof import("node:child_process").spawn;
156
+ }): Spawner {
157
+ return {
158
+ spawn({ command, args, env }) {
159
+ const proc = spawnFn(command, args, {
160
+ env: env as NodeJS.ProcessEnv,
161
+ stdio: "inherit",
162
+ });
163
+ return {
164
+ kill(signal) {
165
+ proc.kill(signal);
166
+ },
167
+ onExit(handler) {
168
+ proc.on("exit", handler);
169
+ },
170
+ };
171
+ },
172
+ };
173
+ }
@@ -0,0 +1,197 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * `bunx @checkstack/scripts dev` — local Checkstack dev server for plugin
4
+ * authors.
5
+ *
6
+ * Spawns the platform's production backend entry (`@checkstack/backend`)
7
+ * as a child process, with three env vars wiring it into "dev mode":
8
+ *
9
+ * - CHECKSTACK_DEV_PLUGIN_PATH=<cwd>: tells the backend to skip
10
+ * filesystem discovery and load the plugin in this directory as a
11
+ * manual plugin.
12
+ * - CHECKSTACK_DEV_EXTRA_PLUGIN_PATHS=<JSON array>: paths to
13
+ * `@checkstack/*-backend` packages co-loaded as additional manual
14
+ * plugins (resolved by walking the plugin's package.json#deps).
15
+ * - CHECKSTACK_DEV_AUTH=true: bypasses login. Every access rule the
16
+ * platform registers is auto-granted to a synthetic dev user.
17
+ *
18
+ * On every save under the plugin's `src/` (any file), we kill the child
19
+ * and respawn. Bun cold-starts in <1s for a single plugin, so the loop
20
+ * is fast enough for everyday work.
21
+ *
22
+ * The child process is the *real* `core/backend/src/index.ts` — same
23
+ * boot code as production. No alternative dev-only stack to drift from.
24
+ *
25
+ * Pure logic lives in `dev-internals.ts`, lifecycle in `dev-lifecycle.ts`,
26
+ * and Vite spawn in `dev-frontend.ts`. This file only wires real adapters
27
+ * to those building blocks.
28
+ */
29
+ import path from "node:path";
30
+ import fs from "node:fs";
31
+ import { spawn } from "node:child_process";
32
+ import { extractErrorMessage } from "@checkstack/common";
33
+ import { resolveCorePluginDeps } from "./dev-deps-resolver";
34
+ import { startFrontendDevServer } from "./dev-frontend";
35
+ import {
36
+ parseDevArgs,
37
+ validatePluginPackageJson,
38
+ resolveBackendEntry,
39
+ shouldSpawnFrontend,
40
+ buildBackendChildEnv,
41
+ createDebouncedWatcher,
42
+ } from "./dev-internals";
43
+ import { createBackendLifecycle, nodeSpawner } from "./dev-lifecycle";
44
+
45
+ export async function runDevServer(rawArgs: string[]): Promise<number> {
46
+ const args = parseDevArgs({ raw: rawArgs });
47
+ if (args.showHelp) {
48
+ printHelp();
49
+ return 0;
50
+ }
51
+
52
+ // 1. Validate package.json
53
+ const validation = validatePluginPackageJson({ cwd: args.cwd });
54
+ if (!validation.ok) {
55
+ if ("missingPackageJson" in validation) {
56
+ console.error(
57
+ `❌ No package.json found at ${args.cwd}. Run from a plugin's repo root.`,
58
+ );
59
+ } else {
60
+ console.error(
61
+ "❌ Plugin package.json failed install-time validation. The dev server boots the same code path as production, so the metadata must match what `plugin-pack` would accept:",
62
+ );
63
+ for (const issue of validation.issues) {
64
+ console.error(` - ${issue}`);
65
+ }
66
+ }
67
+ return 1;
68
+ }
69
+ const pkg = validation.metadata;
70
+ console.log(
71
+ `🛠 Booting Checkstack dev server for ${pkg.name}@${pkg.version} (${pkg.checkstack.type})`,
72
+ );
73
+
74
+ // 2. Resolve @checkstack/backend entry
75
+ const backendEntry = resolveBackendEntry({ fromCwd: args.cwd });
76
+ if (!backendEntry) {
77
+ console.error(
78
+ "❌ Could not locate @checkstack/backend. Add it to your plugin's devDependencies, or install @checkstack/scripts which depends on it transitively.",
79
+ );
80
+ return 1;
81
+ }
82
+
83
+ // 3. Walk @checkstack/*-backend deps so we co-load the platform
84
+ // plugins the user's plugin needs at runtime.
85
+ const corePluginDeps = resolveCorePluginDeps({ pluginDir: args.cwd });
86
+ if (corePluginDeps.length > 0) {
87
+ console.log(
88
+ `📦 Co-loading ${corePluginDeps.length} core plugin dep${
89
+ corePluginDeps.length === 1 ? "" : "s"
90
+ }: ${corePluginDeps.map((d) => d.name).join(", ")}`,
91
+ );
92
+ }
93
+
94
+ // 4. Build the child env
95
+ const childEnv = buildBackendChildEnv({
96
+ args,
97
+ parentEnv: process.env,
98
+ extraPluginPaths: corePluginDeps.map((d) => d.modulePath),
99
+ });
100
+
101
+ // 5. Wire the backend lifecycle (spawn → restart → shutdown)
102
+ let exitCode: number | null = 0;
103
+ let shutdownResolve!: (code: number) => void;
104
+ const shutdownPromise = new Promise<number>((resolve) => {
105
+ shutdownResolve = resolve;
106
+ });
107
+ const lifecycle = createBackendLifecycle({
108
+ spawnArgs: {
109
+ command: "bun",
110
+ args: ["run", backendEntry],
111
+ env: childEnv,
112
+ },
113
+ spawner: nodeSpawner({ spawnFn: spawn }),
114
+ hooks: {
115
+ onNaturalExit: (code) => {
116
+ exitCode = code;
117
+ shutdownResolve(code ?? 0);
118
+ },
119
+ },
120
+ });
121
+
122
+ // 6. Watcher (debounced) → restart
123
+ const srcDir = path.join(args.cwd, "src");
124
+ if (args.watch && fs.existsSync(srcDir)) {
125
+ const watcher = createDebouncedWatcher({ onTrigger: lifecycle.restart });
126
+ fs.watch(srcDir, { recursive: true }, (_event, filename) => {
127
+ watcher.feed(filename ?? undefined);
128
+ });
129
+ console.log(`👀 Watching ${srcDir} for changes`);
130
+ }
131
+
132
+ // 7. Frontend dev server (Vite) when applicable
133
+ let viteServer:
134
+ | Awaited<ReturnType<typeof startFrontendDevServer>>
135
+ | undefined;
136
+ if (shouldSpawnFrontend(pkg)) {
137
+ try {
138
+ viteServer = await startFrontendDevServer({
139
+ pluginCwd: args.cwd,
140
+ port: args.frontendPort,
141
+ backendUrl: `http://localhost:${args.port}`,
142
+ });
143
+ } catch (error) {
144
+ console.error(
145
+ `⚠ Frontend dev server failed to start: ${extractErrorMessage(error)}`,
146
+ );
147
+ console.error(
148
+ " The backend will still run — your -frontend plugin just won't have HMR.",
149
+ );
150
+ }
151
+ }
152
+
153
+ // 8. Signal forwarding
154
+ const onSignal = (signal: NodeJS.Signals) => {
155
+ lifecycle.shutdown(signal);
156
+ void viteServer?.close();
157
+ setTimeout(() => process.exit(exitCode ?? 0), 200).unref();
158
+ };
159
+ process.on("SIGINT", () => onSignal("SIGINT"));
160
+ process.on("SIGTERM", () => onSignal("SIGTERM"));
161
+
162
+ // 9. Boot
163
+ lifecycle.start();
164
+ return shutdownPromise;
165
+ }
166
+
167
+ function printHelp(): void {
168
+ console.log(String.raw`Usage: checkstack-scripts dev [options]
169
+
170
+ Spins up a local Checkstack dev server with the current directory's
171
+ plugin loaded. Same boot code as production — the only differences are:
172
+ * filesystem discovery is skipped (only your plugin loads)
173
+ * a synthetic dev auth grants every access rule (no login)
174
+ * the backend restarts on changes under ./src
175
+
176
+ Options:
177
+ --cwd <dir> Plugin directory (default: process.cwd())
178
+ --port <num> Backend HTTP port (default: 3000 or $PORT)
179
+ --frontend-port <num> Frontend Vite dev port (default: 5173 or $FRONTEND_PORT)
180
+ --db-url <url> Postgres URL (default: $DATABASE_URL or
181
+ postgresql://checkstack:checkstack@localhost:5432/checkstack)
182
+ --no-watch Disable file watching / auto-restart
183
+ --help, -h Show this message
184
+
185
+ Prerequisites: a running Postgres reachable at --db-url. The simplest
186
+ local setup is:
187
+
188
+ docker run --name checkstack-dev-pg -d -p 5432:5432 \
189
+ -e POSTGRES_USER=checkstack -e POSTGRES_PASSWORD=checkstack \
190
+ -e POSTGRES_DB=checkstack postgres:16-alpine
191
+ `);
192
+ }
193
+
194
+ if (import.meta.main) {
195
+ const code = await runDevServer(process.argv.slice(2));
196
+ process.exit(code);
197
+ }