@keel_flow/cli 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.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +170 -0
  3. package/dist/build.d.ts +57 -0
  4. package/dist/build.d.ts.map +1 -0
  5. package/dist/build.js +350 -0
  6. package/dist/build.js.map +1 -0
  7. package/dist/claude-auth.d.ts +16 -0
  8. package/dist/claude-auth.d.ts.map +1 -0
  9. package/dist/claude-auth.js +75 -0
  10. package/dist/claude-auth.js.map +1 -0
  11. package/dist/doctor.d.ts +18 -0
  12. package/dist/doctor.d.ts.map +1 -0
  13. package/dist/doctor.js +547 -0
  14. package/dist/doctor.js.map +1 -0
  15. package/dist/git.d.ts +4 -0
  16. package/dist/git.d.ts.map +1 -0
  17. package/dist/git.js +138 -0
  18. package/dist/git.js.map +1 -0
  19. package/dist/goals.d.ts +34 -0
  20. package/dist/goals.d.ts.map +1 -0
  21. package/dist/goals.js +218 -0
  22. package/dist/goals.js.map +1 -0
  23. package/dist/index.d.ts +3 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +1015 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/kb-reindex.d.ts +31 -0
  28. package/dist/kb-reindex.d.ts.map +1 -0
  29. package/dist/kb-reindex.js +71 -0
  30. package/dist/kb-reindex.js.map +1 -0
  31. package/dist/keys.d.ts +26 -0
  32. package/dist/keys.d.ts.map +1 -0
  33. package/dist/keys.js +209 -0
  34. package/dist/keys.js.map +1 -0
  35. package/dist/learn.d.ts +37 -0
  36. package/dist/learn.d.ts.map +1 -0
  37. package/dist/learn.js +274 -0
  38. package/dist/learn.js.map +1 -0
  39. package/dist/lifecycle.d.ts +27 -0
  40. package/dist/lifecycle.d.ts.map +1 -0
  41. package/dist/lifecycle.js +193 -0
  42. package/dist/lifecycle.js.map +1 -0
  43. package/dist/load-ts.d.ts +2 -0
  44. package/dist/load-ts.d.ts.map +1 -0
  45. package/dist/load-ts.js +20 -0
  46. package/dist/load-ts.js.map +1 -0
  47. package/dist/map-repo.d.ts +15 -0
  48. package/dist/map-repo.d.ts.map +1 -0
  49. package/dist/map-repo.js +36 -0
  50. package/dist/map-repo.js.map +1 -0
  51. package/dist/memory.d.ts +23 -0
  52. package/dist/memory.d.ts.map +1 -0
  53. package/dist/memory.js +84 -0
  54. package/dist/memory.js.map +1 -0
  55. package/dist/orchestrate.d.ts +61 -0
  56. package/dist/orchestrate.d.ts.map +1 -0
  57. package/dist/orchestrate.js +556 -0
  58. package/dist/orchestrate.js.map +1 -0
  59. package/dist/reflect.d.ts +12 -0
  60. package/dist/reflect.d.ts.map +1 -0
  61. package/dist/reflect.js +67 -0
  62. package/dist/reflect.js.map +1 -0
  63. package/dist/report.d.ts +3 -0
  64. package/dist/report.d.ts.map +1 -0
  65. package/dist/report.js +56 -0
  66. package/dist/report.js.map +1 -0
  67. package/dist/rules.d.ts +16 -0
  68. package/dist/rules.d.ts.map +1 -0
  69. package/dist/rules.js +65 -0
  70. package/dist/rules.js.map +1 -0
  71. package/dist/telemetry-helper.d.ts +12 -0
  72. package/dist/telemetry-helper.d.ts.map +1 -0
  73. package/dist/telemetry-helper.js +40 -0
  74. package/dist/telemetry-helper.js.map +1 -0
  75. package/dist/up.d.ts +97 -0
  76. package/dist/up.d.ts.map +1 -0
  77. package/dist/up.js +656 -0
  78. package/dist/up.js.map +1 -0
  79. package/package.json +58 -0
package/dist/up.js ADDED
@@ -0,0 +1,656 @@
1
+ import { execSync, spawn } from "child_process";
2
+ import { existsSync } from "fs";
3
+ import * as nodeFs from "node:fs";
4
+ import { createServer } from "node:net";
5
+ import { join, dirname } from "path";
6
+ import { fileURLToPath } from "url";
7
+ import pc from "picocolors";
8
+ import { ensureEncryptionKey } from "@keel_flow/setup/server";
9
+ import { dockerContainerStatus, listLocks, readLock, removeLock, stopDockerContainer, stopListenersOnPorts, writeLock, } from "./lifecycle.js";
10
+ import { CLAUDE_INSTALL_HINT, probeClaudeAuth, resolveProviderKind, triggerClaudeLogin, } from "./claude-auth.js";
11
+ // claude-bridge login preflight for `keel up`. When the default keyless provider
12
+ // is in effect, make sure the user is signed in to their Claude subscription
13
+ // BEFORE booting — otherwise the /chat agent boots dead at its first LLM call.
14
+ // Never hard-blocks the workspace (verify/map/UI still work without it); it
15
+ // auto-triggers the browser sign-in only on an interactive TTY, and otherwise
16
+ // prints the exact recovery command. Honors --no-login / CLAUDE_CODE_OAUTH_TOKEN.
17
+ async function claudeBridgeLoginPreflight(opts) {
18
+ if (resolveProviderKind() !== "claude-bridge")
19
+ return; // user picked a keyed provider
20
+ if (process.env["CLAUDE_CODE_OAUTH_TOKEN"])
21
+ return; // headless token already provides auth
22
+ const auth = probeClaudeAuth();
23
+ if (auth.state === "authed") {
24
+ process.stdout.write(pc.dim(` Claude subscription: signed in${auth.subscriptionType ? ` (${auth.subscriptionType})` : ""}\n`));
25
+ return;
26
+ }
27
+ if (auth.state === "cli-missing") {
28
+ process.stdout.write(pc.yellow(` ${CLAUDE_INSTALL_HINT}\n`) +
29
+ pc.dim(" (the workspace boots either way — the chat agent needs sign-in to answer)\n"));
30
+ return;
31
+ }
32
+ // logged-out
33
+ if (opts.skipLogin || !process.stdout.isTTY) {
34
+ process.stdout.write(pc.yellow(" not signed in to Claude — run `keel login` to enable the agent (or set CLAUDE_CODE_OAUTH_TOKEN)\n"));
35
+ return;
36
+ }
37
+ process.stdout.write(pc.cyan(" signing in to your Claude subscription (no API key needed)...\n"));
38
+ triggerClaudeLogin("browser");
39
+ }
40
+ const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
41
+ // Only KEEL_TOKEN_ENCRYPTION_KEY is genuinely needed at boot — it encrypts the
42
+ // provider secrets stored per workspace. ANTHROPIC_API_KEY is NOT a boot
43
+ // requirement: the default interactive surface is claude-bridge (Claude
44
+ // subscription auth, no API key) and OpenAI-compatible providers bring their own
45
+ // key, so warning about a missing ANTHROPIC_API_KEY on every boot was a false
46
+ // alarm that undercut the "just works" feel. A genuinely-missing Anthropic key
47
+ // now surfaces lazily, at the point an AnthropicProvider is actually constructed.
48
+ const REQUIRED_KEYS_AT_BOOT = ["KEEL_TOKEN_ENCRYPTION_KEY"];
49
+ export function parseDotEnvContent(content) {
50
+ const result = {};
51
+ for (const line of content.split("\n")) {
52
+ const trimmed = line.trim();
53
+ if (!trimmed || trimmed.startsWith("#"))
54
+ continue;
55
+ const eqIdx = trimmed.indexOf("=");
56
+ if (eqIdx < 1)
57
+ continue;
58
+ const key = trimmed.slice(0, eqIdx).trim();
59
+ const val = trimmed.slice(eqIdx + 1).trim();
60
+ if (!key)
61
+ continue;
62
+ result[key] = val;
63
+ }
64
+ return result;
65
+ }
66
+ export function loadDotEnv(repoRoot) {
67
+ const envPath = join(repoRoot, ".env");
68
+ if (!nodeFs.existsSync(envPath))
69
+ return;
70
+ let raw;
71
+ try {
72
+ raw = nodeFs.readFileSync(envPath, "utf8");
73
+ }
74
+ catch {
75
+ return;
76
+ }
77
+ const parsed = parseDotEnvContent(raw);
78
+ for (const [key, val] of Object.entries(parsed)) {
79
+ if (!(key in process.env)) {
80
+ process.env[key] = val;
81
+ }
82
+ }
83
+ }
84
+ export function warnMissingKeys() {
85
+ for (const key of REQUIRED_KEYS_AT_BOOT) {
86
+ if (!process.env[key]) {
87
+ process.stderr.write(pc.yellow(` warning: ${key} is not set — some features may not work\n`));
88
+ }
89
+ }
90
+ }
91
+ export const DEFAULTS = {
92
+ API_PORT: 3001,
93
+ UI_PORT: 3000,
94
+ DB_PORT: 5432,
95
+ DB_USER: "keel",
96
+ DB_PASSWORD: "keel_local",
97
+ DB_NAME: "keel",
98
+ DOCKER_CONTAINER: "keel-postgres-up",
99
+ HEALTH_TIMEOUT_MS: 60_000,
100
+ HEALTH_POLL_INTERVAL_MS: 1_000,
101
+ };
102
+ // Parse user/password/database out of a Postgres connection string. Any
103
+ // component the URL omits comes back undefined so the caller can fall back to a
104
+ // default. Tolerates both the postgres:// and postgresql:// schemes and
105
+ // percent-encoded credentials; on an unparseable string it returns all-empty so
106
+ // the caller keeps its existing defaults rather than throwing.
107
+ export function parsePgConnectionString(connectionString) {
108
+ try {
109
+ const url = new URL(connectionString);
110
+ const database = url.pathname.replace(/^\//, "");
111
+ return {
112
+ user: url.username ? decodeURIComponent(url.username) : undefined,
113
+ password: url.password ? decodeURIComponent(url.password) : undefined,
114
+ database: database ? decodeURIComponent(database) : undefined,
115
+ };
116
+ }
117
+ catch {
118
+ return {};
119
+ }
120
+ }
121
+ export function resolveConfig() {
122
+ const apiPort = parseInt(process.env["KEEL_API_PORT"] ?? String(DEFAULTS.API_PORT), 10);
123
+ const uiPort = parseInt(process.env["KEEL_UI_PORT"] ?? String(DEFAULTS.UI_PORT), 10);
124
+ const dbPort = parseInt(process.env["KEEL_DB_PORT"] ?? String(DEFAULTS.DB_PORT), 10);
125
+ // Default credentials used to BOTH initialize the managed container and build
126
+ // the connection string when no explicit URL is supplied.
127
+ const defaultUser = process.env["POSTGRES_USER"] ?? DEFAULTS.DB_USER;
128
+ const defaultPassword = process.env["POSTGRES_PASSWORD"] ?? DEFAULTS.DB_PASSWORD;
129
+ const defaultName = process.env["POSTGRES_DB"] ?? DEFAULTS.DB_NAME;
130
+ const databaseUrl = process.env["KEEL_DATABASE_URL"] ?? process.env["DATABASE_URL"] ??
131
+ `postgresql://${defaultUser}:${defaultPassword}@localhost:${dbPort}/${defaultName}`;
132
+ // Single source of truth: when an explicit DATABASE_URL/KEEL_DATABASE_URL is
133
+ // present, the managed Postgres container must be INITIALIZED with the same
134
+ // user/password/db the migration and API will later CONNECT with. Previously
135
+ // the container creds came from POSTGRES_* (defaulting to keel/keel_local)
136
+ // while the connection string came from the URL, so a custom password in the
137
+ // URL without a matching POSTGRES_PASSWORD initialized the container with one
138
+ // password and then failed auth with another (keel up: migration "role/
139
+ // password" failure on a fresh container). Derive the container creds from the
140
+ // resolved URL so the two can never diverge; the default URL parses back to
141
+ // the defaults, so the no-override path is unchanged.
142
+ const parsed = parsePgConnectionString(databaseUrl);
143
+ const dbUser = parsed.user ?? defaultUser;
144
+ const dbPassword = parsed.password ?? defaultPassword;
145
+ const dbName = parsed.database ?? defaultName;
146
+ const apiUrl = `http://localhost:${apiPort}`;
147
+ const uiUrl = `http://localhost:${uiPort}`;
148
+ return {
149
+ apiPort,
150
+ uiPort,
151
+ dbPort,
152
+ dbUser,
153
+ dbPassword,
154
+ dbName,
155
+ databaseUrl,
156
+ apiUrl,
157
+ uiUrl,
158
+ dockerContainer: process.env["KEEL_DOCKER_CONTAINER"] ?? DEFAULTS.DOCKER_CONTAINER,
159
+ repoRoot: process.env["KEEL_REPO_ROOT"] ?? REPO_ROOT,
160
+ };
161
+ }
162
+ export function checkWorkspacePreflight(repoRoot) {
163
+ const migrateScript = join(repoRoot, "apps", "api", "scripts", "migrate.mjs");
164
+ const workspaceDir = join(repoRoot, "apps", "workspace");
165
+ if (!existsSync(migrateScript) || !existsSync(workspaceDir)) {
166
+ process.stderr.write(pc.red(`\nkeel up — workspace preflight failed\n\n`) +
167
+ pc.yellow(` keel up must run from a Keel monorepo checkout\n`) +
168
+ pc.yellow(` (or set KEEL_REPO_ROOT to point at one).\n`) +
169
+ pc.yellow(` This is not the same as the globally-installed subcommands.\n`) +
170
+ pc.dim(`\n Expected to find:\n`) +
171
+ pc.dim(` ${migrateScript}\n`) +
172
+ pc.dim(` ${workspaceDir}\n`) +
173
+ pc.dim(`\nFix one of the above, then re-run: keel up\n\n`));
174
+ process.exit(1);
175
+ }
176
+ }
177
+ export function detectDocker() {
178
+ try {
179
+ execSync("docker info --format '{{.ServerVersion}}'", { stdio: "pipe" });
180
+ return "running";
181
+ }
182
+ catch {
183
+ try {
184
+ execSync("docker --version", { stdio: "pipe" });
185
+ return "not-running";
186
+ }
187
+ catch {
188
+ return "unavailable";
189
+ }
190
+ }
191
+ }
192
+ export function hasExternalDb() {
193
+ return Boolean(process.env["DATABASE_URL"] ?? process.env["KEEL_DATABASE_URL"]);
194
+ }
195
+ export function preflightFail(reason) {
196
+ process.stderr.write(pc.red(`\nkeel up — database preflight failed\n\n`) +
197
+ reason +
198
+ pc.dim(`\n\nFix one of the above, then re-run: keel up\n\n`));
199
+ process.exit(1);
200
+ }
201
+ export function checkPreflight() {
202
+ if (hasExternalDb())
203
+ return "external-db";
204
+ const dockerStatus = detectDocker();
205
+ if (dockerStatus === "running")
206
+ return "docker";
207
+ if (dockerStatus === "not-running") {
208
+ preflightFail(pc.yellow("Docker is installed but the daemon is not running.\n") +
209
+ pc.yellow(" → Start Docker Desktop (or run: sudo systemctl start docker) and retry.\n"));
210
+ }
211
+ preflightFail(pc.yellow("No database available. Keel requires one of:\n\n") +
212
+ pc.cyan(" Option A — install Docker\n") +
213
+ pc.dim(" https://docs.docker.com/get-docker/\n\n") +
214
+ pc.cyan(" Option B — point to an existing Postgres+pgvector instance\n") +
215
+ pc.dim(" export DATABASE_URL=postgresql://user:pass@host:5432/keel\n"));
216
+ }
217
+ export const defaultSpawner = (cmd, args, opts) => spawn(cmd, args, { stdio: "inherit", ...opts });
218
+ export async function startPostgres(config, spawner = defaultSpawner) {
219
+ const existing = (() => {
220
+ try {
221
+ const out = execSync(`docker inspect --format '{{.State.Status}}' ${config.dockerContainer}`, { stdio: "pipe" }).toString().trim();
222
+ return out;
223
+ }
224
+ catch {
225
+ return null;
226
+ }
227
+ })();
228
+ if (existing === "running") {
229
+ process.stdout.write(pc.dim(` postgres container ${config.dockerContainer} already running\n`));
230
+ return;
231
+ }
232
+ if (existing === "exited" || existing === "created") {
233
+ process.stdout.write(pc.dim(` restarting container ${config.dockerContainer}\n`));
234
+ try {
235
+ execSync(`docker start ${config.dockerContainer}`, { stdio: "pipe" });
236
+ }
237
+ catch (err) {
238
+ throw new Error(`Failed to restart container ${config.dockerContainer}: ${String(err)}\n` +
239
+ `Try removing it manually with: docker rm ${config.dockerContainer}`);
240
+ }
241
+ return;
242
+ }
243
+ process.stdout.write(pc.dim(` starting pgvector container ${config.dockerContainer}\n`));
244
+ const proc = spawner("docker", [
245
+ "run",
246
+ "--name", config.dockerContainer,
247
+ "-e", `POSTGRES_USER=${config.dbUser}`,
248
+ "-e", `POSTGRES_PASSWORD=${config.dbPassword}`,
249
+ "-e", `POSTGRES_DB=${config.dbName}`,
250
+ "-p", `${config.dbPort}:5432`,
251
+ "-d",
252
+ "pgvector/pgvector:pg16",
253
+ ], { env: process.env });
254
+ await new Promise((resolve, reject) => {
255
+ proc.on("exit", (code) => {
256
+ if (code === 0)
257
+ resolve();
258
+ else
259
+ reject(new Error(`docker run exited with code ${code}`));
260
+ });
261
+ proc.on("error", reject);
262
+ });
263
+ }
264
+ export function isPortFree(port) {
265
+ return new Promise((resolve) => {
266
+ const probe = createServer();
267
+ probe.once("error", () => resolve(false));
268
+ probe.once("listening", () => probe.close(() => resolve(true)));
269
+ probe.listen(port, "0.0.0.0");
270
+ });
271
+ }
272
+ export async function assertPortsFree(ports) {
273
+ const taken = [];
274
+ for (const entry of ports) {
275
+ if (!(await isPortFree(entry.port)))
276
+ taken.push(entry);
277
+ }
278
+ if (taken.length === 0)
279
+ return;
280
+ const lines = taken
281
+ .map((entry) => ` port ${entry.port} (${entry.label}) is already in use by another process.\n` +
282
+ ` Find it: lsof -nP -iTCP:${entry.port} -sTCP:LISTEN\n` +
283
+ ` Or pick a different port: export ${entry.envVar}=<port>\n`)
284
+ .join("");
285
+ throw new Error(`keel up cannot start — required ports are taken:\n${lines}` +
286
+ ` Starting anyway would health-check a server keel up does not own.`);
287
+ }
288
+ export async function pollUrl(url, timeoutMs = DEFAULTS.HEALTH_TIMEOUT_MS, intervalMs = DEFAULTS.HEALTH_POLL_INTERVAL_MS, waitingLabel) {
289
+ const start = Date.now();
290
+ const deadline = start + timeoutMs;
291
+ let hintShown = false;
292
+ while (Date.now() < deadline) {
293
+ try {
294
+ const res = await fetch(url, { signal: AbortSignal.timeout(2000) });
295
+ if (res.ok)
296
+ return;
297
+ }
298
+ catch {
299
+ }
300
+ // Liveness during a long wait: a cold Next.js compile can take ~30s, and a
301
+ // single static "polling ..." line with no follow-up reads as a hang. After
302
+ // ~5s emit one reassurance — TTY only, so piped/CI output stays clean.
303
+ if (!hintShown && waitingLabel && process.stdout.isTTY && Date.now() - start > 5000) {
304
+ hintShown = true;
305
+ process.stdout.write(pc.dim(` still waiting for ${waitingLabel} (first run can take ~30s)...\n`));
306
+ }
307
+ await new Promise((r) => setTimeout(r, intervalMs));
308
+ }
309
+ throw new Error(`Timed out waiting for ${url} to become healthy (${timeoutMs}ms)`);
310
+ }
311
+ export async function waitForPostgres(config, timeoutMs = DEFAULTS.HEALTH_TIMEOUT_MS) {
312
+ process.stdout.write(pc.dim(" waiting for postgres...\n"));
313
+ const deadline = Date.now() + timeoutMs;
314
+ while (Date.now() < deadline) {
315
+ try {
316
+ execSync(`docker exec ${config.dockerContainer} pg_isready -U ${config.dbUser} -d ${config.dbName}`, { stdio: "pipe" });
317
+ return;
318
+ }
319
+ catch {
320
+ }
321
+ await new Promise((r) => setTimeout(r, 1000));
322
+ }
323
+ throw new Error(`Postgres container did not become ready within ${timeoutMs}ms`);
324
+ }
325
+ export function buildApiEnv(config) {
326
+ return {
327
+ ...process.env,
328
+ DATABASE_URL: config.databaseUrl,
329
+ PORT: String(config.apiPort),
330
+ CORS_ORIGIN: config.uiUrl,
331
+ KEEL_SINGLE_USER_MODE: process.env["KEEL_SINGLE_USER_MODE"] ?? "true",
332
+ };
333
+ }
334
+ export function buildUiEnv(config) {
335
+ return {
336
+ ...process.env,
337
+ PORT: String(config.uiPort),
338
+ KEEL_API_URL: config.apiUrl,
339
+ NEXT_PUBLIC_API_URL: config.apiUrl,
340
+ KEEL_SINGLE_USER_MODE: process.env["KEEL_SINGLE_USER_MODE"] ?? "true",
341
+ };
342
+ }
343
+ export async function runMigrationsViaScript(config, spawner = defaultSpawner) {
344
+ process.stdout.write(pc.dim(" running migrations...\n"));
345
+ const scriptPath = join(config.repoRoot, "apps", "api", "scripts", "migrate.mjs");
346
+ if (!existsSync(scriptPath)) {
347
+ throw new Error(`Migration script not found at ${scriptPath}`);
348
+ }
349
+ const proc = spawner("node", [scriptPath], {
350
+ env: { ...process.env, DATABASE_URL: config.databaseUrl },
351
+ cwd: join(config.repoRoot, "apps", "api"),
352
+ });
353
+ await new Promise((resolve, reject) => {
354
+ proc.on("exit", (code) => {
355
+ if (code === 0)
356
+ resolve();
357
+ else
358
+ reject(new Error(`Migration script exited with code ${code}`));
359
+ });
360
+ proc.on("error", reject);
361
+ });
362
+ }
363
+ export function openBrowser(url) {
364
+ const platform = process.platform;
365
+ try {
366
+ if (platform === "darwin")
367
+ execSync(`open ${url}`, { stdio: "pipe" });
368
+ else if (platform === "win32")
369
+ execSync(`start ${url}`, { stdio: "pipe" });
370
+ else
371
+ execSync(`xdg-open ${url}`, { stdio: "pipe" });
372
+ }
373
+ catch {
374
+ }
375
+ }
376
+ // The identity marker the API's /health endpoint echoes (apps/api/src/server.ts).
377
+ // detectKeelApi requires it, so a bare {status:"ok"} from an unrelated dev server
378
+ // on the same port is NOT mistaken for keel.
379
+ export const KEEL_API_SERVICE = "keel-api";
380
+ // Probe the API port and return true ONLY when a running keel API positively
381
+ // identifies itself. Used by runUp to decide attach-vs-boot and (later) by
382
+ // `keel status`/`keel open`. Returns false on any error, timeout, unexpected
383
+ // status, non-JSON body, or a JSON body missing the keel-api marker — it never
384
+ // guesses "yes". A 503 (degraded: db down but keel IS up) still counts as keel,
385
+ // so a partially-healthy instance is recognized as ours rather than booted over.
386
+ export async function detectKeelApi(apiUrl, timeoutMs = 2000) {
387
+ try {
388
+ const res = await fetch(`${apiUrl}/health`, { signal: AbortSignal.timeout(timeoutMs) });
389
+ if (!res.ok && res.status !== 503)
390
+ return false;
391
+ const body = (await res.json());
392
+ return body?.service === KEEL_API_SERVICE;
393
+ }
394
+ catch {
395
+ return false;
396
+ }
397
+ }
398
+ export function makeTeardown(services, stopContainer, apiPort) {
399
+ let tearingDown = false;
400
+ return async () => {
401
+ if (tearingDown)
402
+ return;
403
+ tearingDown = true;
404
+ process.stdout.write(pc.dim("\nshutting down...\n"));
405
+ // The dev servers share keel up's foreground process group, so the terminal
406
+ // SIGINT (Ctrl+C) already reached them; these kills are a backstop for the
407
+ // programmatic teardown path (a child crashing and triggering teardown).
408
+ services.uiProc?.kill("SIGTERM");
409
+ services.apiProc?.kill("SIGTERM");
410
+ let dockerWarning = null;
411
+ if (services.dockerContainer) {
412
+ try {
413
+ stopContainer(services.dockerContainer);
414
+ }
415
+ catch {
416
+ // Surface, don't swallow: a failed container stop means it's still
417
+ // running and the user needs the manual recovery command.
418
+ dockerWarning = `could not stop the database container — stop it with: docker stop ${services.dockerContainer}`;
419
+ }
420
+ }
421
+ // Remove this instance's lockfile so `keel status` doesn't report a ghost.
422
+ if (apiPort !== undefined)
423
+ removeLock(apiPort);
424
+ process.stdout.write((dockerWarning ? pc.yellow(` ${dockerWarning}\n`) : "") +
425
+ pc.dim("stopped.\n"));
426
+ };
427
+ }
428
+ export async function runUp(opts = {}) {
429
+ const spawner = opts.spawner ?? defaultSpawner;
430
+ process.stdout.write(pc.bold("\nkeel up\n\n"));
431
+ loadDotEnv(process.env["KEEL_REPO_ROOT"] ?? REPO_ROOT);
432
+ // Zero-config secret: auto-generate + persist the token-encryption key on first
433
+ // run (to .keel/encryption-key, mode 0600) so the user never has to run openssl
434
+ // or hand-edit .env. ensureEncryptionKey returns the existing env/file key if
435
+ // present, else generates one. The spawned API + migrations inherit it via
436
+ // process.env (buildApiEnv spreads ...process.env).
437
+ const keyResult = ensureEncryptionKey();
438
+ process.env["KEEL_TOKEN_ENCRYPTION_KEY"] = keyResult.hex;
439
+ if (keyResult.status.warning) {
440
+ process.stdout.write(pc.dim(` ${keyResult.status.warning}\n`));
441
+ }
442
+ warnMissingKeys();
443
+ const config = opts.config ?? resolveConfig();
444
+ checkWorkspacePreflight(config.repoRoot);
445
+ // Attach-or-fail-fast — decided BEFORE any docker/db/migration side effects,
446
+ // so the common re-run cases exit in milliseconds without mutating state.
447
+ //
448
+ // 1. A keel API already answering on the API port means this is a re-run of an
449
+ // already-running workspace. Don't error and don't reboot anything — just
450
+ // open the browser and exit cleanly. (This is the exact case that used to
451
+ // boot a container, run 20 migrations, then throw a raw stack trace.)
452
+ if (await detectKeelApi(config.apiUrl)) {
453
+ process.stdout.write(pc.green("✓ keel is already running\n") +
454
+ pc.dim(" UI ") + pc.cyan(config.uiUrl) + pc.dim(" ← open this\n") +
455
+ pc.dim(` API ${config.apiUrl}\n`));
456
+ if (!opts.skipBrowser) {
457
+ process.stdout.write(pc.dim(" reopening it in your browser...\n"));
458
+ openBrowser(config.uiUrl);
459
+ }
460
+ return;
461
+ }
462
+ // 2. No keel here, but if the ports are held by some OTHER process, stop now
463
+ // with actionable guidance — before booting postgres or running migrations.
464
+ // (assertPortsFree's lsof/relocate advice is correct now that we've ruled
465
+ // out our own instance above.)
466
+ await assertPortsFree([
467
+ { port: config.apiPort, label: "workspace API", envVar: "KEEL_API_PORT" },
468
+ { port: config.uiPort, label: "workspace UI", envVar: "KEEL_UI_PORT" },
469
+ ]);
470
+ // Make sure the keyless Claude sign-in is live before we boot, so /chat works.
471
+ await claudeBridgeLoginPreflight({ skipLogin: opts.skipLogin ?? false });
472
+ const dbMode = checkPreflight();
473
+ const services = {
474
+ dockerContainer: null,
475
+ apiProc: null,
476
+ uiProc: null,
477
+ };
478
+ const teardown = makeTeardown(services, (name) => {
479
+ execSync(`docker stop ${name}`, { stdio: "pipe" });
480
+ }, config.apiPort);
481
+ process.on("SIGINT", () => { void teardown().then(() => process.exit(0)); });
482
+ process.on("SIGTERM", () => { void teardown().then(() => process.exit(0)); });
483
+ if (dbMode === "docker") {
484
+ process.stdout.write(pc.cyan("[ 1/5 ] starting postgres+pgvector\n"));
485
+ await startPostgres(config, spawner);
486
+ services.dockerContainer = config.dockerContainer;
487
+ await waitForPostgres(config);
488
+ }
489
+ else {
490
+ process.stdout.write(pc.cyan("[ 1/5 ] using external database (DATABASE_URL set)\n"));
491
+ }
492
+ process.stdout.write(pc.cyan("[ 2/5 ] running migrations\n"));
493
+ await runMigrationsViaScript(config, spawner);
494
+ process.stdout.write(pc.cyan("[ 3/5 ] starting workspace API\n"));
495
+ const apiEnv = buildApiEnv(config);
496
+ const apiProc = spawner("pnpm", ["--filter", "@keel_flow/api", "dev"], {
497
+ env: apiEnv,
498
+ cwd: config.repoRoot,
499
+ });
500
+ services.apiProc = apiProc;
501
+ process.stdout.write(pc.dim(` polling ${config.apiUrl}/health\n`));
502
+ await pollUrl(`${config.apiUrl}/health`, DEFAULTS.HEALTH_TIMEOUT_MS, DEFAULTS.HEALTH_POLL_INTERVAL_MS, "the API");
503
+ process.stdout.write(pc.green(` API ready at ${config.apiUrl}\n`));
504
+ process.stdout.write(pc.cyan("[ 4/5 ] starting workspace UI\n"));
505
+ const uiEnv = buildUiEnv(config);
506
+ const uiProc = spawner("pnpm", ["--filter", "@keel_flow/workspace", "dev"], {
507
+ env: uiEnv,
508
+ cwd: config.repoRoot,
509
+ });
510
+ services.uiProc = uiProc;
511
+ process.stdout.write(pc.dim(` polling ${config.uiUrl}\n`));
512
+ await pollUrl(config.uiUrl, DEFAULTS.HEALTH_TIMEOUT_MS, DEFAULTS.HEALTH_POLL_INTERVAL_MS, "the UI to compile");
513
+ process.stdout.write(pc.green(` UI ready at ${config.uiUrl}\n`));
514
+ process.stdout.write(pc.cyan("[ 5/5 ] opening browser\n"));
515
+ if (!opts.skipBrowser) {
516
+ openBrowser(config.uiUrl);
517
+ }
518
+ // Record this instance so `keel status`/`keel down` can find it from another
519
+ // terminal. Keyed by API port for multi-workspace coexistence. Informational
520
+ // only — `down` re-resolves the live port listeners and never trusts a PID.
521
+ writeLock({
522
+ apiPort: config.apiPort,
523
+ uiPort: config.uiPort,
524
+ apiUrl: config.apiUrl,
525
+ uiUrl: config.uiUrl,
526
+ dockerContainer: services.dockerContainer,
527
+ repoRoot: config.repoRoot,
528
+ startedAt: new Date().toISOString(),
529
+ });
530
+ process.stdout.write(pc.bold(`\n✓ keel workspace is running\n`) +
531
+ pc.dim(` UI `) + pc.cyan(config.uiUrl) + pc.dim(` ← open this\n`) +
532
+ pc.dim(` API ${config.apiUrl}\n`) +
533
+ (opts.skipBrowser ? "" : pc.dim(`\n opened in your browser automatically.\n`)) +
534
+ pc.dim(` single-user mode — no sign-in required.\n`) +
535
+ pc.dim(`\n Press Ctrl+C to stop`) + (process.platform === "win32" ? pc.dim(".\n\n") : pc.dim(" (or run: keel down).\n\n")));
536
+ await new Promise((resolve) => {
537
+ const exitIfDead = (name) => (code) => {
538
+ if (code !== null && code !== 0) {
539
+ process.stderr.write(pc.red(`${name} exited with code ${code}\n`));
540
+ void teardown().then(() => process.exit(code ?? 1));
541
+ }
542
+ else if (code !== null) {
543
+ process.stdout.write(pc.dim(`${name} exited cleanly\n`));
544
+ void teardown().then(() => resolve());
545
+ }
546
+ };
547
+ apiProc.on("exit", exitIfDead("API"));
548
+ uiProc.on("exit", exitIfDead("UI"));
549
+ });
550
+ }
551
+ // ── Lifecycle commands: status / down / open / restart ──────────────────────
552
+ // These give keel an app-like lifecycle from any terminal, not just the one that
553
+ // ran `keel up`. They all resolve the same config (port/container) and rely on
554
+ // detectKeelApi's fingerprint to act on a real keel rather than a stranger.
555
+ async function probeOk(url, timeoutMs = 2000) {
556
+ try {
557
+ const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
558
+ return res.ok;
559
+ }
560
+ catch {
561
+ return false;
562
+ }
563
+ }
564
+ export async function probeStatus(config) {
565
+ const [apiUp, uiUp] = await Promise.all([detectKeelApi(config.apiUrl), probeOk(config.uiUrl)]);
566
+ return { apiUp, uiUp, dbStatus: dockerContainerStatus(config.dockerContainer) };
567
+ }
568
+ function statusDot(up) {
569
+ return up ? pc.green("●") : pc.red("○");
570
+ }
571
+ // `keel status` — where is the workspace and is it up? Returns true iff both API
572
+ // and UI answer, so the command can exit non-zero for scripting.
573
+ export async function runStatus(opts = {}) {
574
+ const config = opts.config ?? resolveConfig();
575
+ const status = await probeStatus(config);
576
+ const dbUp = status.dbStatus === "running";
577
+ const dbLabel = status.dbStatus === "absent"
578
+ ? pc.dim("external / not managed")
579
+ : status.dbStatus === "running"
580
+ ? pc.green("running")
581
+ : pc.yellow("stopped");
582
+ process.stdout.write(pc.bold("\nkeel status\n\n") +
583
+ ` ${statusDot(status.apiUp)} API ${pc.dim(config.apiUrl)} ${status.apiUp ? pc.green("running") : pc.red("stopped")}\n` +
584
+ ` ${statusDot(status.uiUp)} UI ${pc.dim(config.uiUrl)} ${status.uiUp ? pc.green("running") : pc.red("stopped")}\n` +
585
+ ` ${statusDot(dbUp)} DB ${pc.dim(config.dockerContainer)} ${dbLabel}\n\n`);
586
+ // Surface other workspaces running on different ports (multi-instance support).
587
+ const others = listLocks().filter((l) => l.apiPort !== config.apiPort);
588
+ if (others.length > 0) {
589
+ process.stdout.write(pc.dim(` other instances on: ${others.map((o) => `:${o.apiPort}`).join(", ")} (KEEL_API_PORT=<port> keel status)\n\n`));
590
+ }
591
+ return status.apiUp && status.uiUp;
592
+ }
593
+ // `keel down` — stop the workspace from any terminal. Kills the process GROUP of
594
+ // whatever currently listens on keel's API/UI ports (verified as keel via the
595
+ // /health fingerprint), stops the managed DB container, and clears the lockfile.
596
+ // It targets live port owners, never a recorded PID — so it cannot kill a
597
+ // recycled PID or orphan a wrapper-forked dev server.
598
+ export async function runDown(opts = {}) {
599
+ const config = opts.config ?? resolveConfig();
600
+ const warnings = [];
601
+ if (process.platform === "win32") {
602
+ // Process-group reaping is POSIX-only; be honest rather than silently failing.
603
+ process.stdout.write(pc.yellow("keel down is not supported on Windows yet.\n") +
604
+ pc.dim(" Stop the workspace with Ctrl+C in the terminal running keel up.\n"));
605
+ return { wasRunning: false, warnings: ["windows-unsupported"] };
606
+ }
607
+ const lock = readLock(config.apiPort);
608
+ const isKeel = await detectKeelApi(config.apiUrl);
609
+ if (!isKeel) {
610
+ if (lock) {
611
+ removeLock(config.apiPort);
612
+ process.stdout.write(pc.dim("keel was not running (cleared a stale lockfile).\n"));
613
+ }
614
+ else {
615
+ process.stdout.write(pc.dim("keel is not running.\n"));
616
+ }
617
+ return { wasRunning: false, warnings };
618
+ }
619
+ process.stdout.write(pc.dim("stopping keel...\n"));
620
+ const outcome = await stopListenersOnPorts([config.apiPort, config.uiPort]);
621
+ // Stop the managed DB container (named in the lockfile, else the configured
622
+ // default) only when it actually runs — an external DATABASE_URL has none.
623
+ const container = lock?.dockerContainer ?? config.dockerContainer;
624
+ if (container && dockerContainerStatus(container) === "running") {
625
+ if (!stopDockerContainer(container)) {
626
+ warnings.push(`could not stop the database container — stop it with: docker stop ${container}`);
627
+ }
628
+ }
629
+ removeLock(config.apiPort);
630
+ // Confirm the API port actually freed; if it still answers it may be respawning.
631
+ if (await detectKeelApi(config.apiUrl)) {
632
+ warnings.push(`the API still answers on ${config.apiUrl} — check: lsof -nP -iTCP:${config.apiPort} -sTCP:LISTEN`);
633
+ }
634
+ const reaped = outcome.killedGroups.length;
635
+ process.stdout.write(warnings.map((w) => pc.yellow(` ${w}\n`)).join("") +
636
+ pc.green(`✓ keel stopped (${reaped} process group${reaped === 1 ? "" : "s"} reaped)\n`));
637
+ return { wasRunning: true, warnings };
638
+ }
639
+ // `keel open` — reopen the workspace in the browser, if it's running.
640
+ export async function runOpen(opts = {}) {
641
+ const config = opts.config ?? resolveConfig();
642
+ if (await detectKeelApi(config.apiUrl)) {
643
+ process.stdout.write(pc.dim(`opening ${config.uiUrl}\n`));
644
+ openBrowser(config.uiUrl);
645
+ return true;
646
+ }
647
+ process.stdout.write(pc.yellow("keel isn't running — start it with: keel up\n"));
648
+ return false;
649
+ }
650
+ // `keel restart` — stop a running instance (if any), then boot fresh. runUp takes
651
+ // over the foreground as usual.
652
+ export async function runRestart(opts = {}) {
653
+ await runDown({ ...(opts.config ? { config: opts.config } : {}) });
654
+ await runUp(opts);
655
+ }
656
+ //# sourceMappingURL=up.js.map