@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.
- package/LICENSE +21 -0
- package/README.md +170 -0
- package/dist/build.d.ts +57 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +350 -0
- package/dist/build.js.map +1 -0
- package/dist/claude-auth.d.ts +16 -0
- package/dist/claude-auth.d.ts.map +1 -0
- package/dist/claude-auth.js +75 -0
- package/dist/claude-auth.js.map +1 -0
- package/dist/doctor.d.ts +18 -0
- package/dist/doctor.d.ts.map +1 -0
- package/dist/doctor.js +547 -0
- package/dist/doctor.js.map +1 -0
- package/dist/git.d.ts +4 -0
- package/dist/git.d.ts.map +1 -0
- package/dist/git.js +138 -0
- package/dist/git.js.map +1 -0
- package/dist/goals.d.ts +34 -0
- package/dist/goals.d.ts.map +1 -0
- package/dist/goals.js +218 -0
- package/dist/goals.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1015 -0
- package/dist/index.js.map +1 -0
- package/dist/kb-reindex.d.ts +31 -0
- package/dist/kb-reindex.d.ts.map +1 -0
- package/dist/kb-reindex.js +71 -0
- package/dist/kb-reindex.js.map +1 -0
- package/dist/keys.d.ts +26 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +209 -0
- package/dist/keys.js.map +1 -0
- package/dist/learn.d.ts +37 -0
- package/dist/learn.d.ts.map +1 -0
- package/dist/learn.js +274 -0
- package/dist/learn.js.map +1 -0
- package/dist/lifecycle.d.ts +27 -0
- package/dist/lifecycle.d.ts.map +1 -0
- package/dist/lifecycle.js +193 -0
- package/dist/lifecycle.js.map +1 -0
- package/dist/load-ts.d.ts +2 -0
- package/dist/load-ts.d.ts.map +1 -0
- package/dist/load-ts.js +20 -0
- package/dist/load-ts.js.map +1 -0
- package/dist/map-repo.d.ts +15 -0
- package/dist/map-repo.d.ts.map +1 -0
- package/dist/map-repo.js +36 -0
- package/dist/map-repo.js.map +1 -0
- package/dist/memory.d.ts +23 -0
- package/dist/memory.d.ts.map +1 -0
- package/dist/memory.js +84 -0
- package/dist/memory.js.map +1 -0
- package/dist/orchestrate.d.ts +61 -0
- package/dist/orchestrate.d.ts.map +1 -0
- package/dist/orchestrate.js +556 -0
- package/dist/orchestrate.js.map +1 -0
- package/dist/reflect.d.ts +12 -0
- package/dist/reflect.d.ts.map +1 -0
- package/dist/reflect.js +67 -0
- package/dist/reflect.js.map +1 -0
- package/dist/report.d.ts +3 -0
- package/dist/report.d.ts.map +1 -0
- package/dist/report.js +56 -0
- package/dist/report.js.map +1 -0
- package/dist/rules.d.ts +16 -0
- package/dist/rules.d.ts.map +1 -0
- package/dist/rules.js +65 -0
- package/dist/rules.js.map +1 -0
- package/dist/telemetry-helper.d.ts +12 -0
- package/dist/telemetry-helper.d.ts.map +1 -0
- package/dist/telemetry-helper.js +40 -0
- package/dist/telemetry-helper.js.map +1 -0
- package/dist/up.d.ts +97 -0
- package/dist/up.d.ts.map +1 -0
- package/dist/up.js +656 -0
- package/dist/up.js.map +1 -0
- 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
|