okengine 0.10.1 → 0.10.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/site/content/docs/ai/mcp.mdx +0 -1
  4. package/site/content/docs/elements/ai.mdx +0 -6
  5. package/site/content/docs/elements/channel.mdx +0 -6
  6. package/site/content/docs/elements/clock.mdx +2 -8
  7. package/site/content/docs/elements/flow.mdx +10 -17
  8. package/site/content/docs/elements/gate.mdx +4 -10
  9. package/site/content/docs/elements/signal.mdx +4 -13
  10. package/site/content/docs/elements/store.mdx +9 -16
  11. package/site/content/docs/elements/vault.mdx +1 -7
  12. package/site/content/docs/get-started/basic-usage.mdx +1 -9
  13. package/site/content/docs/get-started/introduction.mdx +1 -1
  14. package/site/content/docs/get-started/why.mdx +1 -2
  15. package/site/content/docs/index.mdx +0 -1
  16. package/site/content/docs/meta.json +0 -1
  17. package/site/content/docs/reference/cli.md +12 -12
  18. package/site/content/docs/reference/fx.mdx +6 -7
  19. package/src/auth/auth.test.ts +1 -2
  20. package/src/auth/bindings.ts +5 -15
  21. package/src/auth/gate-auth.test.ts +2 -2
  22. package/src/cli/ai-setup/ai-setup.test.ts +3 -1
  23. package/src/cli/ai-setup/apply.ts +13 -0
  24. package/src/cli/build.test.ts +67 -0
  25. package/src/cli/build.ts +33 -0
  26. package/src/cli/db-seed.ts +1 -1
  27. package/src/cli/dev-controls.test.ts +43 -48
  28. package/src/cli/dev-controls.ts +23 -172
  29. package/src/cli/dev.test.ts +90 -5
  30. package/src/cli/dev.ts +39 -16
  31. package/src/cli/doctor.ts +98 -5
  32. package/src/cli/load-config.images.test.ts +31 -1
  33. package/src/cli/load-config.ts +2 -2
  34. package/src/cli/safe-defaults.test.ts +1 -1
  35. package/src/client/notes-contract.test.ts +3 -4
  36. package/src/compiler/aot.test.ts +1 -2
  37. package/src/compiler/extract.test.ts +76 -21
  38. package/src/compiler/extract.ts +15 -3
  39. package/src/compiler/fixtures/raw/raw-unannotated.ts +2 -4
  40. package/src/compiler/fixtures/skyport/oke.config.ts +4 -2
  41. package/src/compiler/fixtures/skyport/src/flows/bookings/index.ts +4 -8
  42. package/src/compiler/fixtures/skyport/src/flows/payments/index.ts +1 -2
  43. package/src/compiler/fixtures/skyport/src/flows/support/index.ts +1 -2
  44. package/src/compiler/fixtures/skyport.expected.json +3 -3
  45. package/src/compiler/fixtures/triggers/five-triggers.ts +5 -10
  46. package/src/compiler/generate-adopt.test.ts +85 -0
  47. package/src/compiler/generate-adopt.ts +111 -0
  48. package/src/config/driver-defaults.test.ts +122 -0
  49. package/src/config/driver-defaults.ts +186 -0
  50. package/src/config/index.ts +97 -7
  51. package/src/console/server/dry-run.audit.test.ts +2 -2
  52. package/src/console/server/flows.ts +50 -150
  53. package/src/docker/compose.ts +13 -3
  54. package/src/docker/images-config.test.ts +168 -0
  55. package/src/docker/recipes/llama-cpp.ts +10 -1
  56. package/src/docker/stack-id.ts +1 -0
  57. package/src/elements/channel/declare.ts +27 -1
  58. package/src/elements/clock/chaos-child.ts +3 -6
  59. package/src/elements/clock/chaos.test.ts +1 -2
  60. package/src/elements/clock.test.ts +2 -4
  61. package/src/elements/signal/declare.ts +25 -1
  62. package/src/elements/store/declare.ts +27 -1
  63. package/src/elements/store/resource.ts +6 -11
  64. package/src/elements/store/upsert-app.test.ts +1 -2
  65. package/src/elements/vault/declare.ts +29 -1
  66. package/src/kernel/adopt-barrel-fresh.test.ts +103 -0
  67. package/src/kernel/app.ts +66 -9
  68. package/src/kernel/auto-registry.test.ts +198 -0
  69. package/src/kernel/boot-bind/channel.ts +12 -4
  70. package/src/kernel/boot-bind/clock.ts +3 -3
  71. package/src/kernel/boot-bind/gate.ts +7 -4
  72. package/src/kernel/boot-bind/honor-config.test.ts +5 -5
  73. package/src/kernel/boot-bind/journal.ts +3 -3
  74. package/src/kernel/boot-bind/signal.ts +3 -1
  75. package/src/kernel/boot-bind/store.test.ts +19 -0
  76. package/src/kernel/boot-bind/store.ts +24 -9
  77. package/src/kernel/boot-bind/vault.ts +3 -3
  78. package/src/kernel/boot.test.ts +19 -15
  79. package/src/kernel/boot.ts +81 -1
  80. package/src/kernel/call.test.ts +5 -10
  81. package/src/kernel/compensate.test.ts +3 -6
  82. package/src/kernel/concurrency.test.ts +3 -5
  83. package/src/kernel/correlation.test.ts +4 -8
  84. package/src/kernel/edge.test.ts +1 -1
  85. package/src/kernel/effects-stamping.test.ts +4 -7
  86. package/src/kernel/element-registries.ts +27 -0
  87. package/src/kernel/errors.ts +11 -0
  88. package/src/kernel/flow.test.ts +8 -15
  89. package/src/kernel/flow.ts +12 -14
  90. package/src/kernel/hook-timing.test.ts +2 -2
  91. package/src/kernel/hooks.test.ts +5 -10
  92. package/src/kernel/horizontal-child.ts +5 -10
  93. package/src/kernel/journal-boot.test.ts +2 -4
  94. package/src/kernel/pipeline.test.ts +6 -12
  95. package/src/kernel/plugin/capabilities.test.ts +1 -1
  96. package/src/kernel/plugin/decorate.test.ts +3 -8
  97. package/src/kernel/plugin/scoping.test.ts +6 -16
  98. package/src/kernel/plugin-elements.test.ts +1 -1
  99. package/src/kernel/plugin-needs.test.ts +1 -1
  100. package/src/kernel/ready.test.ts +1 -2
  101. package/src/kernel/registry-isolation.test.ts +5 -5
  102. package/src/kernel/triggers.ts +1 -1
  103. package/src/plugins/anonymous.ts +1 -3
  104. package/src/plugins/compression.test.ts +6 -8
  105. package/src/plugins/config-source.test.ts +1 -1
  106. package/src/plugins/config-source.ts +1 -2
  107. package/src/plugins/cors.test.ts +10 -10
  108. package/src/plugins/csrf.test.ts +2 -2
  109. package/src/plugins/headers.test.ts +14 -15
  110. package/src/plugins/ip-allowlist.test.ts +9 -9
  111. package/src/plugins/magic-link.ts +2 -6
  112. package/src/plugins/maintenance-mode.test.ts +6 -6
  113. package/src/plugins/otp.ts +5 -15
  114. package/src/plugins/passkey.ts +4 -12
  115. package/src/plugins/two-factor.ts +3 -9
  116. package/src/plugins/username.ts +2 -6
  117. package/src/release/measure.ts +1 -1
  118. package/src/runs/runs.test.ts +3 -5
  119. package/src/runtime/serve.test.ts +2 -4
  120. package/src/test/create-test-app.test.ts +3 -7
  121. package/src/test/provisions.integration.test.ts +4 -9
  122. package/src/test/reset-element-registries.ts +31 -0
  123. package/site/content/docs/console/access.mdx +0 -29
  124. package/site/content/docs/console/ai.mdx +0 -35
  125. package/site/content/docs/console/architecture.mdx +0 -35
  126. package/site/content/docs/console/channels.mdx +0 -37
  127. package/site/content/docs/console/clock.mdx +0 -51
  128. package/site/content/docs/console/flows.mdx +0 -31
  129. package/site/content/docs/console/gates.mdx +0 -73
  130. package/site/content/docs/console/index.mdx +0 -54
  131. package/site/content/docs/console/manifest-diff.mdx +0 -34
  132. package/site/content/docs/console/meta.json +0 -24
  133. package/site/content/docs/console/overview.mdx +0 -40
  134. package/site/content/docs/console/plugins.mdx +0 -41
  135. package/site/content/docs/console/privacy.mdx +0 -32
  136. package/site/content/docs/console/runs.mdx +0 -50
  137. package/site/content/docs/console/signals.mdx +0 -40
  138. package/site/content/docs/console/store.mdx +0 -32
  139. package/site/content/docs/console/tenancy.mdx +0 -32
  140. package/site/content/docs/console/traces.mdx +0 -38
  141. package/site/content/docs/console/vault.mdx +0 -41
@@ -7,9 +7,14 @@
7
7
  */
8
8
 
9
9
  import { afterEach, describe, expect, test } from "bun:test";
10
- import { mkdtemp } from "node:fs/promises";
10
+ import { mkdir, mkdtemp, readdir } from "node:fs/promises";
11
11
  import { tmpdir } from "node:os";
12
12
  import { join, resolve } from "node:path";
13
+ import {
14
+ ADOPT_BARREL_FILE,
15
+ generateAdoptBarrel,
16
+ writeAdoptBarrel,
17
+ } from "../compiler/generate-adopt.ts";
13
18
  import type { Manifest } from "../manifest/types.ts";
14
19
  import type { WideEvent } from "../runs/types.ts";
15
20
  import { isDataEnvelope, MCP_DATA_KIND } from "../mcp/data.ts";
@@ -398,13 +403,11 @@ async function writePingApp(dir: string, version: string): Promise<void> {
398
403
  join(dir, "src/flows/ping.ts"),
399
404
  `import { on, flow, http, gate } from ${JSON.stringify(OKE_INDEX)};
400
405
 
401
- export const ping = on(http.get("/ping").gate(gate.public), flow({
402
- name: "ping",
406
+ export const ping = on(http.get("/ping").gate(gate.public), flow("ping", {
403
407
  do: () => ({ version: ${JSON.stringify(version)} as const }),
404
408
  }));
405
409
 
406
- export const slow = on(http.get("/slow").gate(gate.public), flow({
407
- name: "slow",
410
+ export const slow = on(http.get("/slow").gate(gate.public), flow("slow", {
408
411
  do: async () => {
409
412
  const started = ${JSON.stringify(version)};
410
413
  await Bun.sleep(800);
@@ -546,6 +549,88 @@ describe("oke dev hot reload", () => {
546
549
  }, 60_000);
547
550
  });
548
551
 
552
+ describe("oke dev syncAdoptBarrel atomic write", () => {
553
+ test("default path regenerates generated.ts and leaves no .tmp behind", async () => {
554
+ const dir = await mkdtemp(join(tmpdir(), "oke-dev-adopt-atomic-"));
555
+ await mkdir(join(dir, "src/flows/notes"), { recursive: true });
556
+ await Bun.write(join(dir, "src/flows/notes/index.ts"), "export {};\n");
557
+ await Bun.write(join(dir, "src/app.ts"), "export {};\n");
558
+ await Bun.write(join(dir, "src/flows", ADOPT_BARREL_FILE), "// stale stub\n");
559
+
560
+ const { code } = await runDev({
561
+ cwd: dir,
562
+ dryRun: true,
563
+ stdinIsTTY: false,
564
+ write: () => {},
565
+ });
566
+ expect(code).toBe(0);
567
+
568
+ const barrelPath = join(dir, "src/flows", ADOPT_BARREL_FILE);
569
+ const text = await Bun.file(barrelPath).text();
570
+ expect(text).toContain('export * as notes from "./notes/index.ts";');
571
+ expect(text).not.toContain("stale stub");
572
+ expect(await Bun.file(`${barrelPath}.tmp`).exists()).toBe(false);
573
+ const leftovers = (await readdir(join(dir, "src/flows"))).filter((f) => f.includes(".tmp"));
574
+ expect(leftovers).toEqual([]);
575
+ });
576
+
577
+ test("writeAdoptBarrel uses temp then rename — concurrent readers never see a torn file", async () => {
578
+ const dir = await mkdtemp(join(tmpdir(), "oke-dev-adopt-race-"));
579
+ const flowsDir = join(dir, "src/flows");
580
+ await mkdir(flowsDir, { recursive: true });
581
+ const target = join(flowsDir, ADOPT_BARREL_FILE);
582
+
583
+ // Two complete, same-length payloads so a mid-write truncate would yield
584
+ // mixed or short content that fails both equality checks.
585
+ const markerA = "A";
586
+ const markerB = "B";
587
+ const body = (marker: string) => `// ${marker}\n` + `${marker.repeat(64)}\n`.repeat(2_000);
588
+ const payloadA = body(markerA);
589
+ const payloadB = body(markerB);
590
+ expect(payloadA.length).toBe(payloadB.length);
591
+ expect(payloadA).not.toBe(payloadB);
592
+
593
+ await writeAdoptBarrel(dir, payloadA);
594
+ expect(await Bun.file(target).text()).toBe(payloadA);
595
+
596
+ const reads: string[] = [];
597
+ let stop = false;
598
+ const reader = (async () => {
599
+ while (!stop) {
600
+ try {
601
+ reads.push(await Bun.file(target).text());
602
+ } catch {
603
+ // Target briefly missing only if rename races a delete — ignore.
604
+ }
605
+ // Yield so writers interleave; Bun.sleep(0) is enough on this loop.
606
+ await Bun.sleep(0);
607
+ }
608
+ })();
609
+
610
+ for (let i = 0; i < 80; i++) {
611
+ await writeAdoptBarrel(dir, i % 2 === 0 ? payloadB : payloadA);
612
+ }
613
+ stop = true;
614
+ await reader;
615
+
616
+ expect(reads.length).toBeGreaterThan(10);
617
+ for (const sample of reads) {
618
+ // Every observation is a complete prior or complete next payload —
619
+ // never a truncated prefix, never a mix of A/B markers.
620
+ const ok = sample === payloadA || sample === payloadB;
621
+ expect(ok).toBe(true);
622
+ expect(sample.length).toBe(payloadA.length);
623
+ expect(sample.includes(markerA) && sample.includes(markerB)).toBe(false);
624
+ }
625
+ expect(await Bun.file(`${target}.tmp`).exists()).toBe(false);
626
+
627
+ // Same helper the default `oke dev` path uses end-to-end.
628
+ const { source } = await generateAdoptBarrel({ rootDir: dir });
629
+ await writeAdoptBarrel(dir, source);
630
+ expect(await Bun.file(target).text()).toBe(source);
631
+ });
632
+ });
633
+
549
634
  describe("oke dev mode resolution", () => {
550
635
  test("non-TTY + unset mode → local, zero ask, zero docker, no save", async () => {
551
636
  const dir = await mkdtemp(join(tmpdir(), "oke-dev-mode-nontty-"));
package/src/cli/dev.ts CHANGED
@@ -47,7 +47,6 @@ import { clientAdd } from "./client-add.ts";
47
47
  import { resolveDriverId } from "../config/index.ts";
48
48
  import { askDevMode, type AskDevModeFn } from "./ask-dev-mode.ts";
49
49
  import {
50
- controlServicesFromStack,
51
50
  formatDevControlsHint,
52
51
  startDevControls,
53
52
  type DevComposeControlAction,
@@ -177,6 +176,19 @@ export interface DevOptions {
177
176
  * @param filename - Relative path from the watcher
178
177
  */
179
178
  readonly onDbAutoPush?: (filename: string) => void;
179
+ /**
180
+ * Injectable `.adopt()` barrel regeneration (tests). Default: real
181
+ * `generateAdoptBarrel` + atomic write to `<cwd>/src/flows/generated.ts`
182
+ * (`generated.ts.tmp` → rename). Runs once per `oke dev` session, before
183
+ * the entry is resolved/imported — a real file on disk, so `oke dev`'s
184
+ * runtime `import()` and `oke build`'s `Bun.build()` resolve it
185
+ * identically (unlike a virtual-module Bun plugin, which does not — see
186
+ * `compiler/generate-adopt.ts`). Atomic so a concurrent import during
187
+ * `bun --hot` never sees a torn mid-write file.
188
+ *
189
+ * @param cwd - Project root
190
+ */
191
+ readonly syncAdoptBarrel?: (cwd: string) => Promise<readonly string[]>;
180
192
  /** Watch project source changes (injectable for tests). */
181
193
  readonly watchFs?: DevWatchFn;
182
194
  readonly images?: Readonly<Record<string, string>>;
@@ -334,6 +346,28 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
334
346
  console.warn = previousWarn;
335
347
  };
336
348
  const cwd = options.cwd ?? process.cwd();
349
+
350
+ // One-shot `.adopt()` barrel regen for the session — real file on disk
351
+ // (`src/flows/generated.ts`), written atomically (tmp → rename) before the
352
+ // entry is resolved/imported below so a freshly-added flows unit is
353
+ // adoptable without a hand edit. Best-effort like `syncDevSchema` below:
354
+ // a project with no `src/flows` yet (or a synthetic test tree) never
355
+ // blocks the dev session.
356
+ const syncAdoptBarrel =
357
+ options.syncAdoptBarrel ??
358
+ (async (root: string) => {
359
+ const { generateAdoptBarrel, writeAdoptBarrel } =
360
+ await import("../compiler/generate-adopt.ts");
361
+ const { source, units } = await generateAdoptBarrel({ rootDir: root });
362
+ await writeAdoptBarrel(root, source);
363
+ return units;
364
+ });
365
+ try {
366
+ await syncAdoptBarrel(cwd);
367
+ } catch {
368
+ /* best-effort — boot-time assertAdoptBarrelFresh (rootDir opt-in) is the real gate */
369
+ }
370
+
337
371
  const preferredApp = options.appPort ?? Number(Bun.env.PORT ?? APP_PORT);
338
372
  const preferredConsole = options.consolePort ?? CONSOLE_PORT;
339
373
  const preferredMcp = options.mcpPort ?? MCP_PORT;
@@ -1388,29 +1422,18 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
1388
1422
  process.exit(0);
1389
1423
  },
1390
1424
  onRefresh: () => refreshChrome(),
1391
- // Refresh first so help/services never sit in the middle of request logs.
1425
+ // Refresh first so help never sits in the middle of request logs.
1392
1426
  onShowPanel: async (body) => {
1393
1427
  await refreshChrome();
1394
1428
  write(body);
1395
1429
  },
1396
1430
  onComposeSettled: () => syncComposeBoard(),
1397
- services: () => controlServicesFromStack(pendingStackSummary?.services ?? []),
1398
- statusOf: (serviceName) => liveComposeHealth.get(serviceName),
1399
- composeAction: async (action: DevComposeControlAction, serviceNames) => {
1431
+ composeAction: async (action: DevComposeControlAction) => {
1400
1432
  const files = started.files;
1401
1433
  const finalArgs =
1402
- action === "up" && serviceNames.length === 0
1434
+ action === "up"
1403
1435
  ? ["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];
1436
+ : ["compose", ...files.flatMap((f) => ["-f", f]), action];
1414
1437
  const proc = Bun.spawn(["docker", ...finalArgs], {
1415
1438
  cwd: started.cwd,
1416
1439
  stdout: "pipe",
package/src/cli/doctor.ts CHANGED
@@ -3,17 +3,70 @@
3
3
  */
4
4
 
5
5
  import { resolve } from "node:path";
6
+ import type { ConfigEnv, DriverRef, DriversConfig, EnvDriverMap } from "../config/index.ts";
7
+ import { resolveEffectiveDrivers, type EffectiveDriversConfig } from "../config/driver-defaults.ts";
6
8
  import type { Manifest } from "../manifest/types.ts";
7
9
  import { APP_PORT, CONSOLE_PORT, MCP_PORT } from "../runtime/types.ts";
8
10
  import { hasFlag, wantsJson } from "./args.ts";
9
11
  import { checkManifestPiiAsks } from "./doctor-pii.ts";
10
12
  import { EXIT_OK, EXIT_RUNTIME } from "./exit.ts";
11
- import { loadManifest } from "./load-config.ts";
13
+ import { loadManifest, loadOkeConfig } from "./load-config.ts";
12
14
  import { isPortInUse } from "./ports.ts";
13
15
  import { schemaFingerprint, readSchemaFingerprint } from "./schema.ts";
14
16
 
15
17
  export { isPortInUse } from "./ports.ts";
16
18
 
19
+ /**
20
+ * `label → merged EnvDriverMap` pairs, in the fixed order shown by
21
+ * {@link formatDriversSummary} / the `drivers` JSON key.
22
+ *
23
+ * @param drivers - Fully-resolved drivers config
24
+ */
25
+ function flattenEffectiveDrivers(
26
+ drivers: EffectiveDriversConfig,
27
+ ): ReadonlyArray<readonly [string, EnvDriverMap]> {
28
+ return [
29
+ ["store.sql", drivers.store.sql],
30
+ ["store.kv", drivers.store.kv],
31
+ ["store.files", drivers.store.files],
32
+ ["signal", drivers.signal],
33
+ ["clock", drivers.clock],
34
+ ["journal", drivers.journal],
35
+ ["vault", drivers.vault],
36
+ ["channel.email", drivers.channel.email],
37
+ ["channel.sms", drivers.channel.sms],
38
+ ];
39
+ }
40
+
41
+ /**
42
+ * @param ref - Driver ref (string id, rich `{ driver }` object, or unset)
43
+ */
44
+ function driverRefId(ref: DriverRef | undefined): string {
45
+ if (ref === undefined) return "—";
46
+ return typeof ref === "string" ? ref : ref.driver;
47
+ }
48
+
49
+ /**
50
+ * Render the fully-resolved drivers config as aligned text lines — every
51
+ * default plus every override, per env, for a developer whose own
52
+ * `oke.config.ts` only pins a handful of keys.
53
+ *
54
+ * @param drivers - Fully-resolved drivers config
55
+ * @param configEnv - Active env (marked in the active-driver column)
56
+ */
57
+ function formatDriversSummary(drivers: EffectiveDriversConfig, configEnv: ConfigEnv): string {
58
+ const rows = flattenEffectiveDrivers(drivers);
59
+ const nameWidth = Math.max(...rows.map(([name]) => name.length));
60
+ const activeWidth = Math.max(...rows.map(([, map]) => driverRefId(map[configEnv]).length));
61
+ const lines = [`drivers (env=${configEnv}):`];
62
+ for (const [name, map] of rows) {
63
+ const active = driverRefId(map[configEnv]);
64
+ const full = `local=${driverRefId(map.local)} docker=${driverRefId(map.docker)} test=${driverRefId(map.test)} prod=${driverRefId(map.prod)}`;
65
+ lines.push(` ${name.padEnd(nameWidth)} ${active.padEnd(activeWidth)} { ${full} }`);
66
+ }
67
+ return lines.join("\n");
68
+ }
69
+
17
70
  /** One doctor finding. */
18
71
  export interface DoctorFinding {
19
72
  readonly code:
@@ -55,6 +108,19 @@ export interface DoctorOptions {
55
108
  }>;
56
109
  /** Skip live DB drift probe (default false). */
57
110
  readonly skipDbDrift?: boolean;
111
+ /**
112
+ * Inject the `drivers` block directly (tests) — skips loading
113
+ * `oke.config.ts` from disk. When omitted, `oke doctor` best-effort loads
114
+ * `oke.config.ts` from {@link cwd}; a project without one just gets the
115
+ * real, untouched defaults in the drivers summary.
116
+ */
117
+ readonly driversConfig?: DriversConfig;
118
+ /**
119
+ * Active {@link ConfigEnv} for the drivers summary. Default: `docker` when
120
+ * `OKE_DOCKER=1` (via {@link env}), else `local` — the same resolution
121
+ * `oke boot` uses without an explicit `env`.
122
+ */
123
+ readonly configEnv?: ConfigEnv;
58
124
  readonly write?: (text: string) => void;
59
125
  /** Write hints / progress (defaults to stderr). */
60
126
  readonly writeErr?: (text: string) => void;
@@ -92,6 +158,20 @@ export async function runDoctor(options: DoctorOptions = {}): Promise<{
92
158
  const env = options.env ?? ((k) => Bun.env[k] ?? process.env[k]);
93
159
  const secretNames = options.secrets ?? (manifest?.vault ? Object.keys(manifest.vault) : []);
94
160
 
161
+ // Fully-resolved `drivers.*` — real defaults + every override merged, so
162
+ // the picture is complete even when `oke.config.ts` only pins one key.
163
+ let driversConfig = options.driversConfig;
164
+ if (driversConfig === undefined) {
165
+ try {
166
+ driversConfig = (await loadOkeConfig(cwd)).config.drivers;
167
+ } catch {
168
+ // No oke.config.ts (or it failed to load) — show real defaults only.
169
+ }
170
+ }
171
+ const configEnv: ConfigEnv =
172
+ options.configEnv ?? (env("OKE_DOCKER") === "1" ? "docker" : "local");
173
+ const effectiveDrivers = resolveEffectiveDrivers(driversConfig);
174
+
95
175
  for (const name of secretNames) {
96
176
  const value = env(name);
97
177
  if (value === undefined || value.length === 0) {
@@ -177,7 +257,9 @@ export async function runDoctor(options: DoctorOptions = {}): Promise<{
177
257
  const code = ok ? EXIT_OK : EXIT_RUNTIME;
178
258
 
179
259
  if (json) {
180
- write(`${JSON.stringify({ ok, findings }, null, 2)}\n`);
260
+ write(
261
+ `${JSON.stringify({ ok, findings, drivers: { env: configEnv, resolved: effectiveDrivers } }, null, 2)}\n`,
262
+ );
181
263
  if (!ok) {
182
264
  writeErr("Hint: fix error-severity findings, then re-run oke doctor.\n");
183
265
  }
@@ -186,6 +268,7 @@ export async function runDoctor(options: DoctorOptions = {}): Promise<{
186
268
 
187
269
  if (findings.length === 0) {
188
270
  write("oke doctor: ok\n");
271
+ write(`${formatDriversSummary(effectiveDrivers, configEnv)}\n`);
189
272
  return { code: EXIT_OK, findings };
190
273
  }
191
274
 
@@ -193,6 +276,7 @@ export async function runDoctor(options: DoctorOptions = {}): Promise<{
193
276
  for (const f of findings) {
194
277
  write(` [${f.severity}] ${f.code}: ${f.message}\n`);
195
278
  }
279
+ write(`${formatDriversSummary(effectiveDrivers, configEnv)}\n`);
196
280
  return { code, findings };
197
281
  }
198
282
 
@@ -208,16 +292,25 @@ export async function doctorCli(args: readonly string[]): Promise<number> {
208
292
  }
209
293
 
210
294
  let manifestPath: string | undefined;
295
+ let configEnv: ConfigEnv | undefined;
211
296
  const json = wantsJson(args);
212
297
  for (let i = 0; i < args.length; i++) {
213
298
  const a = args[i]!;
214
299
  if (a === "--manifest" || a === "-m") manifestPath = args[++i];
215
- else if (a === "--help" || a === "-h") {
216
- console.log(`oke doctor [--manifest|-m path] [--json|-j]
300
+ else if (a === "--env" || a === "-e") {
301
+ const value = args[++i];
302
+ if (value === "local" || value === "docker" || value === "test" || value === "prod") {
303
+ configEnv = value;
304
+ }
305
+ } else if (a === "--help" || a === "-h") {
306
+ console.log(`oke doctor [--manifest|-m path] [--env|-e local|docker|test|prod] [--json|-j]
217
307
  oke doctor --diff|-d [--before|-b <path> --after|-a <path>] [--base|-B <branch>]
218
308
 
219
309
  Verify secrets, ports, schema drift, and PII→model egress before serving.
310
+ Also prints drivers.* fully resolved (every default + every override merged)
311
+ for the active env, from oke.config.ts.
220
312
 
313
+ --env Env to resolve drivers for (default: docker when OKE_DOCKER=1, else local).
221
314
  --diff CI gate: block undeclared contract breaks (Manifest Diff).
222
315
  Default baseline is git merge-base (main/master) vs the working
223
316
  tree; pass --before/--after for an explicit comparison.
@@ -227,6 +320,6 @@ Verify secrets, ports, schema drift, and PII→model egress before serving.
227
320
  return EXIT_OK;
228
321
  }
229
322
  }
230
- const { code } = await runDoctor({ manifestPath, json });
323
+ const { code } = await runDoctor({ manifestPath, configEnv, json });
231
324
  return code;
232
325
  }
@@ -3,12 +3,42 @@
3
3
  */
4
4
 
5
5
  import { describe, expect, test } from "bun:test";
6
+ import { flattenImagesConfig } from "../config/index.ts";
6
7
  import {
7
8
  defaultImagesFromConfig,
8
9
  resolveImages,
9
10
  dockerDevDriverMismatches,
10
11
  } from "./load-config.ts";
11
12
 
13
+ describe("flattenImagesConfig", () => {
14
+ test("flattens store/channel nesting to dotted keys; flat roles pass through", () => {
15
+ expect(
16
+ flattenImagesConfig({
17
+ store: { sql: "postgres:18-alpine", kv: "redis:8-alpine", files: "rustfs/rustfs:1.0.0" },
18
+ channel: { email: "axllent/mailpit:v1.22.3" },
19
+ vault: "openbao/openbao:2.6.1",
20
+ ai: "ollama/ollama:0.32.6",
21
+ pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.51",
22
+ proxy: "caddy:2-alpine",
23
+ }),
24
+ ).toEqual({
25
+ "store.sql": "postgres:18-alpine",
26
+ "store.kv": "redis:8-alpine",
27
+ "store.files": "rustfs/rustfs:1.0.0",
28
+ "channel.email": "axllent/mailpit:v1.22.3",
29
+ vault: "openbao/openbao:2.6.1",
30
+ ai: "ollama/ollama:0.32.6",
31
+ pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.51",
32
+ proxy: "caddy:2-alpine",
33
+ });
34
+ });
35
+
36
+ test("undefined / empty input yields empty map", () => {
37
+ expect(flattenImagesConfig(undefined)).toEqual({});
38
+ expect(flattenImagesConfig({})).toEqual({});
39
+ });
40
+ });
41
+
12
42
  describe("defaultImagesFromConfig", () => {
13
43
  test("maps postgres + redis prod drivers", () => {
14
44
  const images = defaultImagesFromConfig({
@@ -82,7 +112,7 @@ describe("resolveImages", () => {
82
112
  kv: { prod: "redis" },
83
113
  },
84
114
  },
85
- images: { "store.sql": "pgvector/pgvector:pg17" },
115
+ images: { store: { sql: "pgvector/pgvector:pg17" } },
86
116
  });
87
117
  expect(images).toEqual({ "store.sql": "pgvector/pgvector:pg17" });
88
118
  });
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import { resolve } from "node:path";
6
- import type { DriverRef, OkeConfig } from "../config/index.ts";
6
+ import { flattenImagesConfig, type DriverRef, type OkeConfig } from "../config/index.ts";
7
7
  import type { Manifest } from "../manifest/types.ts";
8
8
 
9
9
  /** Default image pins when `images` is omitted but prod drivers need containers. */
@@ -145,7 +145,7 @@ export function resolveImages(
145
145
  config?: OkeConfig,
146
146
  manifest?: Manifest,
147
147
  ): Readonly<Record<string, string>> {
148
- const explicit = config?.images ?? manifest?.images;
148
+ const explicit = config?.images ? flattenImagesConfig(config.images) : manifest?.images;
149
149
  if (explicit && Object.keys(explicit).length > 0) return explicit;
150
150
  if (config) return defaultImagesFromConfig(config);
151
151
  return {};
@@ -16,7 +16,7 @@ describe("oke safe-default overrides", () => {
16
16
  const dir = await mkdtemp(join(tmpdir(), "oke-safe-docker-"));
17
17
  await Bun.write(
18
18
  join(dir, "oke.config.ts"),
19
- `export default { images: { "store.sql": "postgres:18-alpine" } }\n`,
19
+ `export default { images: { store: { sql: "postgres:18-alpine" } } }\n`,
20
20
  );
21
21
  // Without --prod, derive still runs but prod overlays are not requested.
22
22
  // We assert the parser default by calling runDockerDerive via dockerCli
@@ -37,7 +37,7 @@ const NotFound = z.object({});
37
37
 
38
38
  const create = on(
39
39
  http.post("/notes"),
40
- flow({
40
+ flow("notes.create", {
41
41
  in: NewNote,
42
42
  out: NoteId,
43
43
  do: (input) => ({ id: `n_${input.title}` }),
@@ -46,7 +46,7 @@ const create = on(
46
46
 
47
47
  const get = on(
48
48
  http.get("/notes/:id"),
49
- flow({
49
+ flow("notes.get", {
50
50
  in: NoteId,
51
51
  out: Note,
52
52
  errors: { NotFound },
@@ -250,8 +250,7 @@ describe("Notes — typeof app carries contracts", () => {
250
250
  });
251
251
 
252
252
  test("untriggered adopted flow is RPC-only on the client", async () => {
253
- const stats = flow({
254
- name: "notes.stats",
253
+ const stats = flow("notes.stats", {
255
254
  in: NoteId,
256
255
  out: z.object({ clicks: z.number() }),
257
256
  do: () => ({ clicks: 7 }),
@@ -120,8 +120,7 @@ describe("typed error narrowing end-to-end", () => {
120
120
 
121
121
  on(
122
122
  http.post("/bookings").gate(gate.public),
123
- flow({
124
- name: "bookings.create",
123
+ flow("bookings.create", {
125
124
  in: Booking,
126
125
  out: z.object({ id: z.string() }),
127
126
  errors: { FlightFull },
@@ -123,8 +123,7 @@ describe("extractManifest — performance", () => {
123
123
  parts.push(`
124
124
  export const flow_${i} = on(
125
125
  http.get("/f/${i}"),
126
- flow({
127
- name: "synth.flow_${i}",
126
+ flow("synth.flow_${i}", {
128
127
  do: async (input, fx) => {
129
128
  await fx.store(db).insert(items).values(input);
130
129
  const rows = await fx.store(db).select().from(items);
@@ -166,8 +165,7 @@ export const daily = { name: "daily" };
166
165
 
167
166
  export const listLinks = on(
168
167
  http.get("/links"),
169
- flow({
170
- name: "rel.root",
168
+ flow("rel.root", {
171
169
  do: async (_input, fx) => {
172
170
  return fx.store(db).select().from(links);
173
171
  },
@@ -178,8 +176,7 @@ export const listLinks = on(
178
176
  // relations, so the related table must NOT appear in effects.
179
177
  export const withDaily = on(
180
178
  http.get("/links/with-daily"),
181
- flow({
182
- name: "rel.with",
179
+ flow("rel.with", {
183
180
  do: async (_input, fx) => {
184
181
  return fx.store(db).findMany({ with: { daily: true } });
185
182
  },
@@ -354,9 +351,7 @@ export const files = store.files("uploads");
354
351
 
355
352
  export const attach = on(
356
353
  http.post("/attach").gate(gate.public),
357
- flow({
358
- name: "notes.attach",
359
- unit: "notes",
354
+ flow("notes.attach", {
360
355
  do: async (input, fx) => {
361
356
  await fx.store(files).put("key", input.text);
362
357
  return { ok: true };
@@ -377,9 +372,7 @@ export const files = store.files("uploads");
377
372
 
378
373
  export const attach = on(
379
374
  http.post("/attach").gate(gate.public),
380
- flow({
381
- name: "notes.attachImage",
382
- unit: "notes",
375
+ flow("notes.attachImage", {
383
376
  do: async (input, fx) => {
384
377
  await fx.store(files).putImage("key", input.bytes);
385
378
  return { ok: true };
@@ -400,9 +393,7 @@ export const files = store.files("uploads");
400
393
 
401
394
  export const sweep = on(
402
395
  every("1d"),
403
- flow({
404
- name: "notes.sweep",
405
- unit: "notes",
396
+ flow("notes.sweep", {
406
397
  do: async (_input, fx) => {
407
398
  const keys = await fx.store(files).list();
408
399
  return { count: keys.length };
@@ -429,9 +420,7 @@ export const noteCreatedMail = mail.template("note-created", {
429
420
 
430
421
  export const onCreated = on(
431
422
  http.post("/hook").gate(gate.public),
432
- flow({
433
- name: "notes.onCreated",
434
- unit: "notes",
423
+ flow("notes.onCreated", {
435
424
  do: async (payload, fx) => {
436
425
  await fx.send(noteCreatedMail, { to: "you@localhost", data: payload });
437
426
  },
@@ -454,9 +443,7 @@ export const otpTemplate = otp.template("otp-code", {});
454
443
 
455
444
  export const send = on(
456
445
  http.post("/otp").gate(gate.public),
457
- flow({
458
- name: "notes.sendOtp",
459
- unit: "notes",
446
+ flow("notes.sendOtp", {
460
447
  do: async (payload, fx) => {
461
448
  await fx.send(otpTemplate, { to: "+10000000000", data: payload });
462
449
  },
@@ -469,3 +456,71 @@ export const send = on(
469
456
  expect(manifest.channels?.["otp-code"]?.medium).toBe("sms");
470
457
  });
471
458
  });
459
+
460
+ describe("extractManifest — flow(name, options) positional signature", () => {
461
+ test("bare flow(name, {...}) extracts name, in, out, effects", async () => {
462
+ const source = `
463
+ import { flow } from "okengine";
464
+
465
+ export const chargeOrder = flow("checkout.chargeOrder", {
466
+ in: ChargeIn,
467
+ out: ChargeOut,
468
+ effects: { reads: ["sql:orders"], writes: ["sql:orders"] },
469
+ do: async (input, fx) => {
470
+ return { ok: true };
471
+ },
472
+ });
473
+ `;
474
+ const manifest = await extractFromSources({ "src/flows/checkout.ts": source });
475
+
476
+ expect(manifest.flows?.["checkout.chargeOrder"]).toBeDefined();
477
+ expect(manifest.flows?.["checkout.chargeOrder"]?.in).toBeDefined();
478
+ expect(manifest.flows?.["checkout.chargeOrder"]?.out).toBeDefined();
479
+ expect(manifest.flows?.["checkout.chargeOrder"]?.effects?.reads).toEqual(["sql:orders"]);
480
+ expect(manifest.flows?.["checkout.chargeOrder"]?.effects?.writes).toEqual(["sql:orders"]);
481
+ });
482
+
483
+ test("on(trigger, flow(name, {...})) extracts the same shape as bare flow()", async () => {
484
+ const source = `
485
+ import { on, http, flow } from "okengine";
486
+
487
+ export const getOrder = on(
488
+ http.get("/orders/:id"),
489
+ flow("checkout.getOrder", {
490
+ out: OrderOut,
491
+ effects: { reads: ["sql:orders"] },
492
+ do: async (input, fx) => {
493
+ return { id: input.id };
494
+ },
495
+ }),
496
+ );
497
+ `;
498
+ const manifest = await extractFromSources({ "src/flows/checkout.ts": source });
499
+
500
+ expect(manifest.flows?.["checkout.getOrder"]).toBeDefined();
501
+ expect(manifest.flows?.["checkout.getOrder"]?.trigger?.http?.method).toBe("GET");
502
+ expect(manifest.flows?.["checkout.getOrder"]?.out).toBeDefined();
503
+ expect(manifest.flows?.["checkout.getOrder"]?.effects?.reads).toEqual(["sql:orders"]);
504
+ });
505
+
506
+ test("a stray `name:` key inside the options object is ignored — the positional arg wins", async () => {
507
+ // Regression guard for the exact class of AST-shape assumption that broke
508
+ // silently before (channel-alias tracking, `.put()` inference): the
509
+ // compiler must read the flow's identity from the first positional
510
+ // argument, never from a `name` property on the options object, even
511
+ // when one happens to be present.
512
+ const source = `
513
+ import { flow } from "okengine";
514
+
515
+ export const decoy = flow("checkout.realName", {
516
+ name: "checkout.decoyName",
517
+ effects: { reads: ["sql:orders"] },
518
+ do: async () => ({ ok: true }),
519
+ });
520
+ `;
521
+ const manifest = await extractFromSources({ "src/flows/checkout.ts": source });
522
+
523
+ expect(manifest.flows?.["checkout.realName"]).toBeDefined();
524
+ expect(manifest.flows?.["checkout.decoyName"]).toBeUndefined();
525
+ });
526
+ });