okengine 0.10.1 → 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 (98) 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-controls.test.ts +43 -48
  21. package/src/cli/dev-controls.ts +23 -172
  22. package/src/cli/dev.test.ts +2 -4
  23. package/src/cli/dev.ts +37 -16
  24. package/src/client/notes-contract.test.ts +3 -4
  25. package/src/compiler/aot.test.ts +1 -2
  26. package/src/compiler/extract.test.ts +76 -21
  27. package/src/compiler/extract.ts +3 -3
  28. package/src/compiler/fixtures/raw/raw-unannotated.ts +2 -4
  29. package/src/compiler/fixtures/skyport/src/flows/bookings/index.ts +4 -8
  30. package/src/compiler/fixtures/skyport/src/flows/payments/index.ts +1 -2
  31. package/src/compiler/fixtures/skyport/src/flows/support/index.ts +1 -2
  32. package/src/compiler/fixtures/skyport.expected.json +3 -3
  33. package/src/compiler/fixtures/triggers/five-triggers.ts +5 -10
  34. package/src/compiler/generate-adopt.test.ts +85 -0
  35. package/src/compiler/generate-adopt.ts +85 -0
  36. package/src/console/server/dry-run.audit.test.ts +2 -2
  37. package/src/console/server/flows.ts +50 -150
  38. package/src/docker/compose.ts +13 -3
  39. package/src/docker/recipes/llama-cpp.ts +10 -1
  40. package/src/docker/stack-id.ts +1 -0
  41. package/src/elements/channel/declare.ts +27 -1
  42. package/src/elements/clock/chaos-child.ts +3 -6
  43. package/src/elements/clock/chaos.test.ts +1 -2
  44. package/src/elements/clock.test.ts +2 -4
  45. package/src/elements/signal/declare.ts +25 -1
  46. package/src/elements/store/declare.ts +27 -1
  47. package/src/elements/store/resource.ts +6 -11
  48. package/src/elements/store/upsert-app.test.ts +1 -2
  49. package/src/elements/vault/declare.ts +29 -1
  50. package/src/kernel/adopt-barrel-fresh.test.ts +103 -0
  51. package/src/kernel/app.ts +66 -9
  52. package/src/kernel/auto-registry.test.ts +198 -0
  53. package/src/kernel/boot-bind/honor-config.test.ts +5 -5
  54. package/src/kernel/boot.test.ts +19 -15
  55. package/src/kernel/boot.ts +81 -1
  56. package/src/kernel/call.test.ts +5 -10
  57. package/src/kernel/compensate.test.ts +3 -6
  58. package/src/kernel/concurrency.test.ts +3 -5
  59. package/src/kernel/correlation.test.ts +4 -8
  60. package/src/kernel/edge.test.ts +1 -1
  61. package/src/kernel/effects-stamping.test.ts +4 -7
  62. package/src/kernel/element-registries.ts +27 -0
  63. package/src/kernel/errors.ts +11 -0
  64. package/src/kernel/flow.test.ts +8 -15
  65. package/src/kernel/flow.ts +12 -14
  66. package/src/kernel/hook-timing.test.ts +2 -2
  67. package/src/kernel/hooks.test.ts +5 -10
  68. package/src/kernel/horizontal-child.ts +5 -10
  69. package/src/kernel/journal-boot.test.ts +2 -4
  70. package/src/kernel/pipeline.test.ts +6 -12
  71. package/src/kernel/plugin/capabilities.test.ts +1 -1
  72. package/src/kernel/plugin/decorate.test.ts +3 -8
  73. package/src/kernel/plugin/scoping.test.ts +6 -16
  74. package/src/kernel/plugin-elements.test.ts +1 -1
  75. package/src/kernel/plugin-needs.test.ts +1 -1
  76. package/src/kernel/ready.test.ts +1 -2
  77. package/src/kernel/registry-isolation.test.ts +5 -5
  78. package/src/kernel/triggers.ts +1 -1
  79. package/src/plugins/anonymous.ts +1 -3
  80. package/src/plugins/compression.test.ts +6 -8
  81. package/src/plugins/config-source.test.ts +1 -1
  82. package/src/plugins/config-source.ts +1 -2
  83. package/src/plugins/cors.test.ts +10 -10
  84. package/src/plugins/csrf.test.ts +2 -2
  85. package/src/plugins/headers.test.ts +14 -15
  86. package/src/plugins/ip-allowlist.test.ts +9 -9
  87. package/src/plugins/magic-link.ts +2 -6
  88. package/src/plugins/maintenance-mode.test.ts +6 -6
  89. package/src/plugins/otp.ts +5 -15
  90. package/src/plugins/passkey.ts +4 -12
  91. package/src/plugins/two-factor.ts +3 -9
  92. package/src/plugins/username.ts +2 -6
  93. package/src/release/measure.ts +1 -1
  94. package/src/runs/runs.test.ts +3 -5
  95. package/src/runtime/serve.test.ts +2 -4
  96. package/src/test/create-test-app.test.ts +3 -7
  97. package/src/test/provisions.integration.test.ts +4 -9
  98. package/src/test/reset-element-registries.ts +31 -0
package/README.md CHANGED
@@ -74,7 +74,7 @@ import { z } from "zod";
74
74
 
75
75
  export const health = on(
76
76
  http.get("/health").gate(gate.public),
77
- flow({
77
+ flow("health.check", {
78
78
  out: z.object({ ok: z.literal(true) }),
79
79
  do: () => ({ ok: true as const }),
80
80
  }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okengine",
3
- "version": "0.10.1",
3
+ "version": "0.10.2",
4
4
  "description": "One law. Eight elements. Ten exports. One package. One manifest. Every backend need is derived, never added.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -26,7 +26,7 @@ import { on, flow, every } from "okengine";
26
26
 
27
27
  export const purgeOld = on(
28
28
  every("1h"),
29
- flow({
29
+ flow("links.purgeOld", {
30
30
  do: async (_, fx) => {
31
31
  const cutoff = fx.clock.now() - 30 * 24 * 60 * 60 * 1000; // 30 days
32
32
  await fx.store(db).delete(links).where(lt(links.createdAt, cutoff));
@@ -55,7 +55,7 @@ export const dailyReport = clock("daily-report", {
55
55
  ```typescript title="src/flows/reports/daily.ts"
56
56
  export const sendDaily = on(
57
57
  dailyReport,
58
- flow({
58
+ flow("reports.sendDaily", {
59
59
  do: async (_, fx) => {
60
60
  /* … */
61
61
  },
@@ -28,7 +28,7 @@ import { z } from "zod";
28
28
 
29
29
  export const createOrder = on(
30
30
  http.post("/orders"),
31
- flow({
31
+ flow("orders.create", {
32
32
  in: z.object({ sku: z.string(), qty: z.number().int().min(1) }),
33
33
  out: z.object({ id: z.string() }),
34
34
  errors: { OutOfStock: z.object({ left: z.number() }) },
@@ -106,7 +106,7 @@ Built-in codes attach a localized `message` ([Errors](/docs/reference/errors));
106
106
  ```typescript
107
107
  export const findOrder = on(
108
108
  http.get("/orders/:id").gate(member), // gates evaluate before do runs
109
- flow({
109
+ flow("orders.find", {
110
110
  in: z.object({ id: z.string() }),
111
111
  out: Order,
112
112
  errors: { NotFound: z.object({}) },
@@ -124,7 +124,7 @@ await fx.emit(orderPlaced, { orderId: id }); // inside the producing flow
124
124
 
125
125
  on(
126
126
  orderPlaced,
127
- flow({
127
+ flow("orders.sendReceipt", {
128
128
  do: async ({ orderId }, fx) => {
129
129
  /* … */
130
130
  },
@@ -139,7 +139,7 @@ on(
139
139
  ```typescript
140
140
  on(
141
141
  every("1h"),
142
- flow({
142
+ flow("sessions.sweepExpired", {
143
143
  do: async (_, fx) => {
144
144
  const cutoff = fx.clock.now() - 30 * 24 * 60 * 60 * 1000;
145
145
  await fx.store(db).delete(sessions).where(lt(sessions.createdAt, cutoff));
@@ -155,7 +155,7 @@ CDC is built in; the flow receives `{ before, after }`:
155
155
  ```typescript
156
156
  on(
157
157
  db.table(orders).changed("status"),
158
- flow({
158
+ flow("orders.reverify", {
159
159
  do: ({ before, after }, fx) => fx.log.info("status", { from: before.status, to: after.status }),
160
160
  }),
161
161
  );
@@ -166,7 +166,7 @@ on(
166
166
  Drop `on()` and it is still a real Flow — contracts, Manifest entry, everything. Other flows call it through `fx.call`:
167
167
 
168
168
  ```typescript
169
- export const getOrder = flow({
169
+ export const getOrder = flow("orders.get", {
170
170
  in: OrderRef,
171
171
  out: Order,
172
172
  do: async ({ id }, fx) => {
@@ -211,7 +211,7 @@ Everything a flow may touch, on one object:
211
211
  Set `durable: true` and every `fx` call is journaled. Wrap side effects in `fx.step` and they never re-run on replay:
212
212
 
213
213
  ```typescript
214
- export const chargeOrder = flow({
214
+ export const chargeOrder = flow("payments.chargeOrder", {
215
215
  durable: true, // every fx call below is journaled
216
216
  in: OrderRef,
217
217
  out: z.boolean(),
@@ -233,14 +233,14 @@ export const chargeOrder = flow({
233
233
 
234
234
  Replay is **at-least-once** for a step whose body never persisted (crash mid-step, lease overrun): that step re-runs on reclaim, so keep step bodies short or internally idempotent. A client transport retry starts a _new_ run with a fresh `runId` — request-level idempotency is an application concern, not the journal's.
235
235
 
236
- For flaky sub-steps, wrap the work in `fx.retry` **inside** `fx.step` so a completed charge is never retried on resume. Coarse whole-body retry is also available as `flow({ retry: { retries, delay, backoff, jitter } })` and reuses the same journal session.
236
+ For flaky sub-steps, wrap the work in `fx.retry` **inside** `fx.step` so a completed charge is never retried on resume. Coarse whole-body retry is also available as `flow(name, { retry: { retries, delay, backoff, jitter } })` and reuses the same journal session.
237
237
 
238
238
  After a **terminal** failure (`fx.fail` or exhausted retries / uncaught throw), optional
239
239
  `compensate` runs under the same journal. Undo work must use **new** step names
240
240
  (`undo:…`) — never rewind forward steps:
241
241
 
242
242
  ```typescript
243
- export const chargeOrder = flow({
243
+ export const chargeOrder = flow("payments.chargeOrder", {
244
244
  durable: true,
245
245
  in: OrderRef,
246
246
  out: z.boolean(),
@@ -295,7 +295,7 @@ Use `fx.all` (or `fx.race`) with thunks. The first rejection aborts siblings thr
295
295
  </Accordion>
296
296
  <Accordion title="Durable flow retried a completed charge">
297
297
 
298
- Put `fx.retry` **inside** `fx.step`, not around it. Completed steps replay from the journal and never re-run. `flow({ retry })` is for coarse whole-body retries on the same session.
298
+ Put `fx.retry` **inside** `fx.step`, not around it. Completed steps replay from the journal and never re-run. `flow(name, { retry })` is for coarse whole-body retries on the same session.
299
299
 
300
300
  </Accordion>
301
301
  <Accordion title="When does compensate run?">
@@ -66,7 +66,7 @@ import { member, canBook, fair } from "../../gates";
66
66
 
67
67
  export const health = on(
68
68
  http.get("/health").gate(gate.public),
69
- flow({
69
+ flow("health.check", {
70
70
  out: HealthOut,
71
71
  do: () => ({ ok: true as const }),
72
72
  }),
@@ -74,7 +74,7 @@ export const health = on(
74
74
 
75
75
  export const shorten = on(
76
76
  http.post("/links").gate(member, canBook, fair),
77
- flow({
77
+ flow("links.shorten", {
78
78
  in: NewLink,
79
79
  out: LinkCode,
80
80
  errors: { Taken },
@@ -174,8 +174,8 @@ Every HTTP trigger must carry a gate or `gate.public`. Omitting both fails boot
174
174
  `GateBootError` — message starts with `gate boot failed —` and lists every gap.
175
175
 
176
176
  ```typescript
177
- on(http.get("/health").gate(gate.public), flow({/* … */}));
178
- on(http.post("/bookings").gate(member, canBook), flow({/* … */}));
177
+ on(http.get("/health").gate(gate.public), flow("health.check", {/* … */}));
178
+ on(http.post("/bookings").gate(member, canBook), flow("bookings.create", {/* … */}));
179
179
  ```
180
180
 
181
181
  **Consequence:** first `fetch` / `execute` auto-boots (`autoBoot` defaults to `true`).
@@ -58,7 +58,7 @@ A signal is a trigger like any other — the consumer is an ordinary Flow:
58
58
  ```typescript title="src/flows/orders/send-confirmation.ts"
59
59
  export const sendConfirmation = on(
60
60
  orderPlaced,
61
- flow({
61
+ flow("orders.sendConfirmation", {
62
62
  in: z.object({ orderId: z.string(), total: z.number() }),
63
63
  do: async (input, fx) => {
64
64
  await fx.send(orderConfirmed, { to: "user-1", data: input });
@@ -131,8 +131,7 @@ export const orderStatus = signal("order-status", {
131
131
  ```typescript title="src/flows/fulfillment/on-order.ts"
132
132
  on(
133
133
  orderPlaced,
134
- flow({
135
- name: "fulfillment.onOrder",
134
+ flow("fulfillment.onOrder", {
136
135
  do: async ({ orderId }, fx) => {
137
136
  await fx.emit(orderChanged, { orderId, kind: "placed" });
138
137
  await fx.emit(orderStatus, { orderId, status: "fulfilling" });
@@ -147,8 +146,7 @@ on(
147
146
  ```typescript title="src/flows/orders/side-effects.ts"
148
147
  on(
149
148
  orderChanged,
150
- flow({
151
- name: "cache.onOrderChanged",
149
+ flow("cache.onOrderChanged", {
152
150
  do: async ({ orderId }, fx) => {
153
151
  /* invalidate order:{orderId} */
154
152
  },
@@ -157,8 +155,7 @@ on(
157
155
 
158
156
  on(
159
157
  orderChanged,
160
- flow({
161
- name: "notify.onOrderChanged",
158
+ flow("notify.onOrderChanged", {
162
159
  do: async ({ orderId }, fx) => {
163
160
  await fx.send(orderConfirmed, { to: "user-1", data: { orderId } });
164
161
  },
@@ -80,7 +80,7 @@ All data access goes through `fx.store(db)` — a typed session, one table at a
80
80
  ```typescript title="src/flows/notes/create.ts"
81
81
  export const createNote = on(
82
82
  http.post("/notes"),
83
- flow({
83
+ flow("notes.create", {
84
84
  in: z.object({ title: z.string(), body: z.string() }),
85
85
  out: z.object({ id: z.string() }),
86
86
  do: async (input, fx) => {
@@ -62,7 +62,7 @@ Call `.reveal()` once at the boundary that needs the real credential:
62
62
  ```typescript title="src/flows/billing/charge.ts"
63
63
  export const charge = on(
64
64
  http.post("/charge"),
65
- flow({
65
+ flow("billing.charge", {
66
66
  in: ChargeInput,
67
67
  out: z.object({ id: z.string() }),
68
68
  do: async (input, fx) => {
@@ -44,9 +44,7 @@ import { z } from "zod";
44
44
 
45
45
  export const health = on(
46
46
  http.get("/health").gate(gate.public),
47
- flow({
48
- name: "main.health",
49
- unit: "main",
47
+ flow("main.health", {
50
48
  out: z.object({ ok: z.literal(true) }),
51
49
  do: () => ({ ok: true as const }),
52
50
  }),
@@ -29,7 +29,7 @@ import { z } from "zod";
29
29
 
30
30
  export const health = on(
31
31
  http.get("/health"),
32
- flow({
32
+ flow("health.check", {
33
33
  out: z.object({ ok: z.literal(true) }),
34
34
  do: () => ({ ok: true as const }),
35
35
  }),
@@ -67,18 +67,18 @@ so ● updates live if you stop a container; AI ● tracks model phase while the
67
67
  AI container is up. Boot does not wait for the model to become ready.
68
68
 
69
69
  On a TTY with `-d`, keyboard controls stay active under Logs. Status ● for
70
- compose/AI live on the board **above** Logs (not as log lines). Press `c` to
71
- clear the log pane and reprint the latest board. `l` / `?` also refresh first
72
- so the services/help panel is not mixed into request logs.
73
-
74
- | Key | Action |
75
- | ---------------------------- | ----------------------------------- |
76
- | `?` | help |
77
- | `c` | refresh — clear logs, show latest ● |
78
- | `q` | quit `oke dev` |
79
- | `l` | list docker services |
80
- | `u` / `x` | compose up / stop (whole stack) |
81
- | `1`–`9` then `u` / `x` / `r` | start / stop / restart one service |
70
+ compose/AI live on the board **above** Logs (not as log lines) it already
71
+ lists every service, so refresh is enough to see the stack; there is no
72
+ separate services panel. Press `r` to clear the log pane and reprint the
73
+ latest board. `?` also refreshes first so the help panel is not mixed into
74
+ request logs.
75
+
76
+ | Key | Action |
77
+ | --------- | ----------------------------------- |
78
+ | `?` | help |
79
+ | `r` | refresh clear logs, show latest ● |
80
+ | `q` | quit `oke dev` |
81
+ | `u` / `x` | compose up / stop (whole stack) |
82
82
 
83
83
  Compose stays quiet unless it fails. Process-local boot notices print once under
84
84
  the hero (not once per process).
@@ -7,7 +7,7 @@ icon: "Braces"
7
7
  `fx` is the second argument of every `do` — the single door to the world. This page is the whole surface; each entry notes the **effect it records**, which is what feeds the Manifest, caching, and capability checks.
8
8
 
9
9
  ```typescript
10
- flow({
10
+ flow("orders.example", {
11
11
  do: async (input, fx) => {
12
12
  // everything below, on this one object
13
13
  },
@@ -51,8 +51,7 @@ Declare `effects: { reads: ["runs"] }`. Powers native SLO checkers (Clock + Chan
51
51
  ```typescript
52
52
  on(
53
53
  every("5m"),
54
- flow({
55
- name: "ops.slo-check",
54
+ flow("ops.slo-check", {
56
55
  effects: { reads: ["runs"], sends: ["slo-alert"] },
57
56
  do: async (_, fx) => {
58
57
  const breaches = await fx.runs.checkSlo(
@@ -133,7 +132,7 @@ const rows = await fx.using(
133
132
  );
134
133
  ```
135
134
 
136
- **Consequence:** put `fx.retry` inside `fx.step` on durable flows so a completed charge never re-runs on resume. Coarse whole-body retry is `flow({ retry: { … } })` on the same journal session.
135
+ **Consequence:** put `fx.retry` inside `fx.step` on durable flows so a completed charge never re-runs on resume. Coarse whole-body retry is `flow(name, { retry: { … } })` on the same journal session.
137
136
 
138
137
  ## Channel
139
138
 
@@ -177,8 +177,7 @@ describe("cross-plane compile error", () => {
177
177
  path: "src/flows/console/store.ts",
178
178
  flow: "console.store.query",
179
179
  source: `
180
- export const query = flow({
181
- name: "console.store.query",
180
+ export const query = flow("console.store.query", {
182
181
  plane: "operator",
183
182
  do: (_i, fx) => fx.auth.userId,
184
183
  });
@@ -205,9 +205,7 @@ export function createAuthHttpBindings(
205
205
 
206
206
  const base = config.basePath;
207
207
 
208
- const refresh = flow({
209
- name: "auth.refresh",
210
- unit: "auth",
208
+ const refresh = flow("auth.refresh", {
211
209
  plane: "user",
212
210
  in: RefreshIn,
213
211
  out: SessionTokensOut,
@@ -231,9 +229,7 @@ export function createAuthHttpBindings(
231
229
  },
232
230
  });
233
231
 
234
- const revoke = flow({
235
- name: "auth.revoke",
236
- unit: "auth",
232
+ const revoke = flow("auth.revoke", {
237
233
  plane: "user",
238
234
  in: RevokeIn,
239
235
  out: z.object({ ok: z.literal(true) }),
@@ -252,9 +248,7 @@ export function createAuthHttpBindings(
252
248
  },
253
249
  });
254
250
 
255
- const me = flow({
256
- name: "auth.me",
257
- unit: "auth",
251
+ const me = flow("auth.me", {
258
252
  plane: "user",
259
253
  out: MeOut,
260
254
  errors: { AuthFailed },
@@ -289,9 +283,7 @@ export function createAuthHttpBindings(
289
283
  ];
290
284
 
291
285
  if (config.emailAndPassword.enabled) {
292
- const signInEmail = flow({
293
- name: "auth.signInEmail",
294
- unit: "auth",
286
+ const signInEmail = flow("auth.signInEmail", {
295
287
  plane: "user",
296
288
  in: EmailPasswordIn,
297
289
  out: SessionTokensOut,
@@ -319,9 +311,7 @@ export function createAuthHttpBindings(
319
311
  },
320
312
  });
321
313
 
322
- const signUpEmail = flow({
323
- name: "auth.signUpEmail",
324
- unit: "auth",
314
+ const signUpEmail = flow("auth.signUpEmail", {
325
315
  plane: "user",
326
316
  in: EmailPasswordIn,
327
317
  out: SessionTokensOut,
@@ -136,7 +136,7 @@ describe("gate.auth — autoBoot / posture", () => {
136
136
  });
137
137
 
138
138
  test(".needs(auth) satisfied by gate.auth without .plug(auth())", async () => {
139
- on(http.get("/x").gate(AUTH_SESSION_GATE), flow({ name: "x", do: () => ({ ok: true }) }));
139
+ on(http.get("/x").gate(AUTH_SESSION_GATE), flow("x", { do: () => ({ ok: true }) }));
140
140
  const dependent = plugin("needs-auth", { version: "0.0.1" }).needs("auth");
141
141
  const app = oke({
142
142
  name: "needs-gate-auth",
@@ -309,7 +309,7 @@ describe("gate.auth — email Flows + security", () => {
309
309
  });
310
310
 
311
311
  test("missing gate on custom HTTP still GateBootError", async () => {
312
- on(http.get("/open"), flow({ name: "open", do: () => ({ ok: true }) }));
312
+ on(http.get("/open"), flow("open", { do: () => ({ ok: true }) }));
313
313
  const app = oke({
314
314
  name: "gap",
315
315
  env: "local",
@@ -5,6 +5,7 @@
5
5
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
6
  import { dirname, join } from "node:path";
7
7
  import { OLLAMA_IMAGE } from "../../docker/recipes/index.ts";
8
+ import { DEFAULT_DOCKER_DIR } from "../../docker/types.ts";
8
9
 
9
10
  /** Choices applied to the project. */
10
11
  export type AiSetupApplyInput = {
@@ -91,6 +92,18 @@ export function applyAiSetup(
91
92
  }
92
93
  writeFileSync(envPath, env.endsWith("\n") ? env : `${env}\n`, "utf8");
93
94
 
95
+ // `docker/.env.docker` is regenerated from `.env.local` only on its first
96
+ // boot (existing values there are treated as durable, possibly hand-edited
97
+ // pins — see stack-id.ts). Once it exists, a later `oke ai setup` run must
98
+ // update it directly, or the docker profile keeps the old model forever.
99
+ if (input.chatModel) {
100
+ const dockerEnvPath = join(cwd, DEFAULT_DOCKER_DIR, ".env.docker");
101
+ if (existsSync(dockerEnvPath)) {
102
+ const dockerEnv = readFileSync(dockerEnvPath, "utf8");
103
+ writeFileSync(dockerEnvPath, upsertEnv(dockerEnv, "OKE_AI_MODEL", input.chatModel), "utf8");
104
+ }
105
+ }
106
+
94
107
  const aiTsPath = join(cwd, "src", "core", "ai.ts");
95
108
  mkdirSync(dirname(aiTsPath), { recursive: true });
96
109
  writeFileSync(aiTsPath, renderAiTs(input), "utf8");
@@ -0,0 +1,67 @@
1
+ /**
2
+ * `oke build` regenerates `src/flows/generated.ts` before bundling — real
3
+ * before/after proof, not an assumption about the wiring.
4
+ */
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { runBuild } from "./build.ts";
11
+
12
+ async function makeUnit(root: string, name: string): Promise<void> {
13
+ const dir = join(root, "src/flows", name);
14
+ await mkdir(dir, { recursive: true });
15
+ await writeFile(join(dir, "index.ts"), "export const nothing = 1;\n");
16
+ }
17
+
18
+ describe("oke build — .adopt() barrel regeneration", () => {
19
+ test("real before/after: no generated.ts before build, real barrel content after", async () => {
20
+ const root = await mkdtemp(join(tmpdir(), "oke-build-adopt-"));
21
+ try {
22
+ await makeUnit(root, "main");
23
+ await makeUnit(root, "notes");
24
+
25
+ const generatedPath = join(root, "src/flows/generated.ts");
26
+ await expect(readFile(generatedPath, "utf8")).rejects.toThrow();
27
+
28
+ const code = await runBuild({
29
+ rootDir: root,
30
+ bundle: async () => ({ success: true, logs: "" }),
31
+ });
32
+ expect(code).toBe(0);
33
+
34
+ const after = await readFile(generatedPath, "utf8");
35
+ expect(after).toContain('export * as main from "./main/index.ts";');
36
+ expect(after).toContain('export * as notes from "./notes/index.ts";');
37
+ } finally {
38
+ await rm(root, { recursive: true, force: true });
39
+ }
40
+ });
41
+
42
+ test("regeneration runs before bundling, using the injected syncAdoptBarrel", async () => {
43
+ const calls: string[] = [];
44
+ const code = await runBuild({
45
+ rootDir: "/fake/root",
46
+ syncAdoptBarrel: async (rootDir) => {
47
+ calls.push(`sync:${rootDir}`);
48
+ return ["main", "notes"];
49
+ },
50
+ bundle: async () => {
51
+ calls.push("bundle");
52
+ return { success: true, logs: "" };
53
+ },
54
+ });
55
+ expect(code).toBe(0);
56
+ expect(calls).toEqual(["sync:/fake/root", "bundle"]);
57
+ });
58
+
59
+ test("bundle failure still returns non-zero after a successful sync", async () => {
60
+ const code = await runBuild({
61
+ rootDir: "/fake/root",
62
+ syncAdoptBarrel: async () => [],
63
+ bundle: async () => ({ success: false, logs: "boom" }),
64
+ });
65
+ expect(code).toBe(1);
66
+ });
67
+ });
package/src/cli/build.ts CHANGED
@@ -2,18 +2,46 @@
2
2
  * `oke build --target edge` — tree-shaken kernel profile.
3
3
  */
4
4
 
5
+ import { writeFile } from "node:fs/promises";
6
+ import { join } from "node:path";
7
+
5
8
  /** Options for {@link runBuild}. */
6
9
  export interface BuildOptions {
7
10
  readonly target?: "bun" | "node" | "edge";
8
11
  readonly entry?: string;
9
12
  readonly outdir?: string;
10
13
  readonly write?: (text: string) => void;
14
+ /** Project root for the `.adopt()` barrel sync (default `process.cwd()`). */
15
+ readonly rootDir?: string;
11
16
  /** Inject bun.build (tests). */
12
17
  readonly bundle?: (opts: {
13
18
  entry: string;
14
19
  outdir: string;
15
20
  target: "bun" | "node" | "browser";
16
21
  }) => Promise<{ success: boolean; logs: string }>;
22
+ /**
23
+ * Inject `.adopt()` barrel regeneration (tests). Default: real
24
+ * `generateAdoptBarrel` + write to `<rootDir>/src/flows/generated.ts`.
25
+ * Returns the unit names written, for the status line.
26
+ */
27
+ readonly syncAdoptBarrel?: (rootDir: string) => Promise<readonly string[]>;
28
+ }
29
+
30
+ /**
31
+ * Regenerate `src/flows/generated.ts` from every `src/flows/<unit>/index.ts`
32
+ * unit folder — pre-step for `oke build` (mirrors `oke db`'s `schema.generated.ts`
33
+ * pre-step). A real file on disk, not a virtual module: identical resolution
34
+ * under `oke dev`'s runtime `import()` and `oke build`'s `Bun.build()`
35
+ * (investigated — a virtual-module Bun plugin does not resolve consistently
36
+ * across those two paths).
37
+ *
38
+ * @param rootDir - Project root
39
+ */
40
+ async function defaultSyncAdoptBarrel(rootDir: string): Promise<readonly string[]> {
41
+ const { generateAdoptBarrel } = await import("../compiler/generate-adopt.ts");
42
+ const { source, units } = await generateAdoptBarrel({ rootDir });
43
+ await writeFile(join(rootDir, "src/flows/generated.ts"), source);
44
+ return units;
17
45
  }
18
46
 
19
47
  /**
@@ -26,6 +54,7 @@ export async function runBuild(options: BuildOptions = {}): Promise<number> {
26
54
  const target = options.target ?? "bun";
27
55
  const entry = options.entry ?? "src/app.ts";
28
56
  const outdir = options.outdir ?? "dist";
57
+ const rootDir = options.rootDir ?? process.cwd();
29
58
  const bunTarget = target === "edge" ? "browser" : target === "node" ? "node" : "bun";
30
59
 
31
60
  const bundle =
@@ -43,8 +72,13 @@ export async function runBuild(options: BuildOptions = {}): Promise<number> {
43
72
  logs: result.logs.map((l) => String(l)).join("\n"),
44
73
  };
45
74
  });
75
+ const syncAdoptBarrel = options.syncAdoptBarrel ?? defaultSyncAdoptBarrel;
46
76
 
47
77
  try {
78
+ const units = await syncAdoptBarrel(rootDir);
79
+ write(
80
+ `oke build: .adopt() barrel → src/flows/generated.ts (${units.length} unit${units.length === 1 ? "" : "s"})\n`,
81
+ );
48
82
  const result = await bundle({ entry, outdir, target: bunTarget });
49
83
  if (!result.success) {
50
84
  console.error(result.logs || "oke build: failed");
@@ -233,7 +233,7 @@ export async function bootSeedFx(
233
233
  if (!app) {
234
234
  throw new Error(`oke db seed: no oke() app export in ${entryAbs}`);
235
235
  }
236
- await app.boot({ env, startScheduler: false, config: config ?? undefined });
236
+ await app.boot({ env, startScheduler: false, config: config ?? undefined, rootDir: cwd });
237
237
  const storeRuntime = app.elements?.store ?? app.bootResult?.store;
238
238
  if (!storeRuntime) {
239
239
  throw new Error(