okengine 0.9.1 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +97 -9
- 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/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/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-app-runner.ts +6 -0
- 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 +633 -66
- package/src/cli/docker-cli.test.ts +15 -0
- 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/cli/start.ts +12 -1
- package/src/compiler/effects-infer.ts +37 -8
- package/src/compiler/extract.test.ts +125 -0
- package/src/compiler/extract.ts +31 -6
- 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.test.ts +28 -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 +131 -10
- 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 +113 -10
- 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/effects-stamping.test.ts +207 -0
- package/src/kernel/errors.ts +11 -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 +153 -14
- package/src/manifest/sql-resource.ts +41 -0
- package/src/manifest/types.ts +8 -2
- 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,32 @@ 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>>;
|
|
236
|
+
/**
|
|
237
|
+
* Injectable `docker compose ps` runner for live health polls
|
|
238
|
+
* ({@link startComposeHealthWatch} / keyboard refresh). Default: real
|
|
239
|
+
* `docker`. When {@link DevOptions.composeHealth} is set and this is
|
|
240
|
+
* omitted, live polls return empty (unit tests never shell out).
|
|
241
|
+
*
|
|
242
|
+
* @param args - Args after `docker` (e.g. `compose -f … ps -a …`)
|
|
243
|
+
* @param cwd - Compose directory
|
|
244
|
+
* @param env - Stack env merged into the process env
|
|
245
|
+
*/
|
|
246
|
+
readonly composeHealthRun?: (
|
|
247
|
+
args: readonly string[],
|
|
248
|
+
cwd: string,
|
|
249
|
+
env: Record<string, string>,
|
|
250
|
+
) => Promise<string>;
|
|
201
251
|
/**
|
|
202
252
|
* Regenerate client types (injectable).
|
|
203
253
|
*
|
|
@@ -260,7 +310,29 @@ export interface DevResult {
|
|
|
260
310
|
* @param options - Flags
|
|
261
311
|
*/
|
|
262
312
|
export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
263
|
-
|
|
313
|
+
/** Reassigned to {@link createAnchoredBoard} wrap after the status board paints. */
|
|
314
|
+
let write: (text: string) => void = options.write ?? ((t) => process.stdout.write(t));
|
|
315
|
+
const chromeWrite = (t: string) => write(formatCliChrome(t));
|
|
316
|
+
/**
|
|
317
|
+
* `docker compose ps` runner for live / default health polls.
|
|
318
|
+
* Explicit inject wins; otherwise a `composeHealth` inject implies tests —
|
|
319
|
+
* never shell out to a real `docker` binary.
|
|
320
|
+
*/
|
|
321
|
+
const composeHealthRun =
|
|
322
|
+
options.composeHealthRun ??
|
|
323
|
+
(options.composeHealth ? async (): Promise<string> => "[]" : undefined);
|
|
324
|
+
const previousWarn = console.warn;
|
|
325
|
+
console.warn = (...args: unknown[]) => {
|
|
326
|
+
const msg = args.map(String).join(" ");
|
|
327
|
+
if (/^oke boot:/i.test(msg.trim())) {
|
|
328
|
+
write(formatBootWarn(msg.trim()));
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
previousWarn.apply(console, args as Parameters<typeof console.warn>);
|
|
332
|
+
};
|
|
333
|
+
const restoreWarn = () => {
|
|
334
|
+
console.warn = previousWarn;
|
|
335
|
+
};
|
|
264
336
|
const cwd = options.cwd ?? process.cwd();
|
|
265
337
|
const preferredApp = options.appPort ?? Number(Bun.env.PORT ?? APP_PORT);
|
|
266
338
|
const preferredConsole = options.consolePort ?? CONSOLE_PORT;
|
|
@@ -301,6 +373,7 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
|
301
373
|
}
|
|
302
374
|
if (!entry) {
|
|
303
375
|
console.error("oke dev: no entry found (src/app.ts)");
|
|
376
|
+
restoreWarn();
|
|
304
377
|
return { code: 1 };
|
|
305
378
|
}
|
|
306
379
|
|
|
@@ -308,6 +381,7 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
|
308
381
|
const explicitDocker = options.docker === true || Array.isArray(options.docker);
|
|
309
382
|
if (explicitLocal && explicitDocker) {
|
|
310
383
|
console.error("oke dev: use either --local or --docker, not both");
|
|
384
|
+
restoreWarn();
|
|
311
385
|
return { code: 1 };
|
|
312
386
|
}
|
|
313
387
|
|
|
@@ -331,6 +405,7 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
|
331
405
|
const chosen = await ask();
|
|
332
406
|
if (chosen === null) {
|
|
333
407
|
console.error("oke dev: cancelled");
|
|
408
|
+
restoreWarn();
|
|
334
409
|
return { code: 1 };
|
|
335
410
|
}
|
|
336
411
|
mode = chosen;
|
|
@@ -340,6 +415,28 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
|
340
415
|
mode = "local";
|
|
341
416
|
}
|
|
342
417
|
|
|
418
|
+
// Paint chrome immediately so docker/vault work is never a blank 6s wait.
|
|
419
|
+
let okeVersion = "0.0.0";
|
|
420
|
+
try {
|
|
421
|
+
const pkg = (await Bun.file(resolve(import.meta.dir, "../../package.json")).json()) as {
|
|
422
|
+
version?: string;
|
|
423
|
+
};
|
|
424
|
+
if (typeof pkg.version === "string") okeVersion = pkg.version;
|
|
425
|
+
} catch {
|
|
426
|
+
// shipped binary may not sit next to package.json
|
|
427
|
+
}
|
|
428
|
+
const earlyProfile = resolveDevProfile({ docker: mode === "docker", nodeEnv: "development" });
|
|
429
|
+
write(
|
|
430
|
+
formatDevBanner({
|
|
431
|
+
profile: earlyProfile,
|
|
432
|
+
runtimeEnv: resolveDevRuntimeEnv(earlyProfile),
|
|
433
|
+
system: formatHeroSystemLine(),
|
|
434
|
+
version: okeVersion,
|
|
435
|
+
}),
|
|
436
|
+
);
|
|
437
|
+
/** Ephemeral compose/vault/health/AI lines — cleared before the final board. */
|
|
438
|
+
const bootProgress: BootProgress = createBootProgress(write);
|
|
439
|
+
|
|
343
440
|
let stackRoles: string[] | null = null;
|
|
344
441
|
let composeFiles: string[] | null = null;
|
|
345
442
|
let stackEnv: Record<string, string> | null = null;
|
|
@@ -351,6 +448,26 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
|
351
448
|
} | null = null;
|
|
352
449
|
let stackSqlDriver = "postgres";
|
|
353
450
|
let stackKvDriver = "redis";
|
|
451
|
+
/** Printed after the hero so Docker ports sit under the Starting column. */
|
|
452
|
+
let pendingStackSummary: {
|
|
453
|
+
readonly project: string;
|
|
454
|
+
readonly services: StackSummaryService[];
|
|
455
|
+
readonly appDrivers: readonly string[];
|
|
456
|
+
} | null = null;
|
|
457
|
+
/** Background AI model poller — cancelled in `stop()`. */
|
|
458
|
+
let stopAiModelWatch: (() => void) | null = null;
|
|
459
|
+
/** Session compose health poller — cancelled in `stop()`. */
|
|
460
|
+
let stopComposeHealthWatch: (() => void) | null = null;
|
|
461
|
+
/** Latest compose ps -a statuses (service name → ●). */
|
|
462
|
+
let liveComposeHealth = new Map<string, DevStatus>();
|
|
463
|
+
/** Start after hero/stack chrome so status lines land in the right place. */
|
|
464
|
+
let pendingAiModelWatch: {
|
|
465
|
+
readonly url: string;
|
|
466
|
+
readonly model: string;
|
|
467
|
+
readonly kind: "openai-compatible" | "ollama";
|
|
468
|
+
} | null = null;
|
|
469
|
+
/** Hero AI status dot (yellow while model loads). */
|
|
470
|
+
let heroAiStatus: DevStatus | undefined;
|
|
354
471
|
let loadedConfig: Awaited<ReturnType<typeof loadOkeConfig>>["config"] | null = null;
|
|
355
472
|
try {
|
|
356
473
|
loadedConfig = (await loadOkeConfig(cwd)).config;
|
|
@@ -371,6 +488,7 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
|
371
488
|
write(formatStatusLine(`meilisearch local server on ${meili.url}`));
|
|
372
489
|
} catch (err) {
|
|
373
490
|
console.error(err instanceof Error ? err.message : String(err));
|
|
491
|
+
restoreWarn();
|
|
374
492
|
return { code: 1 };
|
|
375
493
|
}
|
|
376
494
|
}
|
|
@@ -386,6 +504,7 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
|
386
504
|
images = resolveImages(loaded.config);
|
|
387
505
|
} catch (err) {
|
|
388
506
|
console.error(err instanceof Error ? err.message : String(err));
|
|
507
|
+
restoreWarn();
|
|
389
508
|
return { code: 1 };
|
|
390
509
|
}
|
|
391
510
|
} else {
|
|
@@ -434,6 +553,44 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
|
434
553
|
);
|
|
435
554
|
stackEnv = { ...derived.stackEnv };
|
|
436
555
|
|
|
556
|
+
const roleLabel = (role: string): string => {
|
|
557
|
+
if (role === "store.sql") return "postgres";
|
|
558
|
+
if (role === "store.kv") return "redis";
|
|
559
|
+
if (role === "store.files") return "files";
|
|
560
|
+
if (role === "channel.email") return "mail";
|
|
561
|
+
return role;
|
|
562
|
+
};
|
|
563
|
+
const extraLabel = (role: string, containerPort: number): string => {
|
|
564
|
+
if (role === "channel.email" && containerPort === 8025) return "mail-ui";
|
|
565
|
+
if (role === "store.files" && containerPort === 9001) return "files-ui";
|
|
566
|
+
return `${roleLabel(role)}+${containerPort}`;
|
|
567
|
+
};
|
|
568
|
+
const appDrivers = [
|
|
569
|
+
...(stackRoles.includes("store.sql") ? [stackSqlDriver] : []),
|
|
570
|
+
...(stackRoles.includes("store.kv") ? [stackKvDriver] : []),
|
|
571
|
+
];
|
|
572
|
+
const stackAiModel =
|
|
573
|
+
stackEnv.OKE_AI_MODEL?.trim() || process.env.OKE_AI_MODEL?.trim() || undefined;
|
|
574
|
+
pendingStackSummary = {
|
|
575
|
+
project: `oke-${appSlug}`,
|
|
576
|
+
services: derived.specs.flatMap((spec) => [
|
|
577
|
+
{
|
|
578
|
+
label: roleLabel(spec.role),
|
|
579
|
+
hostPort: spec.hostPort,
|
|
580
|
+
serviceName: spec.serviceName,
|
|
581
|
+
status: "pending" as const,
|
|
582
|
+
...(spec.role === "ai" && stackAiModel ? { detail: stackAiModel } : {}),
|
|
583
|
+
},
|
|
584
|
+
...resolveExtraPorts(spec, { images, instanceId }).map((p) => ({
|
|
585
|
+
label: extraLabel(spec.role, p.containerPort),
|
|
586
|
+
hostPort: p.hostPort,
|
|
587
|
+
serviceName: spec.serviceName,
|
|
588
|
+
status: "pending" as const,
|
|
589
|
+
})),
|
|
590
|
+
]),
|
|
591
|
+
appDrivers,
|
|
592
|
+
};
|
|
593
|
+
|
|
437
594
|
if (!options.dryRun) {
|
|
438
595
|
try {
|
|
439
596
|
await writeDerivedFiles(derived, dockerOut, {
|
|
@@ -445,17 +602,26 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
|
445
602
|
const args = ["compose", ...files.flatMap((f) => ["-f", f]), "up", "-d"];
|
|
446
603
|
const proc = Bun.spawn(["docker", ...args], {
|
|
447
604
|
cwd: dir,
|
|
448
|
-
stdout: "
|
|
449
|
-
stderr: "
|
|
605
|
+
stdout: "pipe",
|
|
606
|
+
stderr: "pipe",
|
|
450
607
|
env: { ...process.env, ...stackEnv! },
|
|
451
608
|
});
|
|
452
|
-
const code = await
|
|
609
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
610
|
+
new Response(proc.stdout).text(),
|
|
611
|
+
new Response(proc.stderr).text(),
|
|
612
|
+
proc.exited,
|
|
613
|
+
]);
|
|
453
614
|
if (code !== 0) {
|
|
454
|
-
|
|
615
|
+
const detail = [stdout.trim(), stderr.trim()].filter(Boolean).join("\n");
|
|
616
|
+
throw new Error(
|
|
617
|
+
`oke dev --docker: docker compose exited ${code}` + (detail ? `\n${detail}` : ""),
|
|
618
|
+
);
|
|
455
619
|
}
|
|
456
620
|
});
|
|
621
|
+
bootProgress.set("compose", formatStatusLine("docker compose up…", undefined, "pending"));
|
|
457
622
|
// Compose files live under docker/; cwd for compose is that directory.
|
|
458
623
|
await up(composeFiles, dockerOut);
|
|
624
|
+
bootProgress.set("compose", formatStatusLine("docker compose up", undefined, "ready"));
|
|
459
625
|
// Remember for SIGINT / terminal close — `stop` keeps volumes (not `down -v`).
|
|
460
626
|
dockerStarted = {
|
|
461
627
|
files: composeFiles,
|
|
@@ -467,6 +633,7 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
|
467
633
|
// least-privilege app token to the app + Console (never root).
|
|
468
634
|
const vaultSpec = derived.specs.find((s) => s.role === "vault");
|
|
469
635
|
if (vaultSpec) {
|
|
636
|
+
bootProgress.set("vault", formatStatusLine("vault unseal…", undefined, "pending"));
|
|
470
637
|
const { ensureOpenBao, openbaoStackEnv } = await import("./openbao-bootstrap.ts");
|
|
471
638
|
const vaultNames = await tryLoadVaultSecretNames(cwd);
|
|
472
639
|
const boot = await ensureOpenBao({
|
|
@@ -476,49 +643,140 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
|
476
643
|
});
|
|
477
644
|
Object.assign(stackEnv!, openbaoStackEnv(boot));
|
|
478
645
|
Object.assign(dockerStarted.env, openbaoStackEnv(boot));
|
|
646
|
+
// Rewrite .env.docker with the full stack env (including token) so a
|
|
647
|
+
// later ensureDockerStack / writeDerivedFiles does not drop it.
|
|
479
648
|
const envPath = resolve(dockerOut, ".env.docker");
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
649
|
+
await Bun.write(envPath, formatStackEnv(stackEnv!));
|
|
650
|
+
bootProgress.set("vault", formatStatusLine("vault ready", undefined, "ready"));
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Stream compose health until infra is up (AI may stay pending — model load).
|
|
654
|
+
const aiServiceName = derived.specs.find((s) => s.role === "ai")?.serviceName;
|
|
655
|
+
const labelForService = (serviceName: string): string => {
|
|
656
|
+
const spec = derived.specs.find((s) => s.serviceName === serviceName);
|
|
657
|
+
return spec ? roleLabel(spec.role) : serviceName;
|
|
658
|
+
};
|
|
659
|
+
const onHealthUpdate = (service: string, status: DevStatus) => {
|
|
660
|
+
const label = labelForService(service);
|
|
661
|
+
const text =
|
|
662
|
+
status === "ready"
|
|
663
|
+
? `${label} ready`
|
|
664
|
+
: status === "error"
|
|
665
|
+
? `${label} unhealthy`
|
|
666
|
+
: `${label} starting…`;
|
|
667
|
+
bootProgress.set(`svc:${service}`, formatStatusLine(text, undefined, status));
|
|
668
|
+
};
|
|
669
|
+
const healthByService = options.composeHealth
|
|
670
|
+
? await options.composeHealth({
|
|
671
|
+
files: composeFiles,
|
|
672
|
+
cwd: dockerOut,
|
|
673
|
+
env: dockerStarted.env,
|
|
674
|
+
onUpdate: onHealthUpdate,
|
|
675
|
+
})
|
|
676
|
+
: await (
|
|
677
|
+
await import("../docker/compose-health.ts")
|
|
678
|
+
).watchComposeHealth({
|
|
679
|
+
files: composeFiles,
|
|
680
|
+
cwd: dockerOut,
|
|
681
|
+
env: dockerStarted.env,
|
|
682
|
+
run: composeHealthRun,
|
|
683
|
+
timeoutMs: 20_000,
|
|
684
|
+
isDone: (map) => {
|
|
685
|
+
// Empty ps (compose just-created / injectable gap): do not spin 20s.
|
|
686
|
+
if (map.size === 0) return true;
|
|
687
|
+
return [...map.entries()].every(
|
|
688
|
+
([name, s]) => name === aiServiceName || s === "ready" || s === "error",
|
|
689
|
+
);
|
|
690
|
+
},
|
|
691
|
+
onUpdate: onHealthUpdate,
|
|
692
|
+
});
|
|
693
|
+
liveComposeHealth = healthByService;
|
|
694
|
+
if (pendingStackSummary) {
|
|
695
|
+
pendingStackSummary = {
|
|
696
|
+
...pendingStackSummary,
|
|
697
|
+
services: pendingStackSummary.services.map((svc) => ({
|
|
698
|
+
...svc,
|
|
699
|
+
status: svc.serviceName
|
|
700
|
+
? (healthByService.get(svc.serviceName) ?? svc.status ?? "pending")
|
|
701
|
+
: (svc.status ?? "pending"),
|
|
702
|
+
})),
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// AI model: Ollama pulls via HTTP (blocking with progress). OpenAI-compatible
|
|
707
|
+
// servers (llama.cpp / vLLM / SGLang) load in-container — poll readiness in
|
|
708
|
+
// the background. Never treat compose "healthy" as model-ready.
|
|
709
|
+
const aiSpec = derived.specs.find((s) => s.role === "ai");
|
|
710
|
+
if (aiSpec) {
|
|
711
|
+
const { recipeFor } = await import("../docker/recipes/index.ts");
|
|
712
|
+
const recipeId = recipeFor(aiSpec.image).id;
|
|
713
|
+
const model =
|
|
714
|
+
stackEnv!.OKE_AI_MODEL?.trim() ||
|
|
715
|
+
process.env.OKE_AI_MODEL?.trim() ||
|
|
716
|
+
(recipeId === "ollama" ? "qwen3.5:9b" : "smollm2");
|
|
717
|
+
const url = `http://127.0.0.1:${aiSpec.hostPort}`;
|
|
718
|
+
const paintAi = (status: DevStatus, detail?: string) => {
|
|
719
|
+
heroAiStatus = status;
|
|
720
|
+
if (!pendingStackSummary) return;
|
|
721
|
+
pendingStackSummary = {
|
|
722
|
+
...pendingStackSummary,
|
|
723
|
+
services: pendingStackSummary.services.map((svc) =>
|
|
724
|
+
svc.serviceName === aiSpec.serviceName
|
|
725
|
+
? {
|
|
726
|
+
...svc,
|
|
727
|
+
status,
|
|
728
|
+
detail: detail ?? svc.detail,
|
|
729
|
+
}
|
|
730
|
+
: svc,
|
|
731
|
+
),
|
|
732
|
+
};
|
|
733
|
+
};
|
|
734
|
+
if (recipeId === "ollama") {
|
|
735
|
+
bootProgress.set(
|
|
736
|
+
"ai-model",
|
|
737
|
+
formatStatusLine(`AI ${model} — ensuring…`, undefined, "pending"),
|
|
738
|
+
);
|
|
739
|
+
const { ensureOllamaModel } = await import("../docker/ollama-pull.ts");
|
|
740
|
+
await ensureOllamaModel({
|
|
741
|
+
url,
|
|
742
|
+
model,
|
|
743
|
+
onStatus: (line) =>
|
|
744
|
+
bootProgress.set("ai-model", formatStatusLine(line, undefined, "pending")),
|
|
745
|
+
});
|
|
746
|
+
pendingAiModelWatch = { url, model, kind: "ollama" };
|
|
747
|
+
paintAi("ready", model);
|
|
748
|
+
} else if (recipeId === "llama-cpp" || recipeId === "vllm" || recipeId === "sglang") {
|
|
749
|
+
pendingAiModelWatch = {
|
|
750
|
+
url: `${url}/v1`,
|
|
751
|
+
model,
|
|
752
|
+
kind: "openai-compatible",
|
|
753
|
+
};
|
|
754
|
+
bootProgress.set(
|
|
755
|
+
"ai-model",
|
|
756
|
+
formatStatusLine(`AI ${model} — probing…`, undefined, "pending"),
|
|
757
|
+
);
|
|
758
|
+
const { probeAiModelStatus, formatAiModelStatusMessage } =
|
|
759
|
+
await import("../docker/ai-model-status.ts");
|
|
760
|
+
const snap = await probeAiModelStatus({
|
|
761
|
+
url: `${url}/v1`,
|
|
762
|
+
model,
|
|
763
|
+
kind: "openai-compatible",
|
|
764
|
+
});
|
|
765
|
+
const status = devStatusFromAiPhase(snap.phase);
|
|
766
|
+
paintAi(status, snap.phase === "ready" ? model : `${model} · ${snap.phase}`);
|
|
767
|
+
bootProgress.set(
|
|
768
|
+
"ai-model",
|
|
769
|
+
formatStatusLine(formatAiModelStatusMessage(snap), undefined, status),
|
|
770
|
+
);
|
|
771
|
+
}
|
|
486
772
|
}
|
|
487
773
|
} catch (err) {
|
|
488
774
|
const msg = err instanceof Error ? err.message : String(err);
|
|
489
775
|
console.error(msg);
|
|
490
776
|
console.error("oke dev: docker mode failed — fix compose, or run `oke mode local`");
|
|
777
|
+
restoreWarn();
|
|
491
778
|
return { code: 1 };
|
|
492
779
|
}
|
|
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
780
|
}
|
|
523
781
|
}
|
|
524
782
|
|
|
@@ -533,36 +791,225 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
|
533
791
|
stackEnv,
|
|
534
792
|
};
|
|
535
793
|
|
|
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
|
|
794
|
+
const heroAiModel =
|
|
795
|
+
stackEnv?.OKE_AI_MODEL?.trim() || process.env.OKE_AI_MODEL?.trim() || undefined;
|
|
796
|
+
// OpenAI-compatible model load can outlive compose health — default AI ● to pending.
|
|
797
|
+
if (pendingAiModelWatch && heroAiStatus === undefined) {
|
|
798
|
+
heroAiStatus = "pending";
|
|
544
799
|
}
|
|
545
|
-
|
|
546
800
|
const heroSnapshot = buildDevHeroSnapshot({
|
|
547
801
|
config: loadedConfig,
|
|
548
802
|
docker: mode === "docker",
|
|
549
803
|
sqlDriver: mode === "docker" ? stackSqlDriver : undefined,
|
|
550
804
|
kvDriver: mode === "docker" ? stackKvDriver : undefined,
|
|
805
|
+
aiModel: heroAiModel,
|
|
806
|
+
aiStatus: heroAiStatus,
|
|
551
807
|
version: okeVersion,
|
|
552
808
|
nodeEnv: "development",
|
|
553
809
|
});
|
|
810
|
+
const aiDriverLabel = (() => {
|
|
811
|
+
const detail = heroSnapshot.elements.find((r) => r.element === "ai")?.detail ?? "—";
|
|
812
|
+
if (detail === "—" || detail === "") return detail;
|
|
813
|
+
return detail.split(" · ")[0] ?? detail;
|
|
814
|
+
})();
|
|
815
|
+
|
|
816
|
+
const worstStatus = (statuses: readonly (DevStatus | undefined)[]): DevStatus | undefined => {
|
|
817
|
+
const present = statuses.filter((s): s is DevStatus => s !== undefined);
|
|
818
|
+
if (present.length === 0) return undefined;
|
|
819
|
+
if (present.includes("error")) return "error";
|
|
820
|
+
if (present.includes("pending")) return "pending";
|
|
821
|
+
if (present.includes("idle")) return "idle";
|
|
822
|
+
return "ready";
|
|
823
|
+
};
|
|
554
824
|
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
825
|
+
const elementStatusFromHealth = (
|
|
826
|
+
element: string,
|
|
827
|
+
aiStatus: DevStatus | undefined,
|
|
828
|
+
): DevStatus | undefined => {
|
|
829
|
+
const h = liveComposeHealth;
|
|
830
|
+
if (h.size === 0) return element === "ai" ? aiStatus : undefined;
|
|
831
|
+
const one = (name: string) => h.get(name);
|
|
832
|
+
switch (element) {
|
|
833
|
+
case "ai": {
|
|
834
|
+
const container = one("ai");
|
|
835
|
+
if (container === "error") return "error";
|
|
836
|
+
return aiStatus ?? container;
|
|
837
|
+
}
|
|
838
|
+
case "vault":
|
|
839
|
+
return one("vault");
|
|
840
|
+
case "signal":
|
|
841
|
+
case "gate":
|
|
842
|
+
return one("store-kv");
|
|
843
|
+
case "channel":
|
|
844
|
+
return one("channel-email");
|
|
845
|
+
case "clock":
|
|
846
|
+
return one("store-sql");
|
|
847
|
+
case "store":
|
|
848
|
+
return worstStatus([
|
|
849
|
+
one("store-sql"),
|
|
850
|
+
one("store-kv"),
|
|
851
|
+
one("store-files"),
|
|
852
|
+
one("store-index"),
|
|
853
|
+
]);
|
|
854
|
+
default:
|
|
855
|
+
return undefined;
|
|
856
|
+
}
|
|
857
|
+
};
|
|
564
858
|
|
|
565
|
-
|
|
859
|
+
const paintBootBoard = (aiStatus: DevStatus | undefined) => {
|
|
860
|
+
const aiSt = aiStatus ?? heroAiStatus;
|
|
861
|
+
const elements = heroSnapshot.elements.map((row) => {
|
|
862
|
+
const fromHealth = elementStatusFromHealth(row.element, aiSt);
|
|
863
|
+
const st =
|
|
864
|
+
fromHealth ?? row.status ?? (row.detail === "—" || row.detail === "" ? "idle" : "ready");
|
|
865
|
+
if (row.element !== "ai") {
|
|
866
|
+
return fromHealth ? { ...row, status: st } : row;
|
|
867
|
+
}
|
|
868
|
+
let detail = aiDriverLabel;
|
|
869
|
+
if (detail !== "—" && heroAiModel) {
|
|
870
|
+
const phase =
|
|
871
|
+
st === "ready" || st === "idle"
|
|
872
|
+
? null
|
|
873
|
+
: st === "error"
|
|
874
|
+
? liveComposeHealth.get("ai") === "error"
|
|
875
|
+
? "stopped"
|
|
876
|
+
: "error"
|
|
877
|
+
: "loading";
|
|
878
|
+
detail = phase
|
|
879
|
+
? `${aiDriverLabel} · ${heroAiModel} · ${phase}`
|
|
880
|
+
: `${aiDriverLabel} · ${heroAiModel}`;
|
|
881
|
+
}
|
|
882
|
+
return { ...row, status: st, detail };
|
|
883
|
+
});
|
|
884
|
+
const elementsBlock = formatDevHeroDetails({ elements });
|
|
885
|
+
const stackBlock = pendingStackSummary
|
|
886
|
+
? formatStackSummary({
|
|
887
|
+
project: pendingStackSummary.project,
|
|
888
|
+
services: pendingStackSummary.services.map((svc) => {
|
|
889
|
+
const fromHealth = svc.serviceName ? liveComposeHealth.get(svc.serviceName) : undefined;
|
|
890
|
+
let status = fromHealth ?? svc.status ?? "pending";
|
|
891
|
+
let detail = svc.detail;
|
|
892
|
+
if (svc.serviceName === "ai") {
|
|
893
|
+
if (fromHealth === "error") {
|
|
894
|
+
status = "error";
|
|
895
|
+
detail = heroAiModel ? `${heroAiModel} · stopped` : "stopped";
|
|
896
|
+
} else if (aiSt !== undefined) {
|
|
897
|
+
status = aiSt === "ready" && fromHealth === "pending" ? "pending" : aiSt;
|
|
898
|
+
detail = !heroAiModel
|
|
899
|
+
? detail
|
|
900
|
+
: status === "ready"
|
|
901
|
+
? heroAiModel
|
|
902
|
+
: `${heroAiModel} · ${status === "error" ? "error" : "loading"}`;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
return { ...svc, status, ...(detail !== undefined ? { detail } : {}) };
|
|
906
|
+
}),
|
|
907
|
+
appDrivers: pendingStackSummary.appDrivers,
|
|
908
|
+
})
|
|
909
|
+
: "";
|
|
910
|
+
// Breath between elements and Docker — they read as two panes, not one block.
|
|
911
|
+
const gap = elementsBlock && stackBlock ? "\n" : "";
|
|
912
|
+
return `${elementsBlock}${gap}${stackBlock}`;
|
|
913
|
+
};
|
|
914
|
+
|
|
915
|
+
// Drop ephemeral progress — final elements + Docker board replaces it.
|
|
916
|
+
bootProgress.clear();
|
|
917
|
+
const bootBoard = createAnchoredBoard(write);
|
|
918
|
+
bootBoard.paint(paintBootBoard(heroAiStatus));
|
|
919
|
+
// Track everything printed below so ● can rewrite in place for the session.
|
|
920
|
+
write = bootBoard.wrapWrite(write);
|
|
921
|
+
|
|
922
|
+
if (options.dryRun) {
|
|
923
|
+
bootBoard.stop();
|
|
924
|
+
restoreWarn();
|
|
925
|
+
return { code: 0, plan };
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
const repaintBoard = (aiStatus?: DevStatus): void => {
|
|
929
|
+
bootBoard.paint(paintBootBoard(aiStatus ?? heroAiStatus));
|
|
930
|
+
};
|
|
931
|
+
|
|
932
|
+
if (pendingAiModelWatch) {
|
|
933
|
+
const target = pendingAiModelWatch;
|
|
934
|
+
pendingAiModelWatch = null;
|
|
935
|
+
const { startAiModelWatch } = await import("../docker/ai-model-status.ts");
|
|
936
|
+
let resolveFirstAi: (() => void) | undefined;
|
|
937
|
+
const firstAiPaint = new Promise<void>((resolve) => {
|
|
938
|
+
resolveFirstAi = resolve;
|
|
939
|
+
});
|
|
940
|
+
let sawAiPaint = false;
|
|
941
|
+
stopAiModelWatch = startAiModelWatch({
|
|
942
|
+
url: target.url,
|
|
943
|
+
model: target.model,
|
|
944
|
+
kind: target.kind,
|
|
945
|
+
onStatus: (_line, status) => {
|
|
946
|
+
const st = devStatusFromAiPhase(status.phase);
|
|
947
|
+
// Container stopped wins over model-phase "loading".
|
|
948
|
+
if (liveComposeHealth.get("ai") === "error") {
|
|
949
|
+
heroAiStatus = "error";
|
|
950
|
+
repaintBoard("error");
|
|
951
|
+
if (!sawAiPaint) {
|
|
952
|
+
sawAiPaint = true;
|
|
953
|
+
resolveFirstAi?.();
|
|
954
|
+
}
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
heroAiStatus = st;
|
|
958
|
+
// Status ● lives on the board above Logs — not in the log stream.
|
|
959
|
+
repaintBoard(st);
|
|
960
|
+
if (!sawAiPaint) {
|
|
961
|
+
sawAiPaint = true;
|
|
962
|
+
resolveFirstAi?.();
|
|
963
|
+
}
|
|
964
|
+
},
|
|
965
|
+
});
|
|
966
|
+
// Let the first probe repaint AI ● (yellow loading) before we print below.
|
|
967
|
+
await Promise.race([firstAiPaint, Bun.sleep(2_500)]);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// Live ● for the whole session — catch Docker Desktop stops / crashes.
|
|
971
|
+
// Updates the board only (no redis stopped / starting / ready log spam).
|
|
972
|
+
if (dockerStarted && composeFiles) {
|
|
973
|
+
const { startComposeHealthWatch } = await import("../docker/compose-health.ts");
|
|
974
|
+
const started = dockerStarted;
|
|
975
|
+
stopComposeHealthWatch = startComposeHealthWatch({
|
|
976
|
+
files: started.files,
|
|
977
|
+
cwd: started.cwd,
|
|
978
|
+
env: started.env,
|
|
979
|
+
run: composeHealthRun,
|
|
980
|
+
intervalMs: 2_000,
|
|
981
|
+
onChange: (map) => {
|
|
982
|
+
liveComposeHealth = map;
|
|
983
|
+
if (map.get("ai") === "error") heroAiStatus = "error";
|
|
984
|
+
repaintBoard();
|
|
985
|
+
},
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/** Snapshot keys we mutate so `stop()` can leave the parent shell clean. */
|
|
990
|
+
const processEnvSnapshot = new Map<string, string | undefined>();
|
|
991
|
+
const setProcessEnv = (key: string, value: string): void => {
|
|
992
|
+
if (!processEnvSnapshot.has(key)) processEnvSnapshot.set(key, process.env[key]);
|
|
993
|
+
process.env[key] = value;
|
|
994
|
+
};
|
|
995
|
+
const restoreProcessEnv = (): void => {
|
|
996
|
+
for (const [key, prev] of processEnvSnapshot) {
|
|
997
|
+
if (prev === undefined) delete process.env[key];
|
|
998
|
+
else process.env[key] = prev;
|
|
999
|
+
}
|
|
1000
|
+
processEnvSnapshot.clear();
|
|
1001
|
+
};
|
|
1002
|
+
|
|
1003
|
+
// Hydrate parent process.env so schema sync / seed / Console see compose URLs
|
|
1004
|
+
// (Backend child gets the same overlay via `env` below). Restored on stop.
|
|
1005
|
+
if (stackEnv) {
|
|
1006
|
+
for (const [key, value] of Object.entries(stackEnv)) {
|
|
1007
|
+
setProcessEnv(key, value);
|
|
1008
|
+
}
|
|
1009
|
+
setProcessEnv("OKE_DOCKER", "1");
|
|
1010
|
+
setProcessEnv("OKE_SQL_DRIVER", stackSqlDriver);
|
|
1011
|
+
setProcessEnv("OKE_KV_DRIVER", stackKvDriver);
|
|
1012
|
+
}
|
|
566
1013
|
|
|
567
1014
|
// One-shot schema sync for the session profile (both local and docker):
|
|
568
1015
|
// emits schema.generated.ts for the active dialect, then pushes via
|
|
@@ -571,12 +1018,24 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
|
571
1018
|
if (!options.noDbPush) {
|
|
572
1019
|
const { syncDevSchema } = await import("./dev-schema-sync.ts");
|
|
573
1020
|
try {
|
|
574
|
-
const result = await syncDevSchema(cwd, mode, {
|
|
1021
|
+
const result = await syncDevSchema(cwd, mode, {
|
|
1022
|
+
write: chromeWrite,
|
|
1023
|
+
quietComposeReady: mode === "docker",
|
|
1024
|
+
});
|
|
575
1025
|
write(
|
|
576
1026
|
formatStatusLine(
|
|
577
1027
|
`oke db push (${mode} · ${result.dialect}) ${result.code === 0 ? "ok" : "failed"}`,
|
|
578
1028
|
),
|
|
579
1029
|
);
|
|
1030
|
+
if (result.code === 0) {
|
|
1031
|
+
const { maybeAskSeed } = await import("./ask-seed.ts");
|
|
1032
|
+
await maybeAskSeed({
|
|
1033
|
+
cwd,
|
|
1034
|
+
env: mode,
|
|
1035
|
+
write,
|
|
1036
|
+
stdinIsTTY: options.stdinIsTTY ?? process.stdin.isTTY,
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
580
1039
|
} catch (err) {
|
|
581
1040
|
write(
|
|
582
1041
|
formatStatusLine(
|
|
@@ -612,8 +1071,10 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
|
612
1071
|
}
|
|
613
1072
|
: {}),
|
|
614
1073
|
...(meili?.overlay ?? {}),
|
|
1074
|
+
/** Backend child — parent Console owns process-local boot notices. */
|
|
1075
|
+
OKE_SUPPRESS_BOOT_WARN: "1",
|
|
615
1076
|
};
|
|
616
|
-
|
|
1077
|
+
setProcessEnv("OKE_DEV_REQUEST_LOG", "1");
|
|
617
1078
|
|
|
618
1079
|
const startApp = options.startApp ?? ((entryPath, appEnv) => startAppHot(cwd, entryPath, appEnv));
|
|
619
1080
|
|
|
@@ -779,6 +1240,9 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
|
779
1240
|
}
|
|
780
1241
|
|
|
781
1242
|
write(formatDevLogSeparator());
|
|
1243
|
+
if (dockerStarted && (options.stdinIsTTY ?? process.stdin.isTTY)) {
|
|
1244
|
+
write(formatDevControlsHint());
|
|
1245
|
+
}
|
|
782
1246
|
|
|
783
1247
|
const watchFs: DevWatchFn =
|
|
784
1248
|
options.watchFs ?? ((path, watchOptions, listener) => watch(path, watchOptions, listener));
|
|
@@ -794,10 +1258,20 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
|
794
1258
|
}
|
|
795
1259
|
});
|
|
796
1260
|
|
|
1261
|
+
let stopDevControls: (() => void) | null = null;
|
|
1262
|
+
|
|
797
1263
|
let stopped = false;
|
|
798
1264
|
const stop = () => {
|
|
799
1265
|
if (stopped) return;
|
|
800
1266
|
stopped = true;
|
|
1267
|
+
restoreWarn();
|
|
1268
|
+
stopDevControls?.();
|
|
1269
|
+
stopDevControls = null;
|
|
1270
|
+
stopAiModelWatch?.();
|
|
1271
|
+
stopAiModelWatch = null;
|
|
1272
|
+
stopComposeHealthWatch?.();
|
|
1273
|
+
stopComposeHealthWatch = null;
|
|
1274
|
+
bootBoard.stop();
|
|
801
1275
|
autoPushRunner.cancel();
|
|
802
1276
|
watcher.close();
|
|
803
1277
|
meili?.stop();
|
|
@@ -814,8 +1288,8 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
|
814
1288
|
const args = ["compose", ...files.flatMap((f) => ["-f", f]), "stop"];
|
|
815
1289
|
const result = Bun.spawnSync(["docker", ...args], {
|
|
816
1290
|
cwd: dir,
|
|
817
|
-
stdout: "
|
|
818
|
-
stderr: "
|
|
1291
|
+
stdout: "pipe",
|
|
1292
|
+
stderr: "pipe",
|
|
819
1293
|
env: { ...process.env, ...env },
|
|
820
1294
|
});
|
|
821
1295
|
if (result.exitCode !== 0) {
|
|
@@ -833,6 +1307,7 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
|
833
1307
|
process.off("SIGINT", onSignal);
|
|
834
1308
|
process.off("SIGTERM", onSignal);
|
|
835
1309
|
process.off("SIGHUP", onSignal);
|
|
1310
|
+
restoreProcessEnv();
|
|
836
1311
|
};
|
|
837
1312
|
|
|
838
1313
|
const onSignal = () => {
|
|
@@ -866,6 +1341,98 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
|
866
1341
|
|
|
867
1342
|
if (options.onReady) await options.onReady(session);
|
|
868
1343
|
|
|
1344
|
+
// Keyboard controls (docker + TTY): start/stop/restart services without leaving oke dev.
|
|
1345
|
+
if (keepAlive && dockerStarted && (options.stdinIsTTY ?? process.stdin.isTTY)) {
|
|
1346
|
+
const started = dockerStarted;
|
|
1347
|
+
const syncComposeBoard = async (): Promise<void> => {
|
|
1348
|
+
const { readComposeHealth } = await import("../docker/compose-health.ts");
|
|
1349
|
+
liveComposeHealth = await readComposeHealth({
|
|
1350
|
+
files: started.files,
|
|
1351
|
+
cwd: started.cwd,
|
|
1352
|
+
env: started.env,
|
|
1353
|
+
run: composeHealthRun,
|
|
1354
|
+
});
|
|
1355
|
+
if (liveComposeHealth.get("ai") === "error") heroAiStatus = "error";
|
|
1356
|
+
repaintBoard();
|
|
1357
|
+
};
|
|
1358
|
+
const refreshChrome = async (): Promise<void> => {
|
|
1359
|
+
await syncComposeBoard();
|
|
1360
|
+
// Clear log noise; reprint head + latest ● board + surfaces + empty Logs.
|
|
1361
|
+
write(clearTerminalScreen());
|
|
1362
|
+
bootBoard.reset();
|
|
1363
|
+
write(
|
|
1364
|
+
formatDevBanner({
|
|
1365
|
+
profile: earlyProfile,
|
|
1366
|
+
runtimeEnv: resolveDevRuntimeEnv(earlyProfile),
|
|
1367
|
+
system: formatHeroSystemLine(),
|
|
1368
|
+
version: okeVersion,
|
|
1369
|
+
}),
|
|
1370
|
+
);
|
|
1371
|
+
bootBoard.paint(paintBootBoard(heroAiStatus));
|
|
1372
|
+
write(formatAppReadyLine(`http://127.0.0.1:${boundAppPort}`));
|
|
1373
|
+
write(formatServiceLine("Console", `http://127.0.0.1:${boundConsolePort}`));
|
|
1374
|
+
if (mcpServer) {
|
|
1375
|
+
write(formatServiceLine("MCP", `http://127.0.0.1:${boundMcpPort}`));
|
|
1376
|
+
}
|
|
1377
|
+
if (docsMcpServer) {
|
|
1378
|
+
write(formatServiceLine("Docs MCP", `http://127.0.0.1:${boundDocsMcpPort}`));
|
|
1379
|
+
}
|
|
1380
|
+
write(formatDevLogSeparator());
|
|
1381
|
+
write(formatDevControlsHint());
|
|
1382
|
+
};
|
|
1383
|
+
const controls = startDevControls({
|
|
1384
|
+
write,
|
|
1385
|
+
isTTY: options.stdinIsTTY ?? process.stdin.isTTY,
|
|
1386
|
+
onQuit: () => {
|
|
1387
|
+
stop();
|
|
1388
|
+
process.exit(0);
|
|
1389
|
+
},
|
|
1390
|
+
onRefresh: () => refreshChrome(),
|
|
1391
|
+
// Refresh first so help/services never sit in the middle of request logs.
|
|
1392
|
+
onShowPanel: async (body) => {
|
|
1393
|
+
await refreshChrome();
|
|
1394
|
+
write(body);
|
|
1395
|
+
},
|
|
1396
|
+
onComposeSettled: () => syncComposeBoard(),
|
|
1397
|
+
services: () => controlServicesFromStack(pendingStackSummary?.services ?? []),
|
|
1398
|
+
statusOf: (serviceName) => liveComposeHealth.get(serviceName),
|
|
1399
|
+
composeAction: async (action: DevComposeControlAction, serviceNames) => {
|
|
1400
|
+
const files = started.files;
|
|
1401
|
+
const finalArgs =
|
|
1402
|
+
action === "up" && serviceNames.length === 0
|
|
1403
|
+
? ["compose", ...files.flatMap((f) => ["-f", f]), "up", "-d"]
|
|
1404
|
+
: action === "up"
|
|
1405
|
+
? [
|
|
1406
|
+
"compose",
|
|
1407
|
+
...files.flatMap((f) => ["-f", f]),
|
|
1408
|
+
"up",
|
|
1409
|
+
"-d",
|
|
1410
|
+
"--no-deps",
|
|
1411
|
+
...serviceNames,
|
|
1412
|
+
]
|
|
1413
|
+
: ["compose", ...files.flatMap((f) => ["-f", f]), action, ...serviceNames];
|
|
1414
|
+
const proc = Bun.spawn(["docker", ...finalArgs], {
|
|
1415
|
+
cwd: started.cwd,
|
|
1416
|
+
stdout: "pipe",
|
|
1417
|
+
stderr: "pipe",
|
|
1418
|
+
env: { ...process.env, ...started.env },
|
|
1419
|
+
});
|
|
1420
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
1421
|
+
new Response(proc.stdout).text(),
|
|
1422
|
+
new Response(proc.stderr).text(),
|
|
1423
|
+
proc.exited,
|
|
1424
|
+
]);
|
|
1425
|
+
if (code !== 0) {
|
|
1426
|
+
const detail = [stdout.trim(), stderr.trim()].filter(Boolean).join("\n");
|
|
1427
|
+
throw new Error(
|
|
1428
|
+
`docker compose ${action} exited ${code}` + (detail ? `\n${detail}` : ""),
|
|
1429
|
+
);
|
|
1430
|
+
}
|
|
1431
|
+
},
|
|
1432
|
+
});
|
|
1433
|
+
stopDevControls = () => controls.stop();
|
|
1434
|
+
}
|
|
1435
|
+
|
|
869
1436
|
if (!keepAlive) {
|
|
870
1437
|
return { code: 0, plan: session.plan, session };
|
|
871
1438
|
}
|
|
@@ -952,11 +1519,11 @@ async function startAppHot(
|
|
|
952
1519
|
OKE_HOSTNAME: hostname,
|
|
953
1520
|
OKE_READY_PATH: readyPath,
|
|
954
1521
|
OKE_DEV_REQUEST_LOG: "1",
|
|
955
|
-
OKE_DEV_SURFACE: "
|
|
1522
|
+
OKE_DEV_SURFACE: "Backend",
|
|
956
1523
|
};
|
|
957
1524
|
|
|
958
1525
|
// `--no-clear-screen`: Bun must not wipe the hero; the runner clears only
|
|
959
|
-
// request logs on soft reload and reprints
|
|
1526
|
+
// request logs on soft reload and reprints Backend / Console / MCP URLs.
|
|
960
1527
|
const proc = Bun.spawn(["bun", "--hot", "--no-clear-screen", runner], {
|
|
961
1528
|
cwd,
|
|
962
1529
|
env,
|