okengine 0.10.0 → 0.10.2

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 (105) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/site/content/docs/elements/clock.mdx +2 -2
  4. package/site/content/docs/elements/flow.mdx +10 -10
  5. package/site/content/docs/elements/gate.mdx +4 -4
  6. package/site/content/docs/elements/signal.mdx +4 -7
  7. package/site/content/docs/elements/store.mdx +1 -1
  8. package/site/content/docs/elements/vault.mdx +1 -1
  9. package/site/content/docs/get-started/basic-usage.mdx +1 -3
  10. package/site/content/docs/get-started/introduction.mdx +1 -1
  11. package/site/content/docs/reference/cli.md +12 -12
  12. package/site/content/docs/reference/fx.mdx +3 -4
  13. package/src/auth/auth.test.ts +1 -2
  14. package/src/auth/bindings.ts +5 -15
  15. package/src/auth/gate-auth.test.ts +2 -2
  16. package/src/cli/ai-setup/apply.ts +13 -0
  17. package/src/cli/build.test.ts +67 -0
  18. package/src/cli/build.ts +34 -0
  19. package/src/cli/db-seed.ts +1 -1
  20. package/src/cli/dev-app-runner.ts +6 -0
  21. package/src/cli/dev-controls.test.ts +43 -48
  22. package/src/cli/dev-controls.ts +23 -172
  23. package/src/cli/dev.test.ts +2 -4
  24. package/src/cli/dev.ts +63 -16
  25. package/src/cli/docker-cli.test.ts +15 -0
  26. package/src/cli/start.ts +12 -1
  27. package/src/client/notes-contract.test.ts +3 -4
  28. package/src/compiler/aot.test.ts +1 -2
  29. package/src/compiler/effects-infer.ts +37 -8
  30. package/src/compiler/extract.test.ts +186 -6
  31. package/src/compiler/extract.ts +31 -6
  32. package/src/compiler/fixtures/raw/raw-unannotated.ts +2 -4
  33. package/src/compiler/fixtures/skyport/src/flows/bookings/index.ts +4 -8
  34. package/src/compiler/fixtures/skyport/src/flows/payments/index.ts +1 -2
  35. package/src/compiler/fixtures/skyport/src/flows/support/index.ts +1 -2
  36. package/src/compiler/fixtures/skyport.expected.json +3 -3
  37. package/src/compiler/fixtures/triggers/five-triggers.ts +5 -10
  38. package/src/compiler/generate-adopt.test.ts +85 -0
  39. package/src/compiler/generate-adopt.ts +85 -0
  40. package/src/console/server/dry-run.audit.test.ts +2 -2
  41. package/src/console/server/flows.ts +50 -150
  42. package/src/docker/compose.ts +13 -3
  43. package/src/docker/recipes/llama-cpp.ts +10 -1
  44. package/src/docker/stack-id.ts +1 -0
  45. package/src/elements/channel/declare.ts +27 -1
  46. package/src/elements/clock/chaos-child.ts +3 -6
  47. package/src/elements/clock/chaos.test.ts +1 -2
  48. package/src/elements/clock.test.ts +2 -4
  49. package/src/elements/signal/declare.ts +25 -1
  50. package/src/elements/store/cache.test.ts +28 -0
  51. package/src/elements/store/declare.ts +27 -1
  52. package/src/elements/store/resource.ts +6 -11
  53. package/src/elements/store/upsert-app.test.ts +1 -2
  54. package/src/elements/vault/declare.ts +29 -1
  55. package/src/kernel/adopt-barrel-fresh.test.ts +103 -0
  56. package/src/kernel/app.ts +80 -9
  57. package/src/kernel/auto-registry.test.ts +198 -0
  58. package/src/kernel/boot-bind/honor-config.test.ts +5 -5
  59. package/src/kernel/boot.test.ts +19 -15
  60. package/src/kernel/boot.ts +186 -8
  61. package/src/kernel/call.test.ts +5 -10
  62. package/src/kernel/compensate.test.ts +3 -6
  63. package/src/kernel/concurrency.test.ts +3 -5
  64. package/src/kernel/correlation.test.ts +4 -8
  65. package/src/kernel/edge.test.ts +1 -1
  66. package/src/kernel/effects-stamping.test.ts +204 -0
  67. package/src/kernel/element-registries.ts +27 -0
  68. package/src/kernel/errors.ts +22 -0
  69. package/src/kernel/flow.test.ts +8 -15
  70. package/src/kernel/flow.ts +12 -14
  71. package/src/kernel/fx.ts +42 -13
  72. package/src/kernel/hook-timing.test.ts +2 -2
  73. package/src/kernel/hooks.test.ts +5 -10
  74. package/src/kernel/horizontal-child.ts +5 -10
  75. package/src/kernel/journal-boot.test.ts +2 -4
  76. package/src/kernel/pipeline.test.ts +6 -12
  77. package/src/kernel/plugin/capabilities.test.ts +1 -1
  78. package/src/kernel/plugin/decorate.test.ts +3 -8
  79. package/src/kernel/plugin/scoping.test.ts +6 -16
  80. package/src/kernel/plugin-elements.test.ts +1 -1
  81. package/src/kernel/plugin-needs.test.ts +1 -1
  82. package/src/kernel/ready.test.ts +1 -2
  83. package/src/kernel/registry-isolation.test.ts +5 -5
  84. package/src/kernel/triggers.ts +1 -1
  85. package/src/manifest/sql-resource.ts +41 -0
  86. package/src/plugins/anonymous.ts +1 -3
  87. package/src/plugins/compression.test.ts +6 -8
  88. package/src/plugins/config-source.test.ts +1 -1
  89. package/src/plugins/config-source.ts +1 -2
  90. package/src/plugins/cors.test.ts +10 -10
  91. package/src/plugins/csrf.test.ts +2 -2
  92. package/src/plugins/headers.test.ts +14 -15
  93. package/src/plugins/ip-allowlist.test.ts +9 -9
  94. package/src/plugins/magic-link.ts +2 -6
  95. package/src/plugins/maintenance-mode.test.ts +6 -6
  96. package/src/plugins/otp.ts +5 -15
  97. package/src/plugins/passkey.ts +4 -12
  98. package/src/plugins/two-factor.ts +3 -9
  99. package/src/plugins/username.ts +2 -6
  100. package/src/release/measure.ts +1 -1
  101. package/src/runs/runs.test.ts +3 -5
  102. package/src/runtime/serve.test.ts +2 -4
  103. package/src/test/create-test-app.test.ts +3 -7
  104. package/src/test/provisions.integration.test.ts +4 -9
  105. package/src/test/reset-element-registries.ts +31 -0
@@ -1,65 +1,42 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import {
3
- controlServicesFromStack,
4
3
  createDevControlDispatcher,
5
4
  formatDevControlsHelp,
6
5
  formatDevControlsHint,
7
- formatDevControlsServiceList,
8
6
  parseDevControlKey,
9
7
  } from "./dev-controls.ts";
10
8
 
9
+ const CTRL_C = String.fromCharCode(3);
10
+
11
11
  describe("dev-controls", () => {
12
12
  test("parseDevControlKey maps raw keys", () => {
13
13
  expect(parseDevControlKey("q")).toBe("q");
14
- expect(parseDevControlKey("\u0003")).toBe("q");
15
14
  expect(parseDevControlKey("?")).toBe("?");
16
15
  expect(parseDevControlKey("h")).toBe("?");
17
- expect(parseDevControlKey("c")).toBe("c");
18
- expect(parseDevControlKey("l")).toBe("l");
16
+ expect(parseDevControlKey("r")).toBe("r");
19
17
  expect(parseDevControlKey("u")).toBe("u");
20
18
  expect(parseDevControlKey("x")).toBe("x");
21
- expect(parseDevControlKey("r")).toBe("r");
22
- expect(parseDevControlKey("3")).toBe("3");
23
- expect(parseDevControlKey("\u001b")).toBe("esc");
19
+ expect(parseDevControlKey("c")).toBeNull();
20
+ expect(parseDevControlKey("l")).toBeNull();
21
+ expect(parseDevControlKey("3")).toBeNull();
22
+ expect(parseDevControlKey("")).toBeNull();
24
23
  expect(parseDevControlKey("z")).toBeNull();
25
24
  });
26
25
 
26
+ test("parseDevControlKey maps Ctrl+C to quit", () => {
27
+ expect(parseDevControlKey(CTRL_C)).toBe("q");
28
+ });
29
+
27
30
  test("format hint and help are scannable", () => {
28
31
  expect(formatDevControlsHint(false)).toContain("keys");
29
32
  expect(formatDevControlsHint(false)).toContain("refresh");
30
33
  expect(formatDevControlsHint(false)).toContain("quit");
31
- expect(formatDevControlsHelp(false)).toContain("select service");
34
+ expect(formatDevControlsHint(false)).not.toContain("services");
32
35
  expect(formatDevControlsHelp(false)).toContain("refresh");
33
- expect(formatDevControlsHelp(false)).not.toMatch(/\u001b\[/);
36
+ expect(formatDevControlsHelp(false)).not.toContain("select service");
34
37
  });
35
38
 
36
- test("controlServicesFromStack dedupes by serviceName", () => {
37
- const list = controlServicesFromStack([
38
- { label: "ai", serviceName: "ai" },
39
- { label: "mail", serviceName: "channel-email" },
40
- { label: "mail-ui", serviceName: "channel-email" },
41
- { label: "postgres", serviceName: "store-sql" },
42
- ]);
43
- expect(list.map((s) => s.serviceName)).toEqual(["ai", "channel-email", "store-sql"]);
44
- });
45
-
46
- test("formatDevControlsServiceList numbers rows", () => {
47
- const out = formatDevControlsServiceList(
48
- [
49
- { label: "ai", serviceName: "ai" },
50
- { label: "postgres", serviceName: "store-sql" },
51
- ],
52
- (name) => (name === "ai" ? "error" : "ready"),
53
- false,
54
- 1,
55
- );
56
- expect(out).toContain("1");
57
- expect(out).toContain("ai");
58
- expect(out).toContain("postgres");
59
- expect(out).toContain(">");
60
- });
61
-
62
- test("dispatcher select → stop → up stack → quit", async () => {
39
+ test("dispatcher up -> stop -> refresh -> help -> quit", async () => {
63
40
  const calls: string[] = [];
64
41
  let quit = false;
65
42
  let settled = 0;
@@ -79,29 +56,47 @@ describe("dev-controls", () => {
79
56
  onComposeSettled: () => {
80
57
  settled += 1;
81
58
  },
82
- services: () => [
83
- { label: "ai", serviceName: "ai" },
84
- { label: "postgres", serviceName: "store-sql" },
85
- ],
86
- composeAction: async (action, names) => {
87
- calls.push(`${action}:${names.join(",") || "*"}`);
59
+ composeAction: async (action) => {
60
+ calls.push(action);
88
61
  },
89
62
  });
90
- d.handleKey("l");
63
+ d.handleKey("u");
91
64
  await Bun.sleep(10);
92
- d.handleKey("1");
93
65
  d.handleKey("x");
94
66
  await Bun.sleep(10);
95
- d.handleKey("u");
67
+ d.handleKey("r");
96
68
  await Bun.sleep(10);
97
- d.handleKey("c");
69
+ d.handleKey("?");
98
70
  await Bun.sleep(10);
99
71
  d.handleKey("q");
100
- expect(calls).toEqual(["stop:ai", "up:*"]);
72
+ expect(calls).toEqual(["up", "stop"]);
101
73
  expect(settled).toBe(2);
102
74
  expect(refreshed).toBe(1);
103
75
  expect(panels).toBe(1);
104
76
  expect(quit).toBe(true);
105
77
  d.stop();
106
78
  });
79
+
80
+ test("busy compose action ignores a concurrent key press", async () => {
81
+ let running = 0;
82
+ let maxConcurrent = 0;
83
+ const calls: string[] = [];
84
+ const d = createDevControlDispatcher({
85
+ write: () => {},
86
+ onQuit: () => {},
87
+ composeAction: async (action) => {
88
+ running += 1;
89
+ maxConcurrent = Math.max(maxConcurrent, running);
90
+ await Bun.sleep(20);
91
+ calls.push(action);
92
+ running -= 1;
93
+ },
94
+ });
95
+ d.handleKey("u");
96
+ d.handleKey("x");
97
+ await Bun.sleep(40);
98
+ expect(calls).toEqual(["up"]);
99
+ expect(maxConcurrent).toBe(1);
100
+ d.stop();
101
+ });
107
102
  });
@@ -1,31 +1,17 @@
1
1
  /**
2
2
  * Keyboard controls for a live `oke dev` session (TTY raw mode).
3
3
  *
4
- * Keys: `?` help · `q` quit · `c` refresh · `l` list · `u` up all ·
5
- * `x` stop all · `1`–`9` select service then `u`/`x`/`r`.
4
+ * Keys: `?` help · `q` quit · `r` refresh · `u` up all · `x` stop all.
6
5
  *
7
- * Compose health / model phase update the status board above Logs — not
8
- * the log stream. `c` clears the TTY and reprints the latest board.
6
+ * Compose health / model phase update the status board above Logs — it
7
+ * already lists every service with a live status, so refresh alone is
8
+ * enough to see the stack; there is no separate services panel.
9
9
  */
10
10
 
11
- import {
12
- formatStatusDot,
13
- formatStatusLine,
14
- termColorEnabled,
15
- termStyle,
16
- type DevStatus,
17
- } from "../term.ts";
11
+ import { formatStatusLine, termColorEnabled, termStyle } from "../term.ts";
18
12
 
19
- /** One Docker service row addressable by a digit key. */
20
- export type DevControlService = {
21
- /** Human label (`ai`, `postgres`). */
22
- readonly label: string;
23
- /** Compose service name (`ai`, `store-sql`). */
24
- readonly serviceName: string;
25
- };
26
-
27
- /** Compose action against zero or more service names. */
28
- export type DevComposeControlAction = "up" | "stop" | "restart";
13
+ /** Compose action against the whole stack. */
14
+ export type DevComposeControlAction = "up" | "stop";
29
15
 
30
16
  /** Options for {@link startDevControls}. */
31
17
  export type StartDevControlsOptions = {
@@ -37,30 +23,22 @@ export type StartDevControlsOptions = {
37
23
  */
38
24
  readonly onRefresh?: () => void | Promise<void>;
39
25
  /**
40
- * Show help / services in a clean pane (typically refresh chrome first so
41
- * the panel is not interleaved with request logs).
26
+ * Show help in a clean pane (refreshes chrome first so the panel is not
27
+ * interleaved with request logs).
42
28
  *
43
29
  * @param body - Formatted panel text
44
30
  */
45
31
  readonly onShowPanel?: (body: string) => void | Promise<void>;
46
32
  /**
47
- * After a compose up/stop/restart — sync health into the board (no log lines).
33
+ * After a compose up/stop — sync health into the board (no log lines).
48
34
  */
49
35
  readonly onComposeSettled?: () => void | Promise<void>;
50
- /** Live service catalogue (unique compose services). */
51
- readonly services: () => readonly DevControlService[];
52
36
  /**
53
- * Run a compose control action.
37
+ * Run a compose control action against the whole stack.
54
38
  *
55
- * @param action - up / stop / restart
56
- * @param serviceNames - Empty → whole stack
39
+ * @param action - up / stop
57
40
  */
58
- readonly composeAction: (
59
- action: DevComposeControlAction,
60
- serviceNames: readonly string[],
61
- ) => Promise<void>;
62
- /** Optional live ● status for list view. */
63
- readonly statusOf?: (serviceName: string) => DevStatus | undefined;
41
+ readonly composeAction: (action: DevComposeControlAction) => Promise<void>;
64
42
  readonly stdin?: NodeJS.ReadStream;
65
43
  readonly isTTY?: boolean;
66
44
  readonly color?: boolean;
@@ -81,9 +59,8 @@ export function formatDevControlsHint(color: boolean = termColorEnabled()): stri
81
59
  return (
82
60
  `${s.dim}│${s.reset} ${s.dim}keys${s.reset} ` +
83
61
  `${s.cyan}?${s.reset} help · ` +
84
- `${s.cyan}c${s.reset} refresh · ` +
62
+ `${s.cyan}r${s.reset} refresh · ` +
85
63
  `${s.cyan}q${s.reset} quit · ` +
86
- `${s.cyan}l${s.reset} services · ` +
87
64
  `${s.cyan}u${s.reset} up · ` +
88
65
  `${s.cyan}x${s.reset} stop` +
89
66
  `\n`
@@ -104,63 +81,15 @@ export function formatDevControlsHelp(color: boolean = termColorEnabled()): stri
104
81
  "",
105
82
  `${s.green}◇${s.reset} ${s.bold}Keys${s.reset}`,
106
83
  k("?", "help"),
107
- k("c", "refresh — clear logs, show latest ●"),
84
+ k("r", "refresh — clear logs, show latest ●"),
108
85
  k("q", "quit oke dev"),
109
- k("l", "list docker services"),
110
86
  k("u", "compose up -d (all)"),
111
87
  k("x", "compose stop (all)"),
112
- k("1-9", "select service"),
113
- k("… u", "start selected"),
114
- k("… x", "stop selected"),
115
- k("… r", "restart selected"),
116
- k("esc", "clear selection"),
117
88
  bar,
118
89
  "",
119
90
  ].join("\n");
120
91
  }
121
92
 
122
- /**
123
- * Format a numbered service list for `l`.
124
- *
125
- * @param services - Catalogue
126
- * @param statusOf - Optional ● lookup
127
- * @param color - Color on/off
128
- * @param selected - Highlighted 1-based index
129
- */
130
- export function formatDevControlsServiceList(
131
- services: readonly DevControlService[],
132
- statusOf?: (serviceName: string) => DevStatus | undefined,
133
- color: boolean = termColorEnabled(),
134
- selected?: number,
135
- ): string {
136
- const s = termStyle(color);
137
- const bar = `${s.dim}│${s.reset}`;
138
- if (services.length === 0) {
139
- return `${bar} ${s.dim}no docker services${s.reset}\n`;
140
- }
141
- const lines = [
142
- "",
143
- `${s.green}◇${s.reset} ${s.bold}Services${s.reset} ${s.dim}1-9 then u/x/r${s.reset}`,
144
- ];
145
- const max = Math.min(9, services.length);
146
- for (let i = 0; i < max; i++) {
147
- const svc = services[i]!;
148
- const st = statusOf?.(svc.serviceName) ?? "pending";
149
- const dot = formatStatusDot(st, color);
150
- const num = `${i + 1}`;
151
- const mark = selected === i + 1 ? `${s.cyan}>${s.reset}` : " ";
152
- lines.push(
153
- `${bar} ${mark}${s.cyan}${num}${s.reset} ${dot} ${svc.label.padEnd(12)} ${s.dim}${svc.serviceName}${s.reset}`,
154
- );
155
- }
156
- if (services.length > 9) {
157
- lines.push(`${bar} ${s.dim}… ${services.length - 9} more (first 9 only)${s.reset}`);
158
- }
159
- lines.push(bar);
160
- lines.push("");
161
- return `${lines.join("\n")}\n`;
162
- }
163
-
164
93
  /**
165
94
  * Parse one raw stdin chunk into a control key (tests / non-TTY inject).
166
95
  *
@@ -169,16 +98,12 @@ export function formatDevControlsServiceList(
169
98
  export function parseDevControlKey(chunk: string): string | null {
170
99
  if (chunk.length === 0) return null;
171
100
  if (chunk === "\u0003") return "q"; // Ctrl+C
172
- if (chunk === "\u001b") return "esc";
173
101
  const ch = chunk[0]!;
174
102
  if (ch === "?" || ch === "h" || ch === "H") return "?";
175
- if (ch === "c" || ch === "C") return "c";
176
103
  if (ch === "q" || ch === "Q") return "q";
177
- if (ch === "l" || ch === "L") return "l";
104
+ if (ch === "r" || ch === "R") return "r";
178
105
  if (ch === "u" || ch === "U") return "u";
179
106
  if (ch === "x" || ch === "X") return "x";
180
- if (ch === "r" || ch === "R") return "r";
181
- if (ch >= "1" && ch <= "9") return ch;
182
107
  return null;
183
108
  }
184
109
 
@@ -191,23 +116,18 @@ export type DevControlDispatcher = {
191
116
  /**
192
117
  * Pure key dispatcher (no stdin) — used by tests and the TTY listener.
193
118
  *
194
- * @param opts - Quit / compose / services
119
+ * @param opts - Quit / compose actions
195
120
  */
196
121
  export function createDevControlDispatcher(opts: StartDevControlsOptions): DevControlDispatcher {
197
122
  const color = opts.color ?? termColorEnabled();
198
- let selected: number | null = null;
199
123
  let busy = false;
200
124
  let stopped = false;
201
125
 
202
- const runAction = async (
203
- action: DevComposeControlAction,
204
- serviceNames: readonly string[],
205
- _label: string,
206
- ): Promise<void> => {
126
+ const runAction = async (action: DevComposeControlAction): Promise<void> => {
207
127
  if (busy) return;
208
128
  busy = true;
209
129
  try {
210
- await opts.composeAction(action, serviceNames);
130
+ await opts.composeAction(action);
211
131
  await opts.onComposeSettled?.();
212
132
  } catch (err) {
213
133
  const msg = err instanceof Error ? err.message : String(err);
@@ -224,7 +144,7 @@ export function createDevControlDispatcher(opts: StartDevControlsOptions): DevCo
224
144
  opts.onQuit();
225
145
  return;
226
146
  }
227
- if (key === "c") {
147
+ if (key === "r") {
228
148
  void Promise.resolve(opts.onRefresh?.()).catch((err: unknown) => {
229
149
  const msg = err instanceof Error ? err.message : String(err);
230
150
  opts.write(formatStatusLine(`refresh failed — ${msg}`, color, "error"));
@@ -236,58 +156,8 @@ export function createDevControlDispatcher(opts: StartDevControlsOptions): DevCo
236
156
  void Promise.resolve(opts.onShowPanel?.(body) ?? opts.write(body));
237
157
  return;
238
158
  }
239
- if (key === "esc") {
240
- selected = null;
241
- return;
242
- }
243
- if (key === "l") {
244
- const body = formatDevControlsServiceList(
245
- opts.services(),
246
- opts.statusOf,
247
- color,
248
- selected ?? undefined,
249
- );
250
- void Promise.resolve(opts.onShowPanel?.(body) ?? opts.write(body));
251
- return;
252
- }
253
- if (key >= "1" && key <= "9") {
254
- const n = Number(key);
255
- const list = opts.services();
256
- if (n < 1 || n > list.length || n > 9) {
257
- opts.write(formatStatusLine(`no service ${n} — press l to list`, color, "pending"));
258
- return;
259
- }
260
- selected = n;
261
- const svc = list[n - 1]!;
262
- opts.write(
263
- formatStatusLine(
264
- `selected ${n} ${svc.label} — u start · x stop · r restart · esc`,
265
- color,
266
- opts.statusOf?.(svc.serviceName) ?? "pending",
267
- ),
268
- );
269
- return;
270
- }
271
- if (key === "u" || key === "x" || key === "r") {
272
- const action: DevComposeControlAction =
273
- key === "u" ? "up" : key === "x" ? "stop" : "restart";
274
- if (selected !== null) {
275
- const list = opts.services();
276
- const svc = list[selected - 1];
277
- if (!svc) {
278
- selected = null;
279
- opts.write(formatStatusLine("selection expired — press l", color, "pending"));
280
- return;
281
- }
282
- selected = null;
283
- void runAction(action, [svc.serviceName], svc.label);
284
- return;
285
- }
286
- if (key === "r") {
287
- opts.write(formatStatusLine("select a service first (l then 1-9)", color, "pending"));
288
- return;
289
- }
290
- void runAction(action, [], "stack");
159
+ if (key === "u" || key === "x") {
160
+ void runAction(key === "u" ? "up" : "stop");
291
161
  }
292
162
  },
293
163
  stop() {
@@ -299,7 +169,7 @@ export function createDevControlDispatcher(opts: StartDevControlsOptions): DevCo
299
169
  /**
300
170
  * Start raw-mode keyboard controls. No-op when stdin is not a TTY.
301
171
  *
302
- * @param opts - Quit / compose / services
172
+ * @param opts - Quit / compose actions
303
173
  */
304
174
  export function startDevControls(opts: StartDevControlsOptions): DevControlsHandle {
305
175
  const stdin = opts.stdin ?? process.stdin;
@@ -333,22 +203,3 @@ export function startDevControls(opts: StartDevControlsOptions): DevControlsHand
333
203
  },
334
204
  };
335
205
  }
336
-
337
- /**
338
- * Dedupe stack summary rows into control services (first label wins).
339
- *
340
- * @param rows - Stack summary services with compose names
341
- */
342
- export function controlServicesFromStack(
343
- rows: readonly { readonly label: string; readonly serviceName?: string }[],
344
- ): DevControlService[] {
345
- const seen = new Set<string>();
346
- const out: DevControlService[] = [];
347
- for (const row of rows) {
348
- const name = row.serviceName?.trim();
349
- if (!name || seen.has(name)) continue;
350
- seen.add(name);
351
- out.push({ label: row.label, serviceName: name });
352
- }
353
- return out;
354
- }
@@ -398,13 +398,11 @@ async function writePingApp(dir: string, version: string): Promise<void> {
398
398
  join(dir, "src/flows/ping.ts"),
399
399
  `import { on, flow, http, gate } from ${JSON.stringify(OKE_INDEX)};
400
400
 
401
- export const ping = on(http.get("/ping").gate(gate.public), flow({
402
- name: "ping",
401
+ export const ping = on(http.get("/ping").gate(gate.public), flow("ping", {
403
402
  do: () => ({ version: ${JSON.stringify(version)} as const }),
404
403
  }));
405
404
 
406
- export const slow = on(http.get("/slow").gate(gate.public), flow({
407
- name: "slow",
405
+ export const slow = on(http.get("/slow").gate(gate.public), flow("slow", {
408
406
  do: async () => {
409
407
  const started = ${JSON.stringify(version)};
410
408
  await Bun.sleep(800);
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,17 @@ 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` + write to `<cwd>/src/flows/generated.ts`. Runs
182
+ * once per `oke dev` session, before the entry is resolved/imported —
183
+ * a real file on disk, so `oke dev`'s runtime `import()` and `oke build`'s
184
+ * `Bun.build()` resolve it identically (unlike a virtual-module Bun
185
+ * plugin, which does not — see `compiler/generate-adopt.ts`).
186
+ *
187
+ * @param cwd - Project root
188
+ */
189
+ readonly syncAdoptBarrel?: (cwd: string) => Promise<readonly string[]>;
180
190
  /** Watch project source changes (injectable for tests). */
181
191
  readonly watchFs?: DevWatchFn;
182
192
  readonly images?: Readonly<Record<string, string>>;
@@ -233,6 +243,21 @@ export interface DevOptions {
233
243
  readonly env: Record<string, string>;
234
244
  readonly onUpdate?: (service: string, status: DevStatus) => void;
235
245
  }) => Promise<Map<string, DevStatus>>;
246
+ /**
247
+ * Injectable `docker compose ps` runner for live health polls
248
+ * ({@link startComposeHealthWatch} / keyboard refresh). Default: real
249
+ * `docker`. When {@link DevOptions.composeHealth} is set and this is
250
+ * omitted, live polls return empty (unit tests never shell out).
251
+ *
252
+ * @param args - Args after `docker` (e.g. `compose -f … ps -a …`)
253
+ * @param cwd - Compose directory
254
+ * @param env - Stack env merged into the process env
255
+ */
256
+ readonly composeHealthRun?: (
257
+ args: readonly string[],
258
+ cwd: string,
259
+ env: Record<string, string>,
260
+ ) => Promise<string>;
236
261
  /**
237
262
  * Regenerate client types (injectable).
238
263
  *
@@ -298,6 +323,14 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
298
323
  /** Reassigned to {@link createAnchoredBoard} wrap after the status board paints. */
299
324
  let write: (text: string) => void = options.write ?? ((t) => process.stdout.write(t));
300
325
  const chromeWrite = (t: string) => write(formatCliChrome(t));
326
+ /**
327
+ * `docker compose ps` runner for live / default health polls.
328
+ * Explicit inject wins; otherwise a `composeHealth` inject implies tests —
329
+ * never shell out to a real `docker` binary.
330
+ */
331
+ const composeHealthRun =
332
+ options.composeHealthRun ??
333
+ (options.composeHealth ? async (): Promise<string> => "[]" : undefined);
301
334
  const previousWarn = console.warn;
302
335
  console.warn = (...args: unknown[]) => {
303
336
  const msg = args.map(String).join(" ");
@@ -311,6 +344,28 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
311
344
  console.warn = previousWarn;
312
345
  };
313
346
  const cwd = options.cwd ?? process.cwd();
347
+
348
+ // One-shot `.adopt()` barrel regen for the session — real file on disk
349
+ // (`src/flows/generated.ts`), written before the entry is resolved/imported
350
+ // below so a freshly-added flows unit is adoptable without a hand edit.
351
+ // Best-effort like `syncDevSchema` below: a project with no `src/flows`
352
+ // yet (or a synthetic test tree) never blocks the dev session.
353
+ const syncAdoptBarrel =
354
+ options.syncAdoptBarrel ??
355
+ (async (root: string) => {
356
+ const { generateAdoptBarrel } = await import("../compiler/generate-adopt.ts");
357
+ const { mkdir, writeFile } = await import("node:fs/promises");
358
+ const { source, units } = await generateAdoptBarrel({ rootDir: root });
359
+ await mkdir(resolve(root, "src/flows"), { recursive: true });
360
+ await writeFile(resolve(root, "src/flows/generated.ts"), source);
361
+ return units;
362
+ });
363
+ try {
364
+ await syncAdoptBarrel(cwd);
365
+ } catch {
366
+ /* best-effort — boot-time assertAdoptBarrelFresh (rootDir opt-in) is the real gate */
367
+ }
368
+
314
369
  const preferredApp = options.appPort ?? Number(Bun.env.PORT ?? APP_PORT);
315
370
  const preferredConsole = options.consolePort ?? CONSOLE_PORT;
316
371
  const preferredMcp = options.mcpPort ?? MCP_PORT;
@@ -656,6 +711,7 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
656
711
  files: composeFiles,
657
712
  cwd: dockerOut,
658
713
  env: dockerStarted.env,
714
+ run: composeHealthRun,
659
715
  timeoutMs: 20_000,
660
716
  isDone: (map) => {
661
717
  // Empty ps (compose just-created / injectable gap): do not spin 20s.
@@ -952,6 +1008,7 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
952
1008
  files: started.files,
953
1009
  cwd: started.cwd,
954
1010
  env: started.env,
1011
+ run: composeHealthRun,
955
1012
  intervalMs: 2_000,
956
1013
  onChange: (map) => {
957
1014
  liveComposeHealth = map;
@@ -1325,6 +1382,7 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
1325
1382
  files: started.files,
1326
1383
  cwd: started.cwd,
1327
1384
  env: started.env,
1385
+ run: composeHealthRun,
1328
1386
  });
1329
1387
  if (liveComposeHealth.get("ai") === "error") heroAiStatus = "error";
1330
1388
  repaintBoard();
@@ -1362,29 +1420,18 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
1362
1420
  process.exit(0);
1363
1421
  },
1364
1422
  onRefresh: () => refreshChrome(),
1365
- // Refresh first so help/services never sit in the middle of request logs.
1423
+ // Refresh first so help never sits in the middle of request logs.
1366
1424
  onShowPanel: async (body) => {
1367
1425
  await refreshChrome();
1368
1426
  write(body);
1369
1427
  },
1370
1428
  onComposeSettled: () => syncComposeBoard(),
1371
- services: () => controlServicesFromStack(pendingStackSummary?.services ?? []),
1372
- statusOf: (serviceName) => liveComposeHealth.get(serviceName),
1373
- composeAction: async (action: DevComposeControlAction, serviceNames) => {
1429
+ composeAction: async (action: DevComposeControlAction) => {
1374
1430
  const files = started.files;
1375
1431
  const finalArgs =
1376
- action === "up" && serviceNames.length === 0
1432
+ action === "up"
1377
1433
  ? ["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];
1434
+ : ["compose", ...files.flatMap((f) => ["-f", f]), action];
1388
1435
  const proc = Bun.spawn(["docker", ...finalArgs], {
1389
1436
  cwd: started.cwd,
1390
1437
  stdout: "pipe",
@@ -207,6 +207,21 @@ describe("oke start", () => {
207
207
  expect(code).toBe(0);
208
208
  expect(ran).toBe(entry);
209
209
  });
210
+
211
+ test("sets OKE_ROOT_DIR so boot() can lazily derive effects for undeclared flows", async () => {
212
+ const dir = await mkdtemp(join(tmpdir(), "oke-cli-start-rootdir-"));
213
+ await Bun.write(join(dir, "src/app.ts"), "export {}\n");
214
+ let seenRootDir: string | undefined;
215
+ const code = await runStart({
216
+ cwd: dir,
217
+ runEntry: async (_e, env) => {
218
+ seenRootDir = env.OKE_ROOT_DIR;
219
+ },
220
+ write: () => {},
221
+ });
222
+ expect(code).toBe(0);
223
+ expect(seenRootDir).toBe(dir);
224
+ });
210
225
  });
211
226
 
212
227
  describe("oke vault", () => {
package/src/cli/start.ts CHANGED
@@ -55,7 +55,18 @@ export async function runStart(options: StartOptions = {}): Promise<number> {
55
55
  try {
56
56
  const entry = await resolveStartEntry(cwd, options.entry);
57
57
  const port = String(options.port ?? Number(Bun.env.PORT ?? APP_PORT));
58
- const env = { ...process.env, NODE_ENV: "production", PORT: port } as Record<string, string>;
58
+ const env = {
59
+ ...process.env,
60
+ NODE_ENV: "production",
61
+ PORT: port,
62
+ // Signal to boot() where to lazily extract effects from when a flow
63
+ // has no hand-declared `effects` — kernel/boot.ts's mintCapabilities()
64
+ // reads this (explicit opt-in only). docker/prod hard-fail (OKE1008)
65
+ // rather than silently open when neither this nor an explicit
66
+ // `manifest` resolves anything — this just gives that resolution a
67
+ // real chance to succeed instead of failing every time.
68
+ OKE_ROOT_DIR: process.env["OKE_ROOT_DIR"] ?? cwd,
69
+ } as Record<string, string>;
59
70
  write(`oke start: ${entry} (port ${port})\n`);
60
71
  if (options.runEntry) {
61
72
  await options.runEntry(entry, env);
@@ -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 },