@checkstack/scripts 0.2.0 → 0.3.1
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 +4 -10
- package/src/cli.ts +0 -11
- package/src/templates/backend/package.json.hbs +3 -1
- package/src/templates/frontend/package.json.hbs +3 -1
- package/src/templates.test.ts +36 -18
- package/src/commands/dev-deps-resolver.test.ts +0 -411
- package/src/commands/dev-deps-resolver.ts +0 -215
- package/src/commands/dev-frontend.test.ts +0 -148
- package/src/commands/dev-frontend.ts +0 -198
- package/src/commands/dev-internals.test.ts +0 -506
- package/src/commands/dev-internals.ts +0 -278
- package/src/commands/dev-lifecycle.test.ts +0 -327
- package/src/commands/dev-lifecycle.ts +0 -173
- package/src/commands/dev-server.ts +0 -197
|
@@ -1,173 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,197 +0,0 @@
|
|
|
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
|
-
}
|