okengine 0.9.0 → 0.10.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/AGENTS.md +1 -1
- package/manifest.v1.schema.json +9 -1
- package/package.json +3 -3
- package/site/content/docs/console/runs.mdx +10 -0
- package/site/content/docs/console/traces.mdx +4 -0
- package/site/content/docs/elements/ai.mdx +54 -32
- package/site/content/docs/elements/channel.mdx +105 -14
- package/site/content/docs/elements/clock.mdx +1 -0
- package/site/content/docs/elements/flow.mdx +42 -8
- package/site/content/docs/get-started/basic-usage.mdx +5 -1
- package/site/content/docs/get-started/installation.mdx +9 -5
- package/site/content/docs/meta.json +2 -0
- package/site/content/docs/plugins/otp.mdx +58 -46
- package/site/content/docs/providers/azure-redis.mdx +85 -0
- package/site/content/docs/providers/cockroachdb.mdx +86 -0
- package/site/content/docs/providers/digitalocean-caching.mdx +88 -0
- package/site/content/docs/providers/dragonfly-cloud.mdx +82 -0
- package/site/content/docs/providers/elasticache.mdx +92 -0
- package/site/content/docs/providers/index.mdx +101 -0
- package/site/content/docs/providers/memorystore.mdx +89 -0
- package/site/content/docs/providers/meta.json +20 -0
- package/site/content/docs/providers/neon.mdx +115 -0
- package/site/content/docs/providers/redis-cloud.mdx +93 -0
- package/site/content/docs/providers/supabase.mdx +117 -0
- package/site/content/docs/providers/upstash.mdx +91 -0
- package/site/content/docs/providers/yugabytedb.mdx +95 -0
- package/site/content/docs/recipes/caddy.mdx +128 -0
- package/site/content/docs/recipes/cockroachdb.mdx +127 -0
- package/site/content/docs/recipes/dragonfly.mdx +125 -0
- package/site/content/docs/recipes/index.mdx +146 -0
- package/site/content/docs/recipes/llama-cpp.mdx +153 -0
- package/site/content/docs/recipes/mailpit.mdx +136 -0
- package/site/content/docs/recipes/meilisearch.mdx +139 -0
- package/site/content/docs/recipes/meta.json +31 -0
- package/site/content/docs/recipes/ollama.mdx +142 -0
- package/site/content/docs/recipes/openbao.mdx +141 -0
- package/site/content/docs/recipes/pgdog.mdx +137 -0
- package/site/content/docs/recipes/postgres.mdx +136 -0
- package/site/content/docs/recipes/redis.mdx +130 -0
- package/site/content/docs/recipes/rustfs.mdx +148 -0
- package/site/content/docs/recipes/sglang.mdx +105 -0
- package/site/content/docs/recipes/supabase-docker.mdx +145 -0
- package/site/content/docs/recipes/timescale.mdx +129 -0
- package/site/content/docs/recipes/traefik.mdx +136 -0
- package/site/content/docs/recipes/valkey.mdx +121 -0
- package/site/content/docs/recipes/vllm.mdx +106 -0
- package/site/content/docs/recipes/yugabytedb.mdx +135 -0
- package/site/content/docs/reference/cli.md +42 -6
- package/site/content/docs/reference/configuration.mdx +5 -3
- package/site/content/docs/reference/environment-variables.mdx +12 -12
- package/site/content/docs/reference/fx.mdx +38 -4
- package/src/auth/otp-capability.ts +14 -14
- package/src/cli/ai-setup/ai-setup.test.ts +32 -12
- package/src/cli/ai-setup/apply.ts +65 -23
- package/src/cli/ai-setup/catalog.ts +1316 -35
- package/src/cli/ai-setup/detect-ollama.ts +47 -0
- package/src/cli/ai-setup/index.ts +44 -7
- package/src/cli/ai-setup/prompts.ts +430 -486
- package/src/cli/ai-setup/recommend.test.ts +150 -50
- package/src/cli/ai-setup/recommend.ts +118 -101
- package/src/cli/ask-seed.test.ts +68 -0
- package/src/cli/ask-seed.ts +79 -0
- package/src/cli/db-seed-redact.test.ts +25 -0
- package/src/cli/db-seed.ts +36 -7
- package/src/cli/db.test.ts +1 -1
- package/src/cli/db.ts +2 -2
- package/src/cli/dev-controls.test.ts +107 -0
- package/src/cli/dev-controls.ts +354 -0
- package/src/cli/dev-schema-sync.ts +12 -4
- package/src/cli/dev.test.ts +50 -30
- package/src/cli/dev.ts +607 -66
- package/src/cli/drizzle-env.test.ts +40 -6
- package/src/cli/drizzle-env.ts +22 -1
- package/src/cli/ensure-drizzle-config.ts +6 -4
- package/src/cli/hero-meta.test.ts +13 -1
- package/src/cli/hero-meta.ts +28 -6
- package/src/cli/index.ts +5 -0
- package/src/cli/registry.ts +24 -0
- package/src/cli/replay.test.ts +105 -0
- package/src/cli/replay.ts +206 -0
- package/src/compiler/extract.ts +3 -3
- package/src/config/index.ts +3 -3
- package/src/console/server/claim.ts +3 -1
- package/src/console/ui/dist/assets/{index-ClX0ZCe5.js → index-D-9KFce5.js} +2 -2
- package/src/console/ui/dist/assets/panel-overview-CW0CQEc2.js +1 -0
- package/src/console/ui/dist/assets/panel-runs-BxuyDI3x.js +1 -0
- package/src/console/ui/dist/assets/{panel-signals-Dsxdu_AR.js → panel-signals-BXofbj7v.js} +1 -1
- package/src/console/ui/dist/assets/{panel-store-hy7O8HOs.js → panel-store-B8mH-arU.js} +1 -1
- package/src/console/ui/dist/assets/{panel-traces-CBiAe5go.js → panel-traces-DSA-ZWnf.js} +1 -1
- package/src/console/ui/dist/index.html +1 -1
- package/src/console/ui/overview/slo.ts +31 -3
- package/src/console/ui/overview/types.ts +6 -0
- package/src/console/ui/runs/errors.test.ts +53 -0
- package/src/console/ui/runs/errors.ts +88 -0
- package/src/console/ui/runs/index.ts +8 -0
- package/src/console/ui/runs/search.ts +16 -0
- package/src/console/ui/shell/panels/runs/RunsPanel.tsx +72 -1
- package/src/docker/ai-model-status.test.ts +101 -0
- package/src/docker/ai-model-status.ts +276 -0
- package/src/docker/compose-health.test.ts +115 -0
- package/src/docker/compose-health.ts +234 -0
- package/src/docker/compose.ts +24 -8
- package/src/docker/derive.ts +35 -9
- package/src/docker/docker.test.ts +304 -12
- package/src/docker/helpers.ts +56 -0
- package/src/docker/index.ts +52 -0
- package/src/docker/ollama-pull.ts +232 -0
- package/src/docker/recipes/cockroach.ts +24 -0
- package/src/docker/recipes/index.ts +32 -0
- package/src/docker/recipes/llama-cpp.ts +273 -0
- package/src/docker/recipes/ollama.ts +19 -23
- package/src/docker/recipes/pgdog.ts +4 -1
- package/src/docker/recipes/postgres.ts +5 -18
- package/src/docker/recipes/sglang.ts +55 -0
- package/src/docker/recipes/supabase.ts +21 -0
- package/src/docker/recipes/timescale.ts +18 -0
- package/src/docker/recipes/vllm.ts +44 -0
- package/src/docker/recipes/yugabyte.ts +23 -0
- package/src/docker/stack-id.test.ts +22 -0
- package/src/docker/stack-id.ts +38 -4
- package/src/docker/types.ts +15 -0
- package/src/drivers/ai-ollama.integration.test.ts +7 -4
- package/src/drivers/signal-engine.ts +10 -0
- package/src/drivers/signal-postgres.ts +24 -2
- package/src/drivers/signal-types.ts +10 -0
- package/src/elements/channel/fallback.test.ts +91 -0
- package/src/elements/channel/hard-bounce.test.ts +82 -0
- package/src/elements/channel/injection.test.ts +153 -0
- package/src/elements/channel/locale-ar.test.ts +102 -0
- package/src/elements/channel/otp-delivery.ts +5 -2
- package/src/elements/channel/receipts-ledger.test.ts +100 -0
- package/src/elements/channel/runtime.ts +24 -1
- package/src/elements/channel/test-helpers.ts +60 -0
- package/src/elements/channel/whatsapp-compliance.test.ts +124 -0
- package/src/elements/store/cache.ts +1 -1
- package/src/elements/store/emit-drizzle.ts +43 -9
- package/src/elements/store/runtime.ts +1 -1
- package/src/elements/store/schema-decl.test.ts +2 -0
- package/src/elements/store/schema-decl.ts +15 -0
- package/src/elements/store/seed.ts +1 -1
- package/src/index.ts +1 -0
- package/src/kernel/app.ts +118 -11
- package/src/kernel/boot-bind/signal.ts +6 -2
- package/src/kernel/boot-bind/store.ts +2 -1
- package/src/kernel/boot.test.ts +6 -5
- package/src/kernel/boot.ts +8 -3
- package/src/kernel/compensate.test.ts +131 -0
- package/src/kernel/correlation.test.ts +108 -0
- package/src/kernel/effect-timing.test.ts +82 -0
- package/src/kernel/flow.ts +38 -0
- package/src/kernel/fx-runs.test.ts +49 -0
- package/src/kernel/fx.test.ts +14 -0
- package/src/kernel/fx.ts +111 -1
- package/src/manifest/types.ts +8 -2
- package/src/plugins/auth-delivery.mailpit.integration.test.ts +1 -1
- package/src/plugins/auth-methods.security.test.ts +3 -3
- package/src/plugins/auth-methods.test.ts +1 -1
- package/src/plugins/index.ts +2 -2
- package/src/plugins/otp.test.ts +21 -19
- package/src/plugins/otp.ts +36 -34
- package/src/plugins/taqnyat.live.test.ts +3 -3
- package/src/runs/collect.ts +3 -0
- package/src/runs/export-otlp.ts +64 -0
- package/src/runs/index.ts +16 -0
- package/src/runs/parquet.ts +9 -0
- package/src/runs/types.ts +5 -0
- package/src/runs/window.test.ts +68 -0
- package/src/runs/window.ts +148 -0
- package/src/runtime/boot-warn.test.ts +45 -0
- package/src/runtime/boot-warn.ts +17 -0
- package/src/runtime/dev-request-log.ts +5 -5
- package/src/term.test.ts +95 -10
- package/src/term.ts +369 -14
- package/src/console/ui/dist/assets/panel-overview-DONDzBd2.js +0 -1
- package/src/console/ui/dist/assets/panel-runs-D5zf-D9c.js +0 -1
package/src/cli/dev.ts
CHANGED
|
@@ -19,21 +19,39 @@ import {
|
|
|
19
19
|
stackAppSlug,
|
|
20
20
|
stackInstanceId,
|
|
21
21
|
writeDerivedFiles,
|
|
22
|
+
formatStackEnv,
|
|
22
23
|
type DeriveOptions,
|
|
23
24
|
} from "../docker/index.ts";
|
|
24
25
|
import type { Manifest } from "../manifest/types.ts";
|
|
25
26
|
import type { McpContext } from "../mcp/tools.ts";
|
|
26
27
|
import { APP_PORT, CONSOLE_PORT, DOCS_MCP_PORT, MCP_PORT } from "../runtime/types.ts";
|
|
27
28
|
import {
|
|
29
|
+
clearTerminalScreen,
|
|
30
|
+
createAnchoredBoard,
|
|
31
|
+
createBootProgress,
|
|
32
|
+
devStatusFromAiPhase,
|
|
33
|
+
formatAppReadyLine,
|
|
34
|
+
formatBootWarn,
|
|
35
|
+
formatCliChrome,
|
|
28
36
|
formatDevBanner,
|
|
37
|
+
formatDevHeroDetails,
|
|
29
38
|
formatDevLogSeparator,
|
|
30
39
|
formatServiceLine,
|
|
31
40
|
formatStackSummary,
|
|
32
41
|
formatStatusLine,
|
|
42
|
+
type BootProgress,
|
|
43
|
+
type DevStatus,
|
|
44
|
+
type StackSummaryService,
|
|
33
45
|
} from "../term.ts";
|
|
34
46
|
import { clientAdd } from "./client-add.ts";
|
|
35
47
|
import { resolveDriverId } from "../config/index.ts";
|
|
36
48
|
import { askDevMode, type AskDevModeFn } from "./ask-dev-mode.ts";
|
|
49
|
+
import {
|
|
50
|
+
controlServicesFromStack,
|
|
51
|
+
formatDevControlsHint,
|
|
52
|
+
startDevControls,
|
|
53
|
+
type DevComposeControlAction,
|
|
54
|
+
} from "./dev-controls.ts";
|
|
37
55
|
import {
|
|
38
56
|
createDebouncedRunner,
|
|
39
57
|
isDomainSchemaWatchPath,
|
|
@@ -41,7 +59,13 @@ import {
|
|
|
41
59
|
} from "./db-auto-push.ts";
|
|
42
60
|
import { resolveDrizzleConfigPath, runPush } from "./db.ts";
|
|
43
61
|
import { readDevMode, shouldAskDevMode, writeDevMode, type DevMode } from "./dev-mode.ts";
|
|
44
|
-
import {
|
|
62
|
+
import {
|
|
63
|
+
buildDevHeroSnapshot,
|
|
64
|
+
encodeHeroSnapshot,
|
|
65
|
+
formatHeroSystemLine,
|
|
66
|
+
resolveDevProfile,
|
|
67
|
+
resolveDevRuntimeEnv,
|
|
68
|
+
} from "./hero-meta.ts";
|
|
45
69
|
import { loadManifest, loadOkeConfig, resolveImages } from "./load-config.ts";
|
|
46
70
|
import { mcpContextFromConsole } from "./mcp-from-console.ts";
|
|
47
71
|
import { resolveDevPorts } from "./ports.ts";
|
|
@@ -198,6 +222,17 @@ export interface DevOptions {
|
|
|
198
222
|
cwd: string,
|
|
199
223
|
env: Readonly<Record<string, string>>,
|
|
200
224
|
) => void;
|
|
225
|
+
/**
|
|
226
|
+
* Poll compose service health (injectable). Default: {@link watchComposeHealth}.
|
|
227
|
+
*
|
|
228
|
+
* @param options - Compose files, cwd, env, optional per-service updates
|
|
229
|
+
*/
|
|
230
|
+
readonly composeHealth?: (options: {
|
|
231
|
+
readonly files: readonly string[];
|
|
232
|
+
readonly cwd: string;
|
|
233
|
+
readonly env: Record<string, string>;
|
|
234
|
+
readonly onUpdate?: (service: string, status: DevStatus) => void;
|
|
235
|
+
}) => Promise<Map<string, DevStatus>>;
|
|
201
236
|
/**
|
|
202
237
|
* Regenerate client types (injectable).
|
|
203
238
|
*
|
|
@@ -260,7 +295,21 @@ export interface DevResult {
|
|
|
260
295
|
* @param options - Flags
|
|
261
296
|
*/
|
|
262
297
|
export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
263
|
-
|
|
298
|
+
/** Reassigned to {@link createAnchoredBoard} wrap after the status board paints. */
|
|
299
|
+
let write: (text: string) => void = options.write ?? ((t) => process.stdout.write(t));
|
|
300
|
+
const chromeWrite = (t: string) => write(formatCliChrome(t));
|
|
301
|
+
const previousWarn = console.warn;
|
|
302
|
+
console.warn = (...args: unknown[]) => {
|
|
303
|
+
const msg = args.map(String).join(" ");
|
|
304
|
+
if (/^oke boot:/i.test(msg.trim())) {
|
|
305
|
+
write(formatBootWarn(msg.trim()));
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
previousWarn.apply(console, args as Parameters<typeof console.warn>);
|
|
309
|
+
};
|
|
310
|
+
const restoreWarn = () => {
|
|
311
|
+
console.warn = previousWarn;
|
|
312
|
+
};
|
|
264
313
|
const cwd = options.cwd ?? process.cwd();
|
|
265
314
|
const preferredApp = options.appPort ?? Number(Bun.env.PORT ?? APP_PORT);
|
|
266
315
|
const preferredConsole = options.consolePort ?? CONSOLE_PORT;
|
|
@@ -301,6 +350,7 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
|
301
350
|
}
|
|
302
351
|
if (!entry) {
|
|
303
352
|
console.error("oke dev: no entry found (src/app.ts)");
|
|
353
|
+
restoreWarn();
|
|
304
354
|
return { code: 1 };
|
|
305
355
|
}
|
|
306
356
|
|
|
@@ -308,6 +358,7 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
|
308
358
|
const explicitDocker = options.docker === true || Array.isArray(options.docker);
|
|
309
359
|
if (explicitLocal && explicitDocker) {
|
|
310
360
|
console.error("oke dev: use either --local or --docker, not both");
|
|
361
|
+
restoreWarn();
|
|
311
362
|
return { code: 1 };
|
|
312
363
|
}
|
|
313
364
|
|
|
@@ -331,6 +382,7 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
|
331
382
|
const chosen = await ask();
|
|
332
383
|
if (chosen === null) {
|
|
333
384
|
console.error("oke dev: cancelled");
|
|
385
|
+
restoreWarn();
|
|
334
386
|
return { code: 1 };
|
|
335
387
|
}
|
|
336
388
|
mode = chosen;
|
|
@@ -340,6 +392,28 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
|
340
392
|
mode = "local";
|
|
341
393
|
}
|
|
342
394
|
|
|
395
|
+
// Paint chrome immediately so docker/vault work is never a blank 6s wait.
|
|
396
|
+
let okeVersion = "0.0.0";
|
|
397
|
+
try {
|
|
398
|
+
const pkg = (await Bun.file(resolve(import.meta.dir, "../../package.json")).json()) as {
|
|
399
|
+
version?: string;
|
|
400
|
+
};
|
|
401
|
+
if (typeof pkg.version === "string") okeVersion = pkg.version;
|
|
402
|
+
} catch {
|
|
403
|
+
// shipped binary may not sit next to package.json
|
|
404
|
+
}
|
|
405
|
+
const earlyProfile = resolveDevProfile({ docker: mode === "docker", nodeEnv: "development" });
|
|
406
|
+
write(
|
|
407
|
+
formatDevBanner({
|
|
408
|
+
profile: earlyProfile,
|
|
409
|
+
runtimeEnv: resolveDevRuntimeEnv(earlyProfile),
|
|
410
|
+
system: formatHeroSystemLine(),
|
|
411
|
+
version: okeVersion,
|
|
412
|
+
}),
|
|
413
|
+
);
|
|
414
|
+
/** Ephemeral compose/vault/health/AI lines — cleared before the final board. */
|
|
415
|
+
const bootProgress: BootProgress = createBootProgress(write);
|
|
416
|
+
|
|
343
417
|
let stackRoles: string[] | null = null;
|
|
344
418
|
let composeFiles: string[] | null = null;
|
|
345
419
|
let stackEnv: Record<string, string> | null = null;
|
|
@@ -351,6 +425,26 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
|
351
425
|
} | null = null;
|
|
352
426
|
let stackSqlDriver = "postgres";
|
|
353
427
|
let stackKvDriver = "redis";
|
|
428
|
+
/** Printed after the hero so Docker ports sit under the Starting column. */
|
|
429
|
+
let pendingStackSummary: {
|
|
430
|
+
readonly project: string;
|
|
431
|
+
readonly services: StackSummaryService[];
|
|
432
|
+
readonly appDrivers: readonly string[];
|
|
433
|
+
} | null = null;
|
|
434
|
+
/** Background AI model poller — cancelled in `stop()`. */
|
|
435
|
+
let stopAiModelWatch: (() => void) | null = null;
|
|
436
|
+
/** Session compose health poller — cancelled in `stop()`. */
|
|
437
|
+
let stopComposeHealthWatch: (() => void) | null = null;
|
|
438
|
+
/** Latest compose ps -a statuses (service name → ●). */
|
|
439
|
+
let liveComposeHealth = new Map<string, DevStatus>();
|
|
440
|
+
/** Start after hero/stack chrome so status lines land in the right place. */
|
|
441
|
+
let pendingAiModelWatch: {
|
|
442
|
+
readonly url: string;
|
|
443
|
+
readonly model: string;
|
|
444
|
+
readonly kind: "openai-compatible" | "ollama";
|
|
445
|
+
} | null = null;
|
|
446
|
+
/** Hero AI status dot (yellow while model loads). */
|
|
447
|
+
let heroAiStatus: DevStatus | undefined;
|
|
354
448
|
let loadedConfig: Awaited<ReturnType<typeof loadOkeConfig>>["config"] | null = null;
|
|
355
449
|
try {
|
|
356
450
|
loadedConfig = (await loadOkeConfig(cwd)).config;
|
|
@@ -371,6 +465,7 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
|
371
465
|
write(formatStatusLine(`meilisearch local server on ${meili.url}`));
|
|
372
466
|
} catch (err) {
|
|
373
467
|
console.error(err instanceof Error ? err.message : String(err));
|
|
468
|
+
restoreWarn();
|
|
374
469
|
return { code: 1 };
|
|
375
470
|
}
|
|
376
471
|
}
|
|
@@ -386,6 +481,7 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
|
386
481
|
images = resolveImages(loaded.config);
|
|
387
482
|
} catch (err) {
|
|
388
483
|
console.error(err instanceof Error ? err.message : String(err));
|
|
484
|
+
restoreWarn();
|
|
389
485
|
return { code: 1 };
|
|
390
486
|
}
|
|
391
487
|
} else {
|
|
@@ -434,6 +530,44 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
|
434
530
|
);
|
|
435
531
|
stackEnv = { ...derived.stackEnv };
|
|
436
532
|
|
|
533
|
+
const roleLabel = (role: string): string => {
|
|
534
|
+
if (role === "store.sql") return "postgres";
|
|
535
|
+
if (role === "store.kv") return "redis";
|
|
536
|
+
if (role === "store.files") return "files";
|
|
537
|
+
if (role === "channel.email") return "mail";
|
|
538
|
+
return role;
|
|
539
|
+
};
|
|
540
|
+
const extraLabel = (role: string, containerPort: number): string => {
|
|
541
|
+
if (role === "channel.email" && containerPort === 8025) return "mail-ui";
|
|
542
|
+
if (role === "store.files" && containerPort === 9001) return "files-ui";
|
|
543
|
+
return `${roleLabel(role)}+${containerPort}`;
|
|
544
|
+
};
|
|
545
|
+
const appDrivers = [
|
|
546
|
+
...(stackRoles.includes("store.sql") ? [stackSqlDriver] : []),
|
|
547
|
+
...(stackRoles.includes("store.kv") ? [stackKvDriver] : []),
|
|
548
|
+
];
|
|
549
|
+
const stackAiModel =
|
|
550
|
+
stackEnv.OKE_AI_MODEL?.trim() || process.env.OKE_AI_MODEL?.trim() || undefined;
|
|
551
|
+
pendingStackSummary = {
|
|
552
|
+
project: `oke-${appSlug}`,
|
|
553
|
+
services: derived.specs.flatMap((spec) => [
|
|
554
|
+
{
|
|
555
|
+
label: roleLabel(spec.role),
|
|
556
|
+
hostPort: spec.hostPort,
|
|
557
|
+
serviceName: spec.serviceName,
|
|
558
|
+
status: "pending" as const,
|
|
559
|
+
...(spec.role === "ai" && stackAiModel ? { detail: stackAiModel } : {}),
|
|
560
|
+
},
|
|
561
|
+
...resolveExtraPorts(spec, { images, instanceId }).map((p) => ({
|
|
562
|
+
label: extraLabel(spec.role, p.containerPort),
|
|
563
|
+
hostPort: p.hostPort,
|
|
564
|
+
serviceName: spec.serviceName,
|
|
565
|
+
status: "pending" as const,
|
|
566
|
+
})),
|
|
567
|
+
]),
|
|
568
|
+
appDrivers,
|
|
569
|
+
};
|
|
570
|
+
|
|
437
571
|
if (!options.dryRun) {
|
|
438
572
|
try {
|
|
439
573
|
await writeDerivedFiles(derived, dockerOut, {
|
|
@@ -445,17 +579,26 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
|
445
579
|
const args = ["compose", ...files.flatMap((f) => ["-f", f]), "up", "-d"];
|
|
446
580
|
const proc = Bun.spawn(["docker", ...args], {
|
|
447
581
|
cwd: dir,
|
|
448
|
-
stdout: "
|
|
449
|
-
stderr: "
|
|
582
|
+
stdout: "pipe",
|
|
583
|
+
stderr: "pipe",
|
|
450
584
|
env: { ...process.env, ...stackEnv! },
|
|
451
585
|
});
|
|
452
|
-
const code = await
|
|
586
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
587
|
+
new Response(proc.stdout).text(),
|
|
588
|
+
new Response(proc.stderr).text(),
|
|
589
|
+
proc.exited,
|
|
590
|
+
]);
|
|
453
591
|
if (code !== 0) {
|
|
454
|
-
|
|
592
|
+
const detail = [stdout.trim(), stderr.trim()].filter(Boolean).join("\n");
|
|
593
|
+
throw new Error(
|
|
594
|
+
`oke dev --docker: docker compose exited ${code}` + (detail ? `\n${detail}` : ""),
|
|
595
|
+
);
|
|
455
596
|
}
|
|
456
597
|
});
|
|
598
|
+
bootProgress.set("compose", formatStatusLine("docker compose up…", undefined, "pending"));
|
|
457
599
|
// Compose files live under docker/; cwd for compose is that directory.
|
|
458
600
|
await up(composeFiles, dockerOut);
|
|
601
|
+
bootProgress.set("compose", formatStatusLine("docker compose up", undefined, "ready"));
|
|
459
602
|
// Remember for SIGINT / terminal close — `stop` keeps volumes (not `down -v`).
|
|
460
603
|
dockerStarted = {
|
|
461
604
|
files: composeFiles,
|
|
@@ -467,6 +610,7 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
|
467
610
|
// least-privilege app token to the app + Console (never root).
|
|
468
611
|
const vaultSpec = derived.specs.find((s) => s.role === "vault");
|
|
469
612
|
if (vaultSpec) {
|
|
613
|
+
bootProgress.set("vault", formatStatusLine("vault unseal…", undefined, "pending"));
|
|
470
614
|
const { ensureOpenBao, openbaoStackEnv } = await import("./openbao-bootstrap.ts");
|
|
471
615
|
const vaultNames = await tryLoadVaultSecretNames(cwd);
|
|
472
616
|
const boot = await ensureOpenBao({
|
|
@@ -476,49 +620,139 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
|
476
620
|
});
|
|
477
621
|
Object.assign(stackEnv!, openbaoStackEnv(boot));
|
|
478
622
|
Object.assign(dockerStarted.env, openbaoStackEnv(boot));
|
|
623
|
+
// Rewrite .env.docker with the full stack env (including token) so a
|
|
624
|
+
// later ensureDockerStack / writeDerivedFiles does not drop it.
|
|
479
625
|
const envPath = resolve(dockerOut, ".env.docker");
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
626
|
+
await Bun.write(envPath, formatStackEnv(stackEnv!));
|
|
627
|
+
bootProgress.set("vault", formatStatusLine("vault ready", undefined, "ready"));
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Stream compose health until infra is up (AI may stay pending — model load).
|
|
631
|
+
const aiServiceName = derived.specs.find((s) => s.role === "ai")?.serviceName;
|
|
632
|
+
const labelForService = (serviceName: string): string => {
|
|
633
|
+
const spec = derived.specs.find((s) => s.serviceName === serviceName);
|
|
634
|
+
return spec ? roleLabel(spec.role) : serviceName;
|
|
635
|
+
};
|
|
636
|
+
const onHealthUpdate = (service: string, status: DevStatus) => {
|
|
637
|
+
const label = labelForService(service);
|
|
638
|
+
const text =
|
|
639
|
+
status === "ready"
|
|
640
|
+
? `${label} ready`
|
|
641
|
+
: status === "error"
|
|
642
|
+
? `${label} unhealthy`
|
|
643
|
+
: `${label} starting…`;
|
|
644
|
+
bootProgress.set(`svc:${service}`, formatStatusLine(text, undefined, status));
|
|
645
|
+
};
|
|
646
|
+
const healthByService = options.composeHealth
|
|
647
|
+
? await options.composeHealth({
|
|
648
|
+
files: composeFiles,
|
|
649
|
+
cwd: dockerOut,
|
|
650
|
+
env: dockerStarted.env,
|
|
651
|
+
onUpdate: onHealthUpdate,
|
|
652
|
+
})
|
|
653
|
+
: await (
|
|
654
|
+
await import("../docker/compose-health.ts")
|
|
655
|
+
).watchComposeHealth({
|
|
656
|
+
files: composeFiles,
|
|
657
|
+
cwd: dockerOut,
|
|
658
|
+
env: dockerStarted.env,
|
|
659
|
+
timeoutMs: 20_000,
|
|
660
|
+
isDone: (map) => {
|
|
661
|
+
// Empty ps (compose just-created / injectable gap): do not spin 20s.
|
|
662
|
+
if (map.size === 0) return true;
|
|
663
|
+
return [...map.entries()].every(
|
|
664
|
+
([name, s]) => name === aiServiceName || s === "ready" || s === "error",
|
|
665
|
+
);
|
|
666
|
+
},
|
|
667
|
+
onUpdate: onHealthUpdate,
|
|
668
|
+
});
|
|
669
|
+
liveComposeHealth = healthByService;
|
|
670
|
+
if (pendingStackSummary) {
|
|
671
|
+
pendingStackSummary = {
|
|
672
|
+
...pendingStackSummary,
|
|
673
|
+
services: pendingStackSummary.services.map((svc) => ({
|
|
674
|
+
...svc,
|
|
675
|
+
status: svc.serviceName
|
|
676
|
+
? (healthByService.get(svc.serviceName) ?? svc.status ?? "pending")
|
|
677
|
+
: (svc.status ?? "pending"),
|
|
678
|
+
})),
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// AI model: Ollama pulls via HTTP (blocking with progress). OpenAI-compatible
|
|
683
|
+
// servers (llama.cpp / vLLM / SGLang) load in-container — poll readiness in
|
|
684
|
+
// the background. Never treat compose "healthy" as model-ready.
|
|
685
|
+
const aiSpec = derived.specs.find((s) => s.role === "ai");
|
|
686
|
+
if (aiSpec) {
|
|
687
|
+
const { recipeFor } = await import("../docker/recipes/index.ts");
|
|
688
|
+
const recipeId = recipeFor(aiSpec.image).id;
|
|
689
|
+
const model =
|
|
690
|
+
stackEnv!.OKE_AI_MODEL?.trim() ||
|
|
691
|
+
process.env.OKE_AI_MODEL?.trim() ||
|
|
692
|
+
(recipeId === "ollama" ? "qwen3.5:9b" : "smollm2");
|
|
693
|
+
const url = `http://127.0.0.1:${aiSpec.hostPort}`;
|
|
694
|
+
const paintAi = (status: DevStatus, detail?: string) => {
|
|
695
|
+
heroAiStatus = status;
|
|
696
|
+
if (!pendingStackSummary) return;
|
|
697
|
+
pendingStackSummary = {
|
|
698
|
+
...pendingStackSummary,
|
|
699
|
+
services: pendingStackSummary.services.map((svc) =>
|
|
700
|
+
svc.serviceName === aiSpec.serviceName
|
|
701
|
+
? {
|
|
702
|
+
...svc,
|
|
703
|
+
status,
|
|
704
|
+
detail: detail ?? svc.detail,
|
|
705
|
+
}
|
|
706
|
+
: svc,
|
|
707
|
+
),
|
|
708
|
+
};
|
|
709
|
+
};
|
|
710
|
+
if (recipeId === "ollama") {
|
|
711
|
+
bootProgress.set(
|
|
712
|
+
"ai-model",
|
|
713
|
+
formatStatusLine(`AI ${model} — ensuring…`, undefined, "pending"),
|
|
714
|
+
);
|
|
715
|
+
const { ensureOllamaModel } = await import("../docker/ollama-pull.ts");
|
|
716
|
+
await ensureOllamaModel({
|
|
717
|
+
url,
|
|
718
|
+
model,
|
|
719
|
+
onStatus: (line) =>
|
|
720
|
+
bootProgress.set("ai-model", formatStatusLine(line, undefined, "pending")),
|
|
721
|
+
});
|
|
722
|
+
pendingAiModelWatch = { url, model, kind: "ollama" };
|
|
723
|
+
paintAi("ready", model);
|
|
724
|
+
} else if (recipeId === "llama-cpp" || recipeId === "vllm" || recipeId === "sglang") {
|
|
725
|
+
pendingAiModelWatch = {
|
|
726
|
+
url: `${url}/v1`,
|
|
727
|
+
model,
|
|
728
|
+
kind: "openai-compatible",
|
|
729
|
+
};
|
|
730
|
+
bootProgress.set(
|
|
731
|
+
"ai-model",
|
|
732
|
+
formatStatusLine(`AI ${model} — probing…`, undefined, "pending"),
|
|
733
|
+
);
|
|
734
|
+
const { probeAiModelStatus, formatAiModelStatusMessage } =
|
|
735
|
+
await import("../docker/ai-model-status.ts");
|
|
736
|
+
const snap = await probeAiModelStatus({
|
|
737
|
+
url: `${url}/v1`,
|
|
738
|
+
model,
|
|
739
|
+
kind: "openai-compatible",
|
|
740
|
+
});
|
|
741
|
+
const status = devStatusFromAiPhase(snap.phase);
|
|
742
|
+
paintAi(status, snap.phase === "ready" ? model : `${model} · ${snap.phase}`);
|
|
743
|
+
bootProgress.set(
|
|
744
|
+
"ai-model",
|
|
745
|
+
formatStatusLine(formatAiModelStatusMessage(snap), undefined, status),
|
|
746
|
+
);
|
|
747
|
+
}
|
|
486
748
|
}
|
|
487
749
|
} catch (err) {
|
|
488
750
|
const msg = err instanceof Error ? err.message : String(err);
|
|
489
751
|
console.error(msg);
|
|
490
752
|
console.error("oke dev: docker mode failed — fix compose, or run `oke mode local`");
|
|
753
|
+
restoreWarn();
|
|
491
754
|
return { code: 1 };
|
|
492
755
|
}
|
|
493
|
-
const roleLabel = (role: string): string => {
|
|
494
|
-
if (role === "store.sql") return "postgres";
|
|
495
|
-
if (role === "store.kv") return "redis";
|
|
496
|
-
if (role === "store.files") return "files";
|
|
497
|
-
if (role === "channel.email") return "mail";
|
|
498
|
-
return role;
|
|
499
|
-
};
|
|
500
|
-
const extraLabel = (role: string, containerPort: number): string => {
|
|
501
|
-
if (role === "channel.email" && containerPort === 8025) return "mail-ui";
|
|
502
|
-
if (role === "store.files" && containerPort === 9001) return "files-ui";
|
|
503
|
-
return `${roleLabel(role)}+${containerPort}`;
|
|
504
|
-
};
|
|
505
|
-
const appDrivers = [
|
|
506
|
-
...(stackRoles.includes("store.sql") ? [stackSqlDriver] : []),
|
|
507
|
-
...(stackRoles.includes("store.kv") ? [stackKvDriver] : []),
|
|
508
|
-
];
|
|
509
|
-
write(
|
|
510
|
-
formatStackSummary({
|
|
511
|
-
project: `oke-${appSlug}`,
|
|
512
|
-
services: derived.specs.flatMap((spec) => [
|
|
513
|
-
{ label: roleLabel(spec.role), hostPort: spec.hostPort },
|
|
514
|
-
...resolveExtraPorts(spec, { images, instanceId }).map((p) => ({
|
|
515
|
-
label: extraLabel(spec.role, p.containerPort),
|
|
516
|
-
hostPort: p.hostPort,
|
|
517
|
-
})),
|
|
518
|
-
]),
|
|
519
|
-
appDrivers,
|
|
520
|
-
}),
|
|
521
|
-
);
|
|
522
756
|
}
|
|
523
757
|
}
|
|
524
758
|
|
|
@@ -533,36 +767,224 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
|
533
767
|
stackEnv,
|
|
534
768
|
};
|
|
535
769
|
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
if (typeof pkg.version === "string") okeVersion = pkg.version;
|
|
542
|
-
} catch {
|
|
543
|
-
// shipped binary may not sit next to package.json
|
|
770
|
+
const heroAiModel =
|
|
771
|
+
stackEnv?.OKE_AI_MODEL?.trim() || process.env.OKE_AI_MODEL?.trim() || undefined;
|
|
772
|
+
// OpenAI-compatible model load can outlive compose health — default AI ● to pending.
|
|
773
|
+
if (pendingAiModelWatch && heroAiStatus === undefined) {
|
|
774
|
+
heroAiStatus = "pending";
|
|
544
775
|
}
|
|
545
|
-
|
|
546
776
|
const heroSnapshot = buildDevHeroSnapshot({
|
|
547
777
|
config: loadedConfig,
|
|
548
778
|
docker: mode === "docker",
|
|
549
779
|
sqlDriver: mode === "docker" ? stackSqlDriver : undefined,
|
|
550
780
|
kvDriver: mode === "docker" ? stackKvDriver : undefined,
|
|
781
|
+
aiModel: heroAiModel,
|
|
782
|
+
aiStatus: heroAiStatus,
|
|
551
783
|
version: okeVersion,
|
|
552
784
|
nodeEnv: "development",
|
|
553
785
|
});
|
|
786
|
+
const aiDriverLabel = (() => {
|
|
787
|
+
const detail = heroSnapshot.elements.find((r) => r.element === "ai")?.detail ?? "—";
|
|
788
|
+
if (detail === "—" || detail === "") return detail;
|
|
789
|
+
return detail.split(" · ")[0] ?? detail;
|
|
790
|
+
})();
|
|
791
|
+
|
|
792
|
+
const worstStatus = (statuses: readonly (DevStatus | undefined)[]): DevStatus | undefined => {
|
|
793
|
+
const present = statuses.filter((s): s is DevStatus => s !== undefined);
|
|
794
|
+
if (present.length === 0) return undefined;
|
|
795
|
+
if (present.includes("error")) return "error";
|
|
796
|
+
if (present.includes("pending")) return "pending";
|
|
797
|
+
if (present.includes("idle")) return "idle";
|
|
798
|
+
return "ready";
|
|
799
|
+
};
|
|
554
800
|
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
801
|
+
const elementStatusFromHealth = (
|
|
802
|
+
element: string,
|
|
803
|
+
aiStatus: DevStatus | undefined,
|
|
804
|
+
): DevStatus | undefined => {
|
|
805
|
+
const h = liveComposeHealth;
|
|
806
|
+
if (h.size === 0) return element === "ai" ? aiStatus : undefined;
|
|
807
|
+
const one = (name: string) => h.get(name);
|
|
808
|
+
switch (element) {
|
|
809
|
+
case "ai": {
|
|
810
|
+
const container = one("ai");
|
|
811
|
+
if (container === "error") return "error";
|
|
812
|
+
return aiStatus ?? container;
|
|
813
|
+
}
|
|
814
|
+
case "vault":
|
|
815
|
+
return one("vault");
|
|
816
|
+
case "signal":
|
|
817
|
+
case "gate":
|
|
818
|
+
return one("store-kv");
|
|
819
|
+
case "channel":
|
|
820
|
+
return one("channel-email");
|
|
821
|
+
case "clock":
|
|
822
|
+
return one("store-sql");
|
|
823
|
+
case "store":
|
|
824
|
+
return worstStatus([
|
|
825
|
+
one("store-sql"),
|
|
826
|
+
one("store-kv"),
|
|
827
|
+
one("store-files"),
|
|
828
|
+
one("store-index"),
|
|
829
|
+
]);
|
|
830
|
+
default:
|
|
831
|
+
return undefined;
|
|
832
|
+
}
|
|
833
|
+
};
|
|
834
|
+
|
|
835
|
+
const paintBootBoard = (aiStatus: DevStatus | undefined) => {
|
|
836
|
+
const aiSt = aiStatus ?? heroAiStatus;
|
|
837
|
+
const elements = heroSnapshot.elements.map((row) => {
|
|
838
|
+
const fromHealth = elementStatusFromHealth(row.element, aiSt);
|
|
839
|
+
const st =
|
|
840
|
+
fromHealth ?? row.status ?? (row.detail === "—" || row.detail === "" ? "idle" : "ready");
|
|
841
|
+
if (row.element !== "ai") {
|
|
842
|
+
return fromHealth ? { ...row, status: st } : row;
|
|
843
|
+
}
|
|
844
|
+
let detail = aiDriverLabel;
|
|
845
|
+
if (detail !== "—" && heroAiModel) {
|
|
846
|
+
const phase =
|
|
847
|
+
st === "ready" || st === "idle"
|
|
848
|
+
? null
|
|
849
|
+
: st === "error"
|
|
850
|
+
? liveComposeHealth.get("ai") === "error"
|
|
851
|
+
? "stopped"
|
|
852
|
+
: "error"
|
|
853
|
+
: "loading";
|
|
854
|
+
detail = phase
|
|
855
|
+
? `${aiDriverLabel} · ${heroAiModel} · ${phase}`
|
|
856
|
+
: `${aiDriverLabel} · ${heroAiModel}`;
|
|
857
|
+
}
|
|
858
|
+
return { ...row, status: st, detail };
|
|
859
|
+
});
|
|
860
|
+
const elementsBlock = formatDevHeroDetails({ elements });
|
|
861
|
+
const stackBlock = pendingStackSummary
|
|
862
|
+
? formatStackSummary({
|
|
863
|
+
project: pendingStackSummary.project,
|
|
864
|
+
services: pendingStackSummary.services.map((svc) => {
|
|
865
|
+
const fromHealth = svc.serviceName ? liveComposeHealth.get(svc.serviceName) : undefined;
|
|
866
|
+
let status = fromHealth ?? svc.status ?? "pending";
|
|
867
|
+
let detail = svc.detail;
|
|
868
|
+
if (svc.serviceName === "ai") {
|
|
869
|
+
if (fromHealth === "error") {
|
|
870
|
+
status = "error";
|
|
871
|
+
detail = heroAiModel ? `${heroAiModel} · stopped` : "stopped";
|
|
872
|
+
} else if (aiSt !== undefined) {
|
|
873
|
+
status = aiSt === "ready" && fromHealth === "pending" ? "pending" : aiSt;
|
|
874
|
+
detail = !heroAiModel
|
|
875
|
+
? detail
|
|
876
|
+
: status === "ready"
|
|
877
|
+
? heroAiModel
|
|
878
|
+
: `${heroAiModel} · ${status === "error" ? "error" : "loading"}`;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
return { ...svc, status, ...(detail !== undefined ? { detail } : {}) };
|
|
882
|
+
}),
|
|
883
|
+
appDrivers: pendingStackSummary.appDrivers,
|
|
884
|
+
})
|
|
885
|
+
: "";
|
|
886
|
+
// Breath between elements and Docker — they read as two panes, not one block.
|
|
887
|
+
const gap = elementsBlock && stackBlock ? "\n" : "";
|
|
888
|
+
return `${elementsBlock}${gap}${stackBlock}`;
|
|
889
|
+
};
|
|
890
|
+
|
|
891
|
+
// Drop ephemeral progress — final elements + Docker board replaces it.
|
|
892
|
+
bootProgress.clear();
|
|
893
|
+
const bootBoard = createAnchoredBoard(write);
|
|
894
|
+
bootBoard.paint(paintBootBoard(heroAiStatus));
|
|
895
|
+
// Track everything printed below so ● can rewrite in place for the session.
|
|
896
|
+
write = bootBoard.wrapWrite(write);
|
|
897
|
+
|
|
898
|
+
if (options.dryRun) {
|
|
899
|
+
bootBoard.stop();
|
|
900
|
+
restoreWarn();
|
|
901
|
+
return { code: 0, plan };
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
const repaintBoard = (aiStatus?: DevStatus): void => {
|
|
905
|
+
bootBoard.paint(paintBootBoard(aiStatus ?? heroAiStatus));
|
|
906
|
+
};
|
|
907
|
+
|
|
908
|
+
if (pendingAiModelWatch) {
|
|
909
|
+
const target = pendingAiModelWatch;
|
|
910
|
+
pendingAiModelWatch = null;
|
|
911
|
+
const { startAiModelWatch } = await import("../docker/ai-model-status.ts");
|
|
912
|
+
let resolveFirstAi: (() => void) | undefined;
|
|
913
|
+
const firstAiPaint = new Promise<void>((resolve) => {
|
|
914
|
+
resolveFirstAi = resolve;
|
|
915
|
+
});
|
|
916
|
+
let sawAiPaint = false;
|
|
917
|
+
stopAiModelWatch = startAiModelWatch({
|
|
918
|
+
url: target.url,
|
|
919
|
+
model: target.model,
|
|
920
|
+
kind: target.kind,
|
|
921
|
+
onStatus: (_line, status) => {
|
|
922
|
+
const st = devStatusFromAiPhase(status.phase);
|
|
923
|
+
// Container stopped wins over model-phase "loading".
|
|
924
|
+
if (liveComposeHealth.get("ai") === "error") {
|
|
925
|
+
heroAiStatus = "error";
|
|
926
|
+
repaintBoard("error");
|
|
927
|
+
if (!sawAiPaint) {
|
|
928
|
+
sawAiPaint = true;
|
|
929
|
+
resolveFirstAi?.();
|
|
930
|
+
}
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
heroAiStatus = st;
|
|
934
|
+
// Status ● lives on the board above Logs — not in the log stream.
|
|
935
|
+
repaintBoard(st);
|
|
936
|
+
if (!sawAiPaint) {
|
|
937
|
+
sawAiPaint = true;
|
|
938
|
+
resolveFirstAi?.();
|
|
939
|
+
}
|
|
940
|
+
},
|
|
941
|
+
});
|
|
942
|
+
// Let the first probe repaint AI ● (yellow loading) before we print below.
|
|
943
|
+
await Promise.race([firstAiPaint, Bun.sleep(2_500)]);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// Live ● for the whole session — catch Docker Desktop stops / crashes.
|
|
947
|
+
// Updates the board only (no redis stopped / starting / ready log spam).
|
|
948
|
+
if (dockerStarted && composeFiles) {
|
|
949
|
+
const { startComposeHealthWatch } = await import("../docker/compose-health.ts");
|
|
950
|
+
const started = dockerStarted;
|
|
951
|
+
stopComposeHealthWatch = startComposeHealthWatch({
|
|
952
|
+
files: started.files,
|
|
953
|
+
cwd: started.cwd,
|
|
954
|
+
env: started.env,
|
|
955
|
+
intervalMs: 2_000,
|
|
956
|
+
onChange: (map) => {
|
|
957
|
+
liveComposeHealth = map;
|
|
958
|
+
if (map.get("ai") === "error") heroAiStatus = "error";
|
|
959
|
+
repaintBoard();
|
|
960
|
+
},
|
|
961
|
+
});
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/** Snapshot keys we mutate so `stop()` can leave the parent shell clean. */
|
|
965
|
+
const processEnvSnapshot = new Map<string, string | undefined>();
|
|
966
|
+
const setProcessEnv = (key: string, value: string): void => {
|
|
967
|
+
if (!processEnvSnapshot.has(key)) processEnvSnapshot.set(key, process.env[key]);
|
|
968
|
+
process.env[key] = value;
|
|
969
|
+
};
|
|
970
|
+
const restoreProcessEnv = (): void => {
|
|
971
|
+
for (const [key, prev] of processEnvSnapshot) {
|
|
972
|
+
if (prev === undefined) delete process.env[key];
|
|
973
|
+
else process.env[key] = prev;
|
|
974
|
+
}
|
|
975
|
+
processEnvSnapshot.clear();
|
|
976
|
+
};
|
|
564
977
|
|
|
565
|
-
|
|
978
|
+
// Hydrate parent process.env so schema sync / seed / Console see compose URLs
|
|
979
|
+
// (Backend child gets the same overlay via `env` below). Restored on stop.
|
|
980
|
+
if (stackEnv) {
|
|
981
|
+
for (const [key, value] of Object.entries(stackEnv)) {
|
|
982
|
+
setProcessEnv(key, value);
|
|
983
|
+
}
|
|
984
|
+
setProcessEnv("OKE_DOCKER", "1");
|
|
985
|
+
setProcessEnv("OKE_SQL_DRIVER", stackSqlDriver);
|
|
986
|
+
setProcessEnv("OKE_KV_DRIVER", stackKvDriver);
|
|
987
|
+
}
|
|
566
988
|
|
|
567
989
|
// One-shot schema sync for the session profile (both local and docker):
|
|
568
990
|
// emits schema.generated.ts for the active dialect, then pushes via
|
|
@@ -571,12 +993,24 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
|
571
993
|
if (!options.noDbPush) {
|
|
572
994
|
const { syncDevSchema } = await import("./dev-schema-sync.ts");
|
|
573
995
|
try {
|
|
574
|
-
const result = await syncDevSchema(cwd, mode, {
|
|
996
|
+
const result = await syncDevSchema(cwd, mode, {
|
|
997
|
+
write: chromeWrite,
|
|
998
|
+
quietComposeReady: mode === "docker",
|
|
999
|
+
});
|
|
575
1000
|
write(
|
|
576
1001
|
formatStatusLine(
|
|
577
1002
|
`oke db push (${mode} · ${result.dialect}) ${result.code === 0 ? "ok" : "failed"}`,
|
|
578
1003
|
),
|
|
579
1004
|
);
|
|
1005
|
+
if (result.code === 0) {
|
|
1006
|
+
const { maybeAskSeed } = await import("./ask-seed.ts");
|
|
1007
|
+
await maybeAskSeed({
|
|
1008
|
+
cwd,
|
|
1009
|
+
env: mode,
|
|
1010
|
+
write,
|
|
1011
|
+
stdinIsTTY: options.stdinIsTTY ?? process.stdin.isTTY,
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
580
1014
|
} catch (err) {
|
|
581
1015
|
write(
|
|
582
1016
|
formatStatusLine(
|
|
@@ -612,8 +1046,10 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
|
612
1046
|
}
|
|
613
1047
|
: {}),
|
|
614
1048
|
...(meili?.overlay ?? {}),
|
|
1049
|
+
/** Backend child — parent Console owns process-local boot notices. */
|
|
1050
|
+
OKE_SUPPRESS_BOOT_WARN: "1",
|
|
615
1051
|
};
|
|
616
|
-
|
|
1052
|
+
setProcessEnv("OKE_DEV_REQUEST_LOG", "1");
|
|
617
1053
|
|
|
618
1054
|
const startApp = options.startApp ?? ((entryPath, appEnv) => startAppHot(cwd, entryPath, appEnv));
|
|
619
1055
|
|
|
@@ -779,6 +1215,9 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
|
779
1215
|
}
|
|
780
1216
|
|
|
781
1217
|
write(formatDevLogSeparator());
|
|
1218
|
+
if (dockerStarted && (options.stdinIsTTY ?? process.stdin.isTTY)) {
|
|
1219
|
+
write(formatDevControlsHint());
|
|
1220
|
+
}
|
|
782
1221
|
|
|
783
1222
|
const watchFs: DevWatchFn =
|
|
784
1223
|
options.watchFs ?? ((path, watchOptions, listener) => watch(path, watchOptions, listener));
|
|
@@ -794,10 +1233,20 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
|
794
1233
|
}
|
|
795
1234
|
});
|
|
796
1235
|
|
|
1236
|
+
let stopDevControls: (() => void) | null = null;
|
|
1237
|
+
|
|
797
1238
|
let stopped = false;
|
|
798
1239
|
const stop = () => {
|
|
799
1240
|
if (stopped) return;
|
|
800
1241
|
stopped = true;
|
|
1242
|
+
restoreWarn();
|
|
1243
|
+
stopDevControls?.();
|
|
1244
|
+
stopDevControls = null;
|
|
1245
|
+
stopAiModelWatch?.();
|
|
1246
|
+
stopAiModelWatch = null;
|
|
1247
|
+
stopComposeHealthWatch?.();
|
|
1248
|
+
stopComposeHealthWatch = null;
|
|
1249
|
+
bootBoard.stop();
|
|
801
1250
|
autoPushRunner.cancel();
|
|
802
1251
|
watcher.close();
|
|
803
1252
|
meili?.stop();
|
|
@@ -814,8 +1263,8 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
|
814
1263
|
const args = ["compose", ...files.flatMap((f) => ["-f", f]), "stop"];
|
|
815
1264
|
const result = Bun.spawnSync(["docker", ...args], {
|
|
816
1265
|
cwd: dir,
|
|
817
|
-
stdout: "
|
|
818
|
-
stderr: "
|
|
1266
|
+
stdout: "pipe",
|
|
1267
|
+
stderr: "pipe",
|
|
819
1268
|
env: { ...process.env, ...env },
|
|
820
1269
|
});
|
|
821
1270
|
if (result.exitCode !== 0) {
|
|
@@ -833,6 +1282,7 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
|
833
1282
|
process.off("SIGINT", onSignal);
|
|
834
1283
|
process.off("SIGTERM", onSignal);
|
|
835
1284
|
process.off("SIGHUP", onSignal);
|
|
1285
|
+
restoreProcessEnv();
|
|
836
1286
|
};
|
|
837
1287
|
|
|
838
1288
|
const onSignal = () => {
|
|
@@ -866,6 +1316,97 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
|
866
1316
|
|
|
867
1317
|
if (options.onReady) await options.onReady(session);
|
|
868
1318
|
|
|
1319
|
+
// Keyboard controls (docker + TTY): start/stop/restart services without leaving oke dev.
|
|
1320
|
+
if (keepAlive && dockerStarted && (options.stdinIsTTY ?? process.stdin.isTTY)) {
|
|
1321
|
+
const started = dockerStarted;
|
|
1322
|
+
const syncComposeBoard = async (): Promise<void> => {
|
|
1323
|
+
const { readComposeHealth } = await import("../docker/compose-health.ts");
|
|
1324
|
+
liveComposeHealth = await readComposeHealth({
|
|
1325
|
+
files: started.files,
|
|
1326
|
+
cwd: started.cwd,
|
|
1327
|
+
env: started.env,
|
|
1328
|
+
});
|
|
1329
|
+
if (liveComposeHealth.get("ai") === "error") heroAiStatus = "error";
|
|
1330
|
+
repaintBoard();
|
|
1331
|
+
};
|
|
1332
|
+
const refreshChrome = async (): Promise<void> => {
|
|
1333
|
+
await syncComposeBoard();
|
|
1334
|
+
// Clear log noise; reprint head + latest ● board + surfaces + empty Logs.
|
|
1335
|
+
write(clearTerminalScreen());
|
|
1336
|
+
bootBoard.reset();
|
|
1337
|
+
write(
|
|
1338
|
+
formatDevBanner({
|
|
1339
|
+
profile: earlyProfile,
|
|
1340
|
+
runtimeEnv: resolveDevRuntimeEnv(earlyProfile),
|
|
1341
|
+
system: formatHeroSystemLine(),
|
|
1342
|
+
version: okeVersion,
|
|
1343
|
+
}),
|
|
1344
|
+
);
|
|
1345
|
+
bootBoard.paint(paintBootBoard(heroAiStatus));
|
|
1346
|
+
write(formatAppReadyLine(`http://127.0.0.1:${boundAppPort}`));
|
|
1347
|
+
write(formatServiceLine("Console", `http://127.0.0.1:${boundConsolePort}`));
|
|
1348
|
+
if (mcpServer) {
|
|
1349
|
+
write(formatServiceLine("MCP", `http://127.0.0.1:${boundMcpPort}`));
|
|
1350
|
+
}
|
|
1351
|
+
if (docsMcpServer) {
|
|
1352
|
+
write(formatServiceLine("Docs MCP", `http://127.0.0.1:${boundDocsMcpPort}`));
|
|
1353
|
+
}
|
|
1354
|
+
write(formatDevLogSeparator());
|
|
1355
|
+
write(formatDevControlsHint());
|
|
1356
|
+
};
|
|
1357
|
+
const controls = startDevControls({
|
|
1358
|
+
write,
|
|
1359
|
+
isTTY: options.stdinIsTTY ?? process.stdin.isTTY,
|
|
1360
|
+
onQuit: () => {
|
|
1361
|
+
stop();
|
|
1362
|
+
process.exit(0);
|
|
1363
|
+
},
|
|
1364
|
+
onRefresh: () => refreshChrome(),
|
|
1365
|
+
// Refresh first so help/services never sit in the middle of request logs.
|
|
1366
|
+
onShowPanel: async (body) => {
|
|
1367
|
+
await refreshChrome();
|
|
1368
|
+
write(body);
|
|
1369
|
+
},
|
|
1370
|
+
onComposeSettled: () => syncComposeBoard(),
|
|
1371
|
+
services: () => controlServicesFromStack(pendingStackSummary?.services ?? []),
|
|
1372
|
+
statusOf: (serviceName) => liveComposeHealth.get(serviceName),
|
|
1373
|
+
composeAction: async (action: DevComposeControlAction, serviceNames) => {
|
|
1374
|
+
const files = started.files;
|
|
1375
|
+
const finalArgs =
|
|
1376
|
+
action === "up" && serviceNames.length === 0
|
|
1377
|
+
? ["compose", ...files.flatMap((f) => ["-f", f]), "up", "-d"]
|
|
1378
|
+
: action === "up"
|
|
1379
|
+
? [
|
|
1380
|
+
"compose",
|
|
1381
|
+
...files.flatMap((f) => ["-f", f]),
|
|
1382
|
+
"up",
|
|
1383
|
+
"-d",
|
|
1384
|
+
"--no-deps",
|
|
1385
|
+
...serviceNames,
|
|
1386
|
+
]
|
|
1387
|
+
: ["compose", ...files.flatMap((f) => ["-f", f]), action, ...serviceNames];
|
|
1388
|
+
const proc = Bun.spawn(["docker", ...finalArgs], {
|
|
1389
|
+
cwd: started.cwd,
|
|
1390
|
+
stdout: "pipe",
|
|
1391
|
+
stderr: "pipe",
|
|
1392
|
+
env: { ...process.env, ...started.env },
|
|
1393
|
+
});
|
|
1394
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
1395
|
+
new Response(proc.stdout).text(),
|
|
1396
|
+
new Response(proc.stderr).text(),
|
|
1397
|
+
proc.exited,
|
|
1398
|
+
]);
|
|
1399
|
+
if (code !== 0) {
|
|
1400
|
+
const detail = [stdout.trim(), stderr.trim()].filter(Boolean).join("\n");
|
|
1401
|
+
throw new Error(
|
|
1402
|
+
`docker compose ${action} exited ${code}` + (detail ? `\n${detail}` : ""),
|
|
1403
|
+
);
|
|
1404
|
+
}
|
|
1405
|
+
},
|
|
1406
|
+
});
|
|
1407
|
+
stopDevControls = () => controls.stop();
|
|
1408
|
+
}
|
|
1409
|
+
|
|
869
1410
|
if (!keepAlive) {
|
|
870
1411
|
return { code: 0, plan: session.plan, session };
|
|
871
1412
|
}
|
|
@@ -952,11 +1493,11 @@ async function startAppHot(
|
|
|
952
1493
|
OKE_HOSTNAME: hostname,
|
|
953
1494
|
OKE_READY_PATH: readyPath,
|
|
954
1495
|
OKE_DEV_REQUEST_LOG: "1",
|
|
955
|
-
OKE_DEV_SURFACE: "
|
|
1496
|
+
OKE_DEV_SURFACE: "Backend",
|
|
956
1497
|
};
|
|
957
1498
|
|
|
958
1499
|
// `--no-clear-screen`: Bun must not wipe the hero; the runner clears only
|
|
959
|
-
// request logs on soft reload and reprints
|
|
1500
|
+
// request logs on soft reload and reprints Backend / Console / MCP URLs.
|
|
960
1501
|
const proc = Bun.spawn(["bun", "--hot", "--no-clear-screen", runner], {
|
|
961
1502
|
cwd,
|
|
962
1503
|
env,
|