@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,278 @@
1
+ /**
2
+ * Pure helpers used by `dev-server.ts`. Extracted into their own module
3
+ * so they're trivially unit-testable without spawning real processes,
4
+ * watching real filesystems, or hitting Postgres.
5
+ *
6
+ * Anything in this file is deterministic given its inputs. Side-effecting
7
+ * code (process spawn, fs.watch, vite startup) lives in dev-server.ts
8
+ * proper.
9
+ */
10
+ import path from "node:path";
11
+ import fs from "node:fs";
12
+ import { createRequire } from "node:module";
13
+ import {
14
+ installPackageMetadataSchema,
15
+ type InstallPackageMetadata,
16
+ } from "@checkstack/common";
17
+
18
+ // ─────────────────────────────────────────────────────────────────────
19
+ // Args
20
+ // ─────────────────────────────────────────────────────────────────────
21
+
22
+ export interface DevArgs {
23
+ cwd: string;
24
+ port: number;
25
+ frontendPort: number;
26
+ databaseUrl: string;
27
+ watch: boolean;
28
+ showHelp: boolean;
29
+ }
30
+
31
+ /**
32
+ * Parse the dev command's CLI arguments. Pure: any env-var fallbacks are
33
+ * passed in via `env` so tests can drive them without touching
34
+ * `process.env`.
35
+ */
36
+ export function parseDevArgs({
37
+ raw,
38
+ env = process.env,
39
+ cwd = process.cwd(),
40
+ }: {
41
+ raw: string[];
42
+ env?: Record<string, string | undefined>;
43
+ cwd?: string;
44
+ }): DevArgs {
45
+ const args: DevArgs = {
46
+ cwd,
47
+ port: env.PORT ? Number(env.PORT) : 3000,
48
+ frontendPort: env.FRONTEND_PORT ? Number(env.FRONTEND_PORT) : 5173,
49
+ databaseUrl:
50
+ env.DATABASE_URL ??
51
+ "postgresql://checkstack:checkstack@localhost:5432/checkstack",
52
+ watch: true,
53
+ showHelp: false,
54
+ };
55
+ for (let i = 0; i < raw.length; i++) {
56
+ const a = raw[i];
57
+ switch (a) {
58
+ case "--cwd": {
59
+ args.cwd = path.resolve(raw[++i]);
60
+ break;
61
+ }
62
+ case "--port": {
63
+ args.port = Number(raw[++i]);
64
+ break;
65
+ }
66
+ case "--frontend-port": {
67
+ args.frontendPort = Number(raw[++i]);
68
+ break;
69
+ }
70
+ case "--db-url": {
71
+ args.databaseUrl = raw[++i];
72
+ break;
73
+ }
74
+ case "--no-watch": {
75
+ args.watch = false;
76
+ break;
77
+ }
78
+ case "--help":
79
+ case "-h": {
80
+ args.showHelp = true;
81
+ break;
82
+ }
83
+ }
84
+ }
85
+ return args;
86
+ }
87
+
88
+ // ─────────────────────────────────────────────────────────────────────
89
+ // Plugin metadata validation
90
+ // ─────────────────────────────────────────────────────────────────────
91
+
92
+ export type ValidationResult =
93
+ | { ok: true; metadata: InstallPackageMetadata }
94
+ | { ok: false; missingPackageJson: true }
95
+ | { ok: false; issues: string[] };
96
+
97
+ /**
98
+ * Validate the plugin author's `package.json` against the install-time
99
+ * schema. The dev server runs the same boot code path as production, so
100
+ * it must accept the same metadata that `plugin-pack` produces.
101
+ *
102
+ * The `readFile` injection makes this trivially unit-testable.
103
+ */
104
+ export function validatePluginPackageJson({
105
+ cwd,
106
+ readFile = (p) => fs.readFileSync(p, "utf8"),
107
+ exists = (p) => fs.existsSync(p),
108
+ }: {
109
+ cwd: string;
110
+ readFile?: (p: string) => string;
111
+ exists?: (p: string) => boolean;
112
+ }): ValidationResult {
113
+ const pkgJsonPath = path.join(cwd, "package.json");
114
+ if (!exists(pkgJsonPath)) {
115
+ return { ok: false, missingPackageJson: true };
116
+ }
117
+ const raw = JSON.parse(readFile(pkgJsonPath)) as unknown;
118
+ const result = installPackageMetadataSchema.safeParse(raw);
119
+ if (!result.success) {
120
+ const issues = result.error.issues.map(
121
+ (issue) => `${issue.path.join(".")}: ${issue.message}`,
122
+ );
123
+ return { ok: false, issues };
124
+ }
125
+ return { ok: true, metadata: result.data };
126
+ }
127
+
128
+ // ─────────────────────────────────────────────────────────────────────
129
+ // Backend entry resolution
130
+ // ─────────────────────────────────────────────────────────────────────
131
+
132
+ /**
133
+ * Locate `@checkstack/backend`'s main entry from the plugin author's
134
+ * node_modules so `bun run <entry>` works whether they pulled in
135
+ * `@checkstack/scripts` (which transitively brings backend in) or
136
+ * `@checkstack/backend` directly.
137
+ *
138
+ * Returns `undefined` when the package can't be resolved at all
139
+ * (probably means `bun install` hasn't run yet).
140
+ */
141
+ export function resolveBackendEntry({
142
+ fromCwd,
143
+ resolveFrom,
144
+ readFile = (p) => fs.readFileSync(p, "utf8"),
145
+ }: {
146
+ fromCwd: string;
147
+ /** Optional injection point for tests. Defaults to Node's `createRequire`. */
148
+ resolveFrom?: (from: string, request: string) => string | undefined;
149
+ readFile?: (p: string) => string;
150
+ }): string | undefined {
151
+ const resolver =
152
+ resolveFrom ??
153
+ ((from: string, request: string): string | undefined => {
154
+ try {
155
+ return createRequire(from).resolve(request);
156
+ } catch {
157
+ return undefined;
158
+ }
159
+ });
160
+ const pkgJsonPath = resolver(
161
+ path.join(fromCwd, "package.json"),
162
+ "@checkstack/backend/package.json",
163
+ );
164
+ if (!pkgJsonPath) return undefined;
165
+ let pkg: { main?: string };
166
+ try {
167
+ pkg = JSON.parse(readFile(pkgJsonPath)) as { main?: string };
168
+ } catch {
169
+ return undefined;
170
+ }
171
+ const main = pkg.main ?? "src/index.ts";
172
+ return path.join(path.dirname(pkgJsonPath), main);
173
+ }
174
+
175
+ // ─────────────────────────────────────────────────────────────────────
176
+ // Frontend Vite spawn decision
177
+ // ─────────────────────────────────────────────────────────────────────
178
+
179
+ /**
180
+ * Determine whether the dev command should also spawn a Vite frontend
181
+ * dev server. True when the plugin under dev is itself a `-frontend`,
182
+ * or when it's a bundle primary (e.g. `-backend`) that ships a
183
+ * `-frontend` sibling in `checkstack.bundle`.
184
+ */
185
+ export function shouldSpawnFrontend(
186
+ metadata: Pick<InstallPackageMetadata, "checkstack">,
187
+ ): boolean {
188
+ if (metadata.checkstack.type === "frontend") return true;
189
+ for (const sibling of metadata.checkstack.bundle ?? []) {
190
+ if (sibling.endsWith("-frontend")) return true;
191
+ }
192
+ return false;
193
+ }
194
+
195
+ // ─────────────────────────────────────────────────────────────────────
196
+ // Child-process env construction
197
+ // ─────────────────────────────────────────────────────────────────────
198
+
199
+ /**
200
+ * Build the env block for the backend child process. Wraps the dev-mode
201
+ * env vars + sensible defaults for required production env (DB URL,
202
+ * AUTH_SECRET, …). Inputs are explicit so the test can verify the env
203
+ * var shape without spawning anything.
204
+ */
205
+ export function buildBackendChildEnv({
206
+ args,
207
+ parentEnv,
208
+ extraPluginPaths,
209
+ }: {
210
+ args: DevArgs;
211
+ parentEnv: Record<string, string | undefined>;
212
+ extraPluginPaths: string[];
213
+ }): Record<string, string | undefined> {
214
+ return {
215
+ ...parentEnv,
216
+ CHECKSTACK_DEV_PLUGIN_PATH: args.cwd,
217
+ CHECKSTACK_DEV_EXTRA_PLUGIN_PATHS: JSON.stringify(extraPluginPaths),
218
+ CHECKSTACK_DEV_AUTH: "true",
219
+ PORT: String(args.port),
220
+ DATABASE_URL: args.databaseUrl,
221
+ BASE_URL: parentEnv.BASE_URL ?? `http://localhost:${args.port}`,
222
+ AUTH_SECRET: parentEnv.AUTH_SECRET ?? "checkstack-dev-secret",
223
+ NODE_ENV: parentEnv.NODE_ENV ?? "development",
224
+ };
225
+ }
226
+
227
+ // ─────────────────────────────────────────────────────────────────────
228
+ // Watcher debouncer
229
+ // ─────────────────────────────────────────────────────────────────────
230
+
231
+ /**
232
+ * Tiny event coalescer — multiple file events within the debounce window
233
+ * trigger a single `onTrigger` call. Editors fire several events per
234
+ * save (write a temp file, rename, touch metadata); we want one restart.
235
+ *
236
+ * Returns a function that consumers call once per filesystem event;
237
+ * it filters dotfiles and tilde-suffixed editor swap files so they don't
238
+ * cause spurious restarts.
239
+ *
240
+ * The `setTimer`/`clearTimer` pair is injectable so tests can drive the
241
+ * timer synchronously without sleeping.
242
+ */
243
+ export interface DebouncedWatcher {
244
+ feed(filename: string | undefined): void;
245
+ }
246
+
247
+ export function createDebouncedWatcher({
248
+ onTrigger,
249
+ delayMs = 150,
250
+ setTimer = (fn, ms) => setTimeout(fn, ms) as unknown as TimerHandle,
251
+ clearTimer = (h: TimerHandle) => clearTimeout(h as unknown as Parameters<typeof clearTimeout>[0]),
252
+ }: {
253
+ onTrigger: () => void;
254
+ delayMs?: number;
255
+ setTimer?: (fn: () => void, ms: number) => TimerHandle;
256
+ clearTimer?: (h: TimerHandle) => void;
257
+ }): DebouncedWatcher {
258
+ let pending: TimerHandle | undefined;
259
+ return {
260
+ feed(filename) {
261
+ if (!filename) return;
262
+ // Skip editor temp/swap files and dotfiles. These fire on every
263
+ // save in Vim, VS Code, and IntelliJ and would otherwise restart
264
+ // the backend twice per actual edit.
265
+ if (filename.endsWith("~") || filename.startsWith(".")) return;
266
+ if (pending !== undefined) clearTimer(pending);
267
+ pending = setTimer(() => {
268
+ pending = undefined;
269
+ onTrigger();
270
+ }, delayMs);
271
+ },
272
+ };
273
+ }
274
+
275
+ // Opaque timer handle — `setTimeout`/`clearTimeout` differ in shape
276
+ // between Node, Bun, and the browser. We don't care about the runtime
277
+ // type, only that it round-trips through `setTimer`/`clearTimer`.
278
+ export type TimerHandle = unknown;
@@ -0,0 +1,327 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import {
3
+ createBackendLifecycle,
4
+ type ChildHandle,
5
+ type Spawner,
6
+ } from "./dev-lifecycle";
7
+
8
+ /**
9
+ * Fake child + spawner. Records every spawn, every kill signal, and lets
10
+ * tests synthesize an exit by calling `simulateExit`. The lifecycle code
11
+ * does not care about real I/O — only about the lifecycle hooks
12
+ * (`kill` + `onExit`) — so this is a complete double for it.
13
+ */
14
+ function makeFakeSpawner() {
15
+ interface FakeChild {
16
+ handle: ChildHandle;
17
+ spawnArgs: { command: string; args: string[]; env: Record<string, string | undefined> };
18
+ killSignals: NodeJS.Signals[];
19
+ exitHandlers: Array<
20
+ (code: number | null, signal: NodeJS.Signals | null) => void
21
+ >;
22
+ exited: boolean;
23
+ /** Trigger the registered onExit handlers. */
24
+ simulateExit: (code: number | null, signal?: NodeJS.Signals | null) => void;
25
+ }
26
+ const children: FakeChild[] = [];
27
+
28
+ const spawner: Spawner = {
29
+ spawn(spawnArgs) {
30
+ const fake: FakeChild = {
31
+ handle: undefined as never,
32
+ spawnArgs,
33
+ killSignals: [],
34
+ exitHandlers: [],
35
+ exited: false,
36
+ simulateExit: (code, signal = null) => {
37
+ if (fake.exited) return;
38
+ fake.exited = true;
39
+ for (const handler of fake.exitHandlers) handler(code, signal);
40
+ },
41
+ };
42
+ fake.handle = {
43
+ kill: (signal: NodeJS.Signals) => {
44
+ fake.killSignals.push(signal);
45
+ },
46
+ onExit: (handler) => {
47
+ fake.exitHandlers.push(handler);
48
+ },
49
+ };
50
+ children.push(fake);
51
+ return fake.handle;
52
+ },
53
+ };
54
+ return { spawner, children };
55
+ }
56
+
57
+ /**
58
+ * Mini fake clock for the SIGKILL escalation timer. Tracks scheduled
59
+ * callbacks; tests advance time explicitly. We do not use a real
60
+ * setTimeout because we don't want to wait 5s in unit tests.
61
+ */
62
+ function makeFakeClock() {
63
+ interface T {
64
+ handle: number;
65
+ fireAt: number;
66
+ fn: () => void;
67
+ }
68
+ const scheduled = new Map<number, T>();
69
+ let now = 0;
70
+ let nextHandle = 1;
71
+ return {
72
+ setTimer: (fn: () => void, ms: number) => {
73
+ const handle = nextHandle++;
74
+ scheduled.set(handle, { handle, fireAt: now + ms, fn });
75
+ return handle;
76
+ },
77
+ clearTimer: (h: unknown) => {
78
+ scheduled.delete(h as number);
79
+ },
80
+ advance: (ms: number) => {
81
+ now += ms;
82
+ const due = [...scheduled.values()]
83
+ .filter((t) => t.fireAt <= now)
84
+ .toSorted((a, b) => a.fireAt - b.fireAt);
85
+ for (const t of due) {
86
+ scheduled.delete(t.handle);
87
+ t.fn();
88
+ }
89
+ },
90
+ pending: () => [...scheduled.values()],
91
+ };
92
+ }
93
+
94
+ const SPAWN_ARGS = {
95
+ command: "bun",
96
+ args: ["run", "/path/to/backend/src/index.ts"],
97
+ env: { CHECKSTACK_DEV_AUTH: "true" },
98
+ };
99
+
100
+ describe("createBackendLifecycle", () => {
101
+ it("start() spawns exactly one child with the given args", () => {
102
+ const { spawner, children } = makeFakeSpawner();
103
+ const lc = createBackendLifecycle({
104
+ spawnArgs: SPAWN_ARGS,
105
+ spawner,
106
+ hooks: { onNaturalExit: () => {} },
107
+ onLog: () => {},
108
+ });
109
+ lc.start();
110
+ expect(children).toHaveLength(1);
111
+ expect(children[0].spawnArgs).toEqual(SPAWN_ARGS);
112
+ });
113
+
114
+ it("start() is idempotent — calling twice doesn't double-spawn", () => {
115
+ const { spawner, children } = makeFakeSpawner();
116
+ const lc = createBackendLifecycle({
117
+ spawnArgs: SPAWN_ARGS,
118
+ spawner,
119
+ hooks: { onNaturalExit: () => {} },
120
+ onLog: () => {},
121
+ });
122
+ lc.start();
123
+ lc.start();
124
+ expect(children).toHaveLength(1);
125
+ });
126
+
127
+ it("natural exit invokes onNaturalExit hook with the exit code", () => {
128
+ const { spawner, children } = makeFakeSpawner();
129
+ let capturedCode: number | null | undefined;
130
+ const lc = createBackendLifecycle({
131
+ spawnArgs: SPAWN_ARGS,
132
+ spawner,
133
+ hooks: {
134
+ onNaturalExit: (code) => {
135
+ capturedCode = code;
136
+ },
137
+ },
138
+ onLog: () => {},
139
+ });
140
+ lc.start();
141
+ children[0].simulateExit(0);
142
+ expect(capturedCode).toBe(0);
143
+ });
144
+
145
+ it("restart() sends SIGTERM to the running child and respawns after exit", () => {
146
+ const { spawner, children } = makeFakeSpawner();
147
+ const lc = createBackendLifecycle({
148
+ spawnArgs: SPAWN_ARGS,
149
+ spawner,
150
+ hooks: { onNaturalExit: () => {} },
151
+ onLog: () => {},
152
+ });
153
+ lc.start();
154
+ expect(children).toHaveLength(1);
155
+
156
+ lc.restart();
157
+ expect(children[0].killSignals).toEqual(["SIGTERM"]);
158
+ expect(children).toHaveLength(1); // not yet — child must exit first
159
+
160
+ // Simulate the child cleaning up and exiting after SIGTERM
161
+ children[0].simulateExit(null, "SIGTERM");
162
+ expect(children).toHaveLength(2);
163
+ expect(children[1].spawnArgs).toEqual(SPAWN_ARGS);
164
+ });
165
+
166
+ it("restart() during an in-flight restart is a no-op (idempotent)", () => {
167
+ const { spawner, children } = makeFakeSpawner();
168
+ const lc = createBackendLifecycle({
169
+ spawnArgs: SPAWN_ARGS,
170
+ spawner,
171
+ hooks: { onNaturalExit: () => {} },
172
+ onLog: () => {},
173
+ });
174
+ lc.start();
175
+ lc.restart();
176
+ lc.restart(); // double-fire while still restarting
177
+ lc.restart();
178
+ expect(children[0].killSignals).toEqual(["SIGTERM"]); // still just one
179
+ });
180
+
181
+ it("multiple sequential restarts work — child cycles through", () => {
182
+ const { spawner, children } = makeFakeSpawner();
183
+ const lc = createBackendLifecycle({
184
+ spawnArgs: SPAWN_ARGS,
185
+ spawner,
186
+ hooks: { onNaturalExit: () => {} },
187
+ onLog: () => {},
188
+ });
189
+ lc.start();
190
+ // Restart 1
191
+ lc.restart();
192
+ children[0].simulateExit(null, "SIGTERM");
193
+ // Restart 2
194
+ lc.restart();
195
+ children[1].simulateExit(null, "SIGTERM");
196
+ expect(children).toHaveLength(3);
197
+ });
198
+
199
+ it("exit during natural shutdown after restart() is treated as a restart", () => {
200
+ // i.e. the lifecycle did issue the SIGTERM, the child took time but
201
+ // exited cleanly with code 0 — we should still spawn the replacement
202
+ // because the user asked for a restart.
203
+ const { spawner, children } = makeFakeSpawner();
204
+ const naturalExits: Array<number | null> = [];
205
+ const lc = createBackendLifecycle({
206
+ spawnArgs: SPAWN_ARGS,
207
+ spawner,
208
+ hooks: { onNaturalExit: (code) => naturalExits.push(code) },
209
+ onLog: () => {},
210
+ });
211
+ lc.start();
212
+ lc.restart();
213
+ children[0].simulateExit(0);
214
+ expect(children).toHaveLength(2);
215
+ expect(naturalExits).toEqual([]);
216
+ });
217
+
218
+ it("hard-kill timer fires SIGKILL when SIGTERM doesn't bring the child down in time", () => {
219
+ const { spawner, children } = makeFakeSpawner();
220
+ const clock = makeFakeClock();
221
+ const lc = createBackendLifecycle({
222
+ spawnArgs: SPAWN_ARGS,
223
+ spawner,
224
+ hooks: { onNaturalExit: () => {} },
225
+ onLog: () => {},
226
+ setHardKillTimer: clock.setTimer,
227
+ clearHardKillTimer: clock.clearTimer,
228
+ hardKillDelayMs: 5000,
229
+ });
230
+ lc.start();
231
+ lc.restart();
232
+ expect(children[0].killSignals).toEqual(["SIGTERM"]);
233
+ expect(clock.pending()).toHaveLength(1);
234
+
235
+ // Advance past the hard-kill threshold without simulating exit.
236
+ clock.advance(5000);
237
+ expect(children[0].killSignals).toEqual(["SIGTERM", "SIGKILL"]);
238
+
239
+ // Eventually the OS does kill it. The replacement should still spawn.
240
+ children[0].simulateExit(137, "SIGKILL");
241
+ expect(children).toHaveLength(2);
242
+ });
243
+
244
+ it("hard-kill timer is cleared if the child exits before the deadline", () => {
245
+ const { spawner, children } = makeFakeSpawner();
246
+ const clock = makeFakeClock();
247
+ const lc = createBackendLifecycle({
248
+ spawnArgs: SPAWN_ARGS,
249
+ spawner,
250
+ hooks: { onNaturalExit: () => {} },
251
+ onLog: () => {},
252
+ setHardKillTimer: clock.setTimer,
253
+ clearHardKillTimer: clock.clearTimer,
254
+ hardKillDelayMs: 5000,
255
+ });
256
+ lc.start();
257
+ lc.restart();
258
+ children[0].simulateExit(null, "SIGTERM");
259
+ // Replacement should be running with no leftover hard-kill timer
260
+ expect(clock.pending()).toHaveLength(0);
261
+ expect(children).toHaveLength(2);
262
+ });
263
+
264
+ it("shutdown(signal) forwards the signal and prevents the natural-exit hook from triggering a respawn loop", () => {
265
+ const { spawner, children } = makeFakeSpawner();
266
+ let naturalExits = 0;
267
+ const lc = createBackendLifecycle({
268
+ spawnArgs: SPAWN_ARGS,
269
+ spawner,
270
+ hooks: { onNaturalExit: () => naturalExits++ },
271
+ onLog: () => {},
272
+ });
273
+ lc.start();
274
+ lc.shutdown("SIGINT");
275
+ expect(children[0].killSignals).toEqual(["SIGINT"]);
276
+ children[0].simulateExit(0, "SIGINT");
277
+ // Natural exit hook NOT called during a shutdown — process.exit
278
+ // happens via the parent's signal handler instead.
279
+ expect(naturalExits).toBe(0);
280
+ expect(children).toHaveLength(1); // no respawn
281
+ });
282
+
283
+ it("shutdown() called before start() is a safe no-op", () => {
284
+ const { spawner, children } = makeFakeSpawner();
285
+ const lc = createBackendLifecycle({
286
+ spawnArgs: SPAWN_ARGS,
287
+ spawner,
288
+ hooks: { onNaturalExit: () => {} },
289
+ onLog: () => {},
290
+ });
291
+ lc.shutdown("SIGTERM");
292
+ expect(children).toHaveLength(0);
293
+ });
294
+
295
+ it("restart() after shutdown() is ignored (we're already shutting down)", () => {
296
+ const { spawner, children } = makeFakeSpawner();
297
+ const lc = createBackendLifecycle({
298
+ spawnArgs: SPAWN_ARGS,
299
+ spawner,
300
+ hooks: { onNaturalExit: () => {} },
301
+ onLog: () => {},
302
+ });
303
+ lc.start();
304
+ lc.shutdown("SIGTERM");
305
+ lc.restart();
306
+ // Only the shutdown signal made it to the child
307
+ expect(children[0].killSignals).toEqual(["SIGTERM"]);
308
+ });
309
+
310
+ it("logs key lifecycle events through the injected onLog", () => {
311
+ const { spawner, children } = makeFakeSpawner();
312
+ const logs: string[] = [];
313
+ const lc = createBackendLifecycle({
314
+ spawnArgs: SPAWN_ARGS,
315
+ spawner,
316
+ hooks: { onNaturalExit: () => {} },
317
+ onLog: (line) => logs.push(line),
318
+ });
319
+ lc.start();
320
+ lc.restart();
321
+ children[0].simulateExit(null, "SIGTERM");
322
+ expect(logs.some((l) => l.includes("Starting backend"))).toBe(true);
323
+ expect(logs.some((l) => l.includes("Plugin source changed"))).toBe(true);
324
+ // After respawn, we log the start line again
325
+ expect(logs.filter((l) => l.includes("Starting backend"))).toHaveLength(2);
326
+ });
327
+ });