okengine 0.6.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/AGENTS.md +2 -2
  2. package/README.md +73 -181
  3. package/package.json +3 -3
  4. package/site/content/docs/console/clock.mdx +25 -5
  5. package/site/content/docs/console/signals.mdx +13 -4
  6. package/site/content/docs/elements/ai.mdx +29 -2
  7. package/site/content/docs/elements/channel.mdx +42 -25
  8. package/site/content/docs/elements/clock.mdx +59 -25
  9. package/site/content/docs/elements/flow.mdx +8 -2
  10. package/site/content/docs/elements/gate.mdx +8 -1
  11. package/site/content/docs/elements/signal.mdx +173 -29
  12. package/site/content/docs/elements/store.mdx +595 -73
  13. package/site/content/docs/elements/vault.mdx +15 -5
  14. package/site/content/docs/get-started/basic-usage.mdx +13 -6
  15. package/site/content/docs/get-started/installation.mdx +52 -14
  16. package/site/content/docs/get-started/introduction.mdx +1 -1
  17. package/site/content/docs/plugins/magic-link.mdx +42 -0
  18. package/site/content/docs/plugins/phone-number.mdx +78 -17
  19. package/site/content/docs/plugins/two-factor.mdx +1 -0
  20. package/site/content/docs/reference/cli.md +32 -2
  21. package/site/content/docs/reference/configuration.mdx +15 -13
  22. package/site/content/docs/reference/environment-variables.mdx +32 -18
  23. package/site/content/docs/reference/errors.mdx +12 -11
  24. package/site/content/docs/reference/fx.mdx +15 -13
  25. package/site/content/docs/reference/security.md +3 -1
  26. package/src/cli/ai-setup/ai-setup.test.ts +144 -0
  27. package/src/cli/ai-setup/apply.ts +214 -0
  28. package/src/cli/ai-setup/catalog.ts +263 -0
  29. package/src/cli/ai-setup/detect-ollama.ts +166 -0
  30. package/src/cli/ai-setup/index.ts +228 -0
  31. package/src/cli/ai-setup/prompts.ts +649 -0
  32. package/src/cli/ai-setup/recommend.test.ts +100 -0
  33. package/src/cli/ai-setup/recommend.ts +203 -0
  34. package/src/cli/ai.ts +38 -0
  35. package/src/cli/db-auto-push.test.ts +6 -1
  36. package/src/cli/db-auto-push.ts +8 -3
  37. package/src/cli/db-seed.ts +359 -0
  38. package/src/cli/db.test.ts +341 -3
  39. package/src/cli/db.ts +75 -8
  40. package/src/cli/dev-db-push.test.ts +59 -0
  41. package/src/cli/dev.test.ts +40 -0
  42. package/src/cli/dev.ts +55 -1
  43. package/src/cli/docker-clean.test.ts +179 -0
  44. package/src/cli/docker-clean.ts +406 -0
  45. package/src/cli/docker.ts +16 -4
  46. package/src/cli/ensure-drizzle-config.ts +1 -1
  47. package/src/cli/hero-meta.test.ts +6 -5
  48. package/src/cli/hero-meta.ts +7 -1
  49. package/src/cli/index.ts +5 -0
  50. package/src/cli/load-config.images.test.ts +22 -0
  51. package/src/cli/load-config.ts +7 -2
  52. package/src/cli/openbao-bootstrap.test.ts +1 -0
  53. package/src/cli/openbao-bootstrap.ts +9 -2
  54. package/src/cli/registry.ts +116 -2
  55. package/src/client/live-gap.test.ts +35 -0
  56. package/src/client/transport.test.ts +21 -0
  57. package/src/client/transport.ts +27 -5
  58. package/src/compiler/effects-infer.ts +1 -0
  59. package/src/compiler/fixtures/skyport/oke.config.ts +1 -1
  60. package/src/config/define-config.test.ts +6 -6
  61. package/src/config/index.ts +5 -1
  62. package/src/console/server/console.test.ts +45 -0
  63. package/src/console/server/flows.ts +39 -9
  64. package/src/console/server/i18n.ts +41 -0
  65. package/src/console/server/vault.ts +12 -38
  66. package/src/console/ui/dist/assets/index-ClX0ZCe5.js +10 -0
  67. package/src/console/ui/dist/assets/{panel-access-BGv45snf.js → panel-access-ri1X9Otj.js} +1 -1
  68. package/src/console/ui/dist/assets/{panel-ai-B2S7LEii.js → panel-ai-DLHjRoJk.js} +1 -1
  69. package/src/console/ui/dist/assets/{panel-architecture-D7UJh91v.js → panel-architecture-DI3bQF3J.js} +1 -1
  70. package/src/console/ui/dist/assets/{panel-channels-9T3ybqRu.js → panel-channels-BDYWX29p.js} +1 -1
  71. package/src/console/ui/dist/assets/{panel-clock-Cb1UXGRQ.js → panel-clock-i-JpTjVB.js} +1 -1
  72. package/src/console/ui/dist/assets/{panel-diff-DmYbKWmN.js → panel-diff-CFuWxiXy.js} +1 -1
  73. package/src/console/ui/dist/assets/{panel-flows-PiHwT55z.js → panel-flows-B8TRhcoe.js} +1 -1
  74. package/src/console/ui/dist/assets/{panel-gates-BQGYXvjT.js → panel-gates-CfkBgco7.js} +1 -1
  75. package/src/console/ui/dist/assets/{panel-overview-BBnRO18l.js → panel-overview-DONDzBd2.js} +1 -1
  76. package/src/console/ui/dist/assets/{panel-plugins-D0PsmVw2.js → panel-plugins-DDJwTehL.js} +1 -1
  77. package/src/console/ui/dist/assets/{panel-runs-CWuRDe0r.js → panel-runs-D5zf-D9c.js} +1 -1
  78. package/src/console/ui/dist/assets/{panel-signals-Bbg4ewpP.js → panel-signals-Dsxdu_AR.js} +1 -1
  79. package/src/console/ui/dist/assets/{panel-store-CPCbsDRa.js → panel-store-hy7O8HOs.js} +1 -1
  80. package/src/console/ui/dist/assets/{panel-traces-DVAzuA_S.js → panel-traces-CBiAe5go.js} +1 -1
  81. package/src/console/ui/dist/assets/{panel-vault-D1_MvOmo.js → panel-vault-Za1GyJfM.js} +1 -1
  82. package/src/console/ui/dist/index.html +1 -1
  83. package/src/console/ui/shell/components/ui.tsx +5 -1
  84. package/src/console/ui/shell/setup/Wizard.tsx +26 -5
  85. package/src/docker/cleanup.test.ts +193 -0
  86. package/src/docker/cleanup.ts +355 -0
  87. package/src/docker/docker.test.ts +4 -0
  88. package/src/docker/index.ts +19 -0
  89. package/src/docker/recipes/ollama.ts +10 -5
  90. package/src/drivers/ai-ollama.ts +45 -2
  91. package/src/drivers/ai-providers.test.ts +31 -0
  92. package/src/drivers/channel-sently.test.ts +8 -0
  93. package/src/drivers/channel-taqnyat-mail.ts +34 -0
  94. package/src/drivers/channel-types.ts +71 -0
  95. package/src/drivers/clock-postgres.test.ts +258 -0
  96. package/src/drivers/clock-postgres.ts +410 -0
  97. package/src/drivers/index.ts +21 -0
  98. package/src/drivers/journal-postgres.test.ts +175 -0
  99. package/src/drivers/journal-postgres.ts +492 -0
  100. package/src/drivers/signal-engine.ts +98 -25
  101. package/src/drivers/signal-nats.ts +4 -4
  102. package/src/drivers/signal-postgres.ts +158 -64
  103. package/src/drivers/signal-redis.ts +4 -4
  104. package/src/drivers/signal-types.ts +61 -2
  105. package/src/drivers/vault-driver-removal.test.ts +5 -1
  106. package/src/elements/channel/runtime.ts +51 -0
  107. package/src/elements/channel.test.ts +71 -0
  108. package/src/elements/clock/chaos-child.ts +389 -0
  109. package/src/elements/clock/chaos.test.ts +204 -0
  110. package/src/elements/clock/durable.ts +7 -0
  111. package/src/elements/clock/reconcile.ts +110 -0
  112. package/src/elements/clock/runtime.ts +13 -0
  113. package/src/elements/clock.test.ts +164 -2
  114. package/src/elements/clock.ts +2 -1
  115. package/src/elements/gate/runtime.ts +6 -0
  116. package/src/elements/signal/chaos-child.ts +41 -4
  117. package/src/elements/signal/declare.ts +4 -1
  118. package/src/elements/signal/delivery-modes.test.ts +179 -0
  119. package/src/elements/signal/key-ordering.test.ts +306 -0
  120. package/src/elements/signal/lease-reclaim.test.ts +123 -0
  121. package/src/elements/signal/optional-emit.test.ts +76 -0
  122. package/src/elements/signal/order-lifecycle.test.ts +130 -0
  123. package/src/elements/signal/orphan-messages.test.ts +152 -0
  124. package/src/elements/signal/runtime.ts +11 -4
  125. package/src/elements/signal/schema-emit.test.ts +107 -0
  126. package/src/elements/signal.test.ts +89 -5
  127. package/src/elements/store/files-fx.ts +104 -0
  128. package/src/elements/store/files-image.test.ts +251 -0
  129. package/src/elements/store/files-image.ts +494 -0
  130. package/src/elements/store/runtime.ts +60 -0
  131. package/src/elements/store/seed.test.ts +27 -0
  132. package/src/elements/store/seed.ts +68 -0
  133. package/src/elements/store/sql-session.test.ts +39 -0
  134. package/src/elements/store/sql-session.ts +55 -0
  135. package/src/elements/store/upsert-app.test.ts +103 -0
  136. package/src/elements/store.ts +29 -0
  137. package/src/elements/vault/boot-chain.ts +150 -0
  138. package/src/elements/vault/runtime.ts +8 -0
  139. package/src/index.ts +15 -0
  140. package/src/kernel/app.ts +168 -15
  141. package/src/kernel/boot-bind/channel.test.ts +16 -0
  142. package/src/kernel/boot-bind/channel.ts +13 -0
  143. package/src/kernel/boot-bind/clock.ts +63 -7
  144. package/src/kernel/boot-bind/gate.ts +71 -3
  145. package/src/kernel/boot-bind/honor-config.test.ts +348 -0
  146. package/src/kernel/boot-bind/journal.ts +89 -0
  147. package/src/kernel/boot-bind/signal.ts +64 -7
  148. package/src/kernel/boot-bind/vault.ts +27 -8
  149. package/src/kernel/boot.test.ts +6 -4
  150. package/src/kernel/boot.ts +63 -15
  151. package/src/kernel/concurrency.ts +1 -1
  152. package/src/kernel/errors.ts +8 -2
  153. package/src/kernel/fx.test.ts +19 -0
  154. package/src/kernel/fx.ts +153 -9
  155. package/src/kernel/index.ts +6 -0
  156. package/src/kernel/journal-boot.test.ts +397 -0
  157. package/src/kernel/journal-suspend.ts +35 -0
  158. package/src/kernel/journal.test.ts +142 -0
  159. package/src/kernel/journal.ts +202 -27
  160. package/src/plugins/auth-methods.security.test.ts +10 -7
  161. package/src/plugins/phone-number.ts +67 -10
  162. package/src/plugins/taqnyat.live.test.ts +174 -0
  163. package/src/runtime/dev-request-log.test.ts +20 -1
  164. package/src/runtime/dev-request-log.ts +38 -0
  165. package/src/term.test.ts +15 -0
  166. package/src/term.ts +8 -3
  167. package/src/console/ui/dist/assets/index-CjxwRGVv.js +0 -10
package/src/cli/docker.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `oke docker` — Dockerfile + per-role compose files.
2
+ * `oke docker` — Dockerfile + per-role compose files · `clean` leftover stacks.
3
3
  */
4
4
 
5
5
  import { resolve } from "node:path";
@@ -10,6 +10,8 @@ import {
10
10
  type DeriveOptions,
11
11
  type DeriveResult,
12
12
  } from "../docker/index.ts";
13
+ import { dockerCleanCli, dockerCleanHelp } from "./docker-clean.ts";
14
+ import { EXIT_OK } from "./exit.ts";
13
15
  import { loadOkeConfig, loadManifest, resolveImages } from "./load-config.ts";
14
16
 
15
17
  /** Options for {@link runDockerDerive}. */
@@ -112,11 +114,16 @@ export async function runDockerDerive(
112
114
  }
113
115
 
114
116
  /**
115
- * CLI entry for `oke docker [--prod|-p] [--out|-o dir]`.
117
+ * CLI entry for `oke docker [clean] …` / derive flags.
116
118
  *
117
119
  * @param args - Args after `docker`
118
120
  */
119
121
  export async function dockerCli(args: readonly string[]): Promise<number> {
122
+ const [head, ...rest] = args;
123
+ if (head === "clean") {
124
+ return dockerCleanCli(rest);
125
+ }
126
+
120
127
  let prod = false;
121
128
  let outDir: string | undefined;
122
129
  let configPath: string | undefined;
@@ -132,8 +139,13 @@ export async function dockerCli(args: readonly string[]): Promise<number> {
132
139
 
133
140
  Derive Dockerfile + compose.<role>.yml under docker/ (default).
134
141
  --prod is opt-in (never the default). Credentials are never written into YAML.
135
- `);
136
- return 0;
142
+
143
+ ${dockerCleanHelp()}`);
144
+ return EXIT_OK;
145
+ } else if (!a.startsWith("-")) {
146
+ console.error(`oke docker: unknown subcommand ${a}`);
147
+ console.error("Run `oke docker --help` for usage.");
148
+ return 1;
137
149
  }
138
150
  }
139
151
  const { code } = await runDockerDerive({
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { resolve } from "node:path";
7
7
 
8
- /** Mode-aware template (matches `packages/create-oke/template/drizzle.config.ts`). */
8
+ /** Mode-aware template (matches create-oke templates' drizzle.config.ts). */
9
9
  const DRIZZLE_CONFIG_TEMPLATE = `import { defineConfig } from "drizzle-kit";
10
10
 
11
11
  /**
@@ -18,9 +18,9 @@ const SAMPLE: OkeConfig = {
18
18
  sql: { local: "sqlite", docker: "postgres", prod: "postgres" },
19
19
  kv: { local: "memory", docker: "redis", prod: "redis" },
20
20
  },
21
- signal: { local: "memory", docker: "postgres", prod: "postgres" },
22
- clock: { local: "memory", docker: "postgres", prod: "postgres" },
23
- vault: { local: "dotenv", docker: "openbao", prod: "openbao" },
21
+ signal: { local: "memory", docker: "redis", prod: "redis" },
22
+ clock: { local: "memory", docker: "file", prod: "file" },
23
+ vault: { local: "env", docker: "openbao", prod: "openbao" },
24
24
  channel: {
25
25
  email: { local: "console", docker: "smtp", prod: "smtp" },
26
26
  },
@@ -62,8 +62,9 @@ describe("hero-meta", () => {
62
62
  expect(store).toContain("postgres");
63
63
  expect(store).toContain("redis");
64
64
  expect(store).not.toContain("sqlite");
65
- expect(rows.find((r) => r.element === "signal")?.detail).toBe("postgres");
66
- expect(rows.find((r) => r.element === "clock")?.detail).toBe("postgres");
65
+ expect(rows.find((r) => r.element === "signal")?.detail).toBe("redis");
66
+ expect(rows.find((r) => r.element === "clock")?.detail).toBe("file");
67
+ expect(rows.find((r) => r.element === "gate")?.detail).toBe("redis");
67
68
  expect(rows.find((r) => r.element === "channel")?.detail).toContain("smtp");
68
69
  });
69
70
 
@@ -156,6 +156,12 @@ export function resolveHeroElements(
156
156
  // `-d` reads the `docker` profile for every element (not a store-only override).
157
157
  const configEnv: ConfigEnv = options.configEnv ?? (options.docker ? "docker" : "local");
158
158
 
159
+ // Gate rates share `drivers.store.kv` (no separate drivers.gate).
160
+ const gateKv =
161
+ options.kvDriver ??
162
+ resolveDriverId(drivers?.store?.kv, configEnv) ??
163
+ (options.docker ? "redis" : "memory");
164
+
159
165
  const byName: Record<string, string> = {
160
166
  flow: "●",
161
167
  signal: resolveDriverId(drivers?.signal, configEnv) ?? "—",
@@ -164,7 +170,7 @@ export function resolveHeroElements(
164
170
  kv: options.kvDriver,
165
171
  }),
166
172
  clock: resolveDriverId(drivers?.clock, configEnv) ?? "—",
167
- gate: "—",
173
+ gate: gateKv,
168
174
  vault: resolveDriverId(drivers?.vault, configEnv) ?? "—",
169
175
  channel: channelDetail(drivers?.channel, configEnv),
170
176
  ai: resolveDriverId(drivers?.ai, configEnv) ?? "—",
package/src/cli/index.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  * `oke` CLI entry.
4
4
  */
5
5
 
6
+ import { aiCli } from "./ai.ts";
6
7
  import { branchCli } from "./branch.ts";
7
8
  import { buildCli } from "./build.ts";
8
9
  import { clientAddCli } from "./client-add.ts";
@@ -79,6 +80,10 @@ if (cmd === "eval") {
79
80
  process.exit(await evalCli(sub ? [sub, ...rest] : rest));
80
81
  }
81
82
 
83
+ if (cmd === "ai") {
84
+ process.exit(await aiCli(sub ? [sub, ...rest] : rest));
85
+ }
86
+
82
87
  if (cmd === "branch") {
83
88
  process.exit(await branchCli(sub ? [sub, ...rest] : rest));
84
89
  }
@@ -37,6 +37,28 @@ describe("defaultImagesFromConfig", () => {
37
37
  expect(images["store.sql"]).toBe("pgvector/pgvector:pg17");
38
38
  });
39
39
 
40
+ test("clock postgres alone pulls store.sql image", () => {
41
+ const images = defaultImagesFromConfig({
42
+ drivers: {
43
+ clock: { prod: "postgres" },
44
+ },
45
+ });
46
+ expect(images).toEqual({
47
+ "store.sql": "postgres:18-alpine",
48
+ });
49
+ });
50
+
51
+ test("journal postgres alone pulls store.sql image", () => {
52
+ const images = defaultImagesFromConfig({
53
+ drivers: {
54
+ journal: { prod: "postgres" },
55
+ },
56
+ });
57
+ expect(images).toEqual({
58
+ "store.sql": "postgres:18-alpine",
59
+ });
60
+ });
61
+
40
62
  test("returns empty when no container drivers", () => {
41
63
  expect(defaultImagesFromConfig({})).toEqual({});
42
64
  expect(
@@ -48,13 +48,17 @@ export function defaultImagesFromConfig(config: OkeConfig): Readonly<Record<stri
48
48
  const kv = dockerOrProdId(config.drivers?.store?.kv);
49
49
  const signal = dockerOrProdId(config.drivers?.signal);
50
50
  const clock = dockerOrProdId(config.drivers?.clock);
51
+ const journal = dockerOrProdId(config.drivers?.journal);
51
52
 
53
+ // SQL-consuming facets pull a SQL image. Signal postgres still needs a
54
+ // LISTEN/NOTIFY client (not Bun.SQL); clock + journal postgres are real
55
+ // SKIP LOCKED stores (CronStore / durable-run journal).
52
56
  const needsSql =
53
57
  sql === "postgres" ||
54
58
  sql === "pgvector" ||
55
59
  index === "pgvector" ||
56
- signal === "postgres" ||
57
60
  clock === "postgres" ||
61
+ journal === "postgres" ||
58
62
  (config.drivers?.prod ?? []).some((p) => p === "postgres" || p === "pgvector");
59
63
 
60
64
  if (needsSql) {
@@ -62,7 +66,8 @@ export function defaultImagesFromConfig(config: OkeConfig): Readonly<Record<stri
62
66
  sql === "pgvector" || index === "pgvector" ? DEFAULT_PGVECTOR_IMAGE : DEFAULT_SQL_IMAGE;
63
67
  }
64
68
 
65
- const needsKv = kv === "redis" || (config.drivers?.prod ?? []).includes("redis");
69
+ const needsKv =
70
+ kv === "redis" || signal === "redis" || (config.drivers?.prod ?? []).includes("redis");
66
71
  if (needsKv) {
67
72
  out["store.kv"] = DEFAULT_KV_IMAGE;
68
73
  }
@@ -145,5 +145,6 @@ describe("openbao bootstrap material gates", () => {
145
145
  }
146
146
  expect(failed).toBeInstanceOf(OpenBaoBootstrapError);
147
147
  expect(String(failed)).toContain("unrecoverable");
148
+ expect(String(failed)).toContain("compose.vault.yml down -v");
148
149
  });
149
150
  });
@@ -221,8 +221,15 @@ export async function ensureOpenBao(
221
221
  if (!existsSync(unsealPath) || !existsSync(rootPath)) {
222
222
  throw new OpenBaoBootstrapError(
223
223
  `openbao bootstrap: OpenBao is initialized but ${stateDir} is missing ` +
224
- `unseal.key/root.token. Without the unseal key the data is unrecoverable. ` +
225
- `Restore your backup of .oke/openbao/ — permanent loss otherwise.`,
224
+ `unseal.key/root.token. Without the unseal key the vault data is unrecoverable.\n` +
225
+ `\n` +
226
+ `Usual cause in docker mode: a previous vault volume survived after the project\n` +
227
+ `(or .oke/openbao/) was deleted — Compose stop keeps volumes on purpose.\n` +
228
+ `\n` +
229
+ `Restore a backup of .oke/openbao/ if you have one.\n` +
230
+ `\n` +
231
+ `Dev reset (wipes vault secrets only, then re-init on next oke dev):\n` +
232
+ ` cd docker && docker compose -f compose.yml -f compose.vault.yml down -v`,
226
233
  );
227
234
  }
228
235
  unsealKey = readFileSync(unsealPath, "utf8").trim();
@@ -202,7 +202,7 @@ export const OKE_COMMANDS: readonly CliCommand[] = [
202
202
  },
203
203
  {
204
204
  name: "db",
205
- summary: "domain schema — push · generate · migrate",
205
+ summary: "domain schema — push · generate · migrate · seed",
206
206
  subcommands: [
207
207
  {
208
208
  name: "push",
@@ -215,6 +215,12 @@ export const OKE_COMMANDS: readonly CliCommand[] = [
215
215
  valueName: "path",
216
216
  summary: "drizzle.config.ts path",
217
217
  },
218
+ {
219
+ long: "--env",
220
+ takesValue: true,
221
+ valueName: "name",
222
+ summary: "Config env (local|docker|test|prod)",
223
+ },
218
224
  HELP,
219
225
  ],
220
226
  },
@@ -229,6 +235,12 @@ export const OKE_COMMANDS: readonly CliCommand[] = [
229
235
  valueName: "path",
230
236
  summary: "drizzle.config.ts path",
231
237
  },
238
+ {
239
+ long: "--env",
240
+ takesValue: true,
241
+ valueName: "name",
242
+ summary: "Config env (local|docker|test|prod)",
243
+ },
232
244
  HELP,
233
245
  ],
234
246
  },
@@ -243,6 +255,30 @@ export const OKE_COMMANDS: readonly CliCommand[] = [
243
255
  valueName: "path",
244
256
  summary: "drizzle.config.ts path",
245
257
  },
258
+ {
259
+ long: "--env",
260
+ takesValue: true,
261
+ valueName: "name",
262
+ summary: "Config env (local|docker|test|prod)",
263
+ },
264
+ HELP,
265
+ ],
266
+ },
267
+ {
268
+ name: "seed",
269
+ summary: "Run defineSeed (essential + env category); never at boot",
270
+ flags: [
271
+ {
272
+ long: "--env",
273
+ takesValue: true,
274
+ valueName: "name",
275
+ summary: "Config env (local|docker|test|prod)",
276
+ },
277
+ {
278
+ long: "--force",
279
+ summary: "Skip docker/prod confirmation prompt",
280
+ },
281
+ ENTRY,
246
282
  HELP,
247
283
  ],
248
284
  },
@@ -287,7 +323,7 @@ export const OKE_COMMANDS: readonly CliCommand[] = [
287
323
  },
288
324
  {
289
325
  name: "docker",
290
- summary: "Dockerfile + compose.<role>.yml",
326
+ summary: "derive compose · clean leftover stacks",
291
327
  leaf: true,
292
328
  flags: [
293
329
  {
@@ -300,6 +336,24 @@ export const OKE_COMMANDS: readonly CliCommand[] = [
300
336
  MANIFEST,
301
337
  HELP,
302
338
  ],
339
+ subcommands: [
340
+ {
341
+ name: "clean",
342
+ summary: "Remove oke-dev stacks (containers, networks, volumes)",
343
+ flags: [
344
+ {
345
+ long: "--yes",
346
+ short: "-y",
347
+ summary: "Non-interactive confirm",
348
+ },
349
+ {
350
+ long: "--all",
351
+ summary: "Every oke-dev-* stack (required with --yes outside a project)",
352
+ },
353
+ HELP,
354
+ ],
355
+ },
356
+ ],
303
357
  },
304
358
  {
305
359
  name: "images",
@@ -346,6 +400,56 @@ export const OKE_COMMANDS: readonly CliCommand[] = [
346
400
  leaf: true,
347
401
  flags: [MANIFEST, HELP],
348
402
  },
403
+ {
404
+ name: "ai",
405
+ summary: "configure AI driver + models",
406
+ subcommands: [
407
+ {
408
+ name: "setup",
409
+ summary: "Interactive / flagged AI provider + model setup",
410
+ flags: [
411
+ {
412
+ long: "--provider",
413
+ takesValue: true,
414
+ valueName: "id",
415
+ summary: "ollama · openai · anthropic · gemini · lmstudio · openrouter · custom",
416
+ },
417
+ {
418
+ long: "--chat",
419
+ takesValue: true,
420
+ valueName: "model",
421
+ summary: "Chat model id",
422
+ },
423
+ {
424
+ long: "--vision",
425
+ takesValue: true,
426
+ valueName: "model",
427
+ summary: "Vision model id",
428
+ },
429
+ {
430
+ long: "--embed",
431
+ takesValue: true,
432
+ valueName: "model",
433
+ summary: "Embedding model id",
434
+ },
435
+ {
436
+ long: "--pull",
437
+ summary: "Pull missing Ollama models (default)",
438
+ },
439
+ {
440
+ long: "--no-pull",
441
+ summary: "Skip ollama pull",
442
+ },
443
+ {
444
+ long: "--yes",
445
+ short: "-y",
446
+ summary: "Non-interactive (requires --provider)",
447
+ },
448
+ HELP,
449
+ ],
450
+ },
451
+ ],
452
+ },
349
453
  {
350
454
  name: "branch",
351
455
  summary: "fork journaled state",
@@ -459,6 +563,16 @@ export function flagTokensFor(command: CliCommand, sub?: string): readonly strin
459
563
  }
460
564
 
461
565
  function formatCommandUsage(cmd: CliCommand): string {
566
+ // Leaf + subcommands (e.g. `oke docker` derive · `oke docker clean`).
567
+ if (cmd.leaf && cmd.subcommands && cmd.subcommands.length > 0) {
568
+ const flagHint = cmd.flags
569
+ ?.filter((f) => f.long !== "--help")
570
+ .slice(0, 2)
571
+ .map((f) => formatFlagHint(f))
572
+ .join(" ");
573
+ const subs = cmd.subcommands.map((s) => s.name).join("|");
574
+ return `oke ${cmd.name}${flagHint ? ` ${flagHint}` : ""} · ${subs}`;
575
+ }
462
576
  if (cmd.subcommands?.length === 1) {
463
577
  const sub = cmd.subcommands[0]!;
464
578
  const flagHint = sub.flags
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Evidence: createClient has no live-signal subscription today.
3
+ *
4
+ * `delivery: "live"` is consumed server-side via `bus.live()` (and
5
+ * `on(signal, flow)` for flow subscribers). `okengine/client` exposes only
6
+ * HTTP REST / RPC — no SSE, WebSocket, or `api.live(…)`.
7
+ *
8
+ * Design for `client.live(signalName)` is tracked with the Signal docs page;
9
+ * do not treat this file as a stub to fill — ship the real surface when ready.
10
+ */
11
+
12
+ import { describe, expect, test } from "bun:test";
13
+
14
+ import * as client from "./index.ts";
15
+ import { createClient } from "./create.ts";
16
+
17
+ describe("createClient — live signal gap", () => {
18
+ test("public client module exports no live / subscribe helpers", () => {
19
+ const keys = Object.keys(client).sort();
20
+ expect(keys).not.toContain("live");
21
+ expect(keys).not.toContain("subscribe");
22
+ expect(keys).toContain("createClient");
23
+ expect(keys).toContain("createTransport");
24
+ });
25
+
26
+ test("createClient builds an HTTP call proxy only (no live method on the root)", async () => {
27
+ const api = createClient("http://app.test", {
28
+ fetch: async () => Response.json({ data: null, error: { code: "NotFound", data: {} } }),
29
+ });
30
+ // Root is a callable proxy for unit.flow — live is not a first-class API.
31
+ expect(typeof api).toBe("function");
32
+ expect(Object.prototype.hasOwnProperty.call(api, "live")).toBe(false);
33
+ expect("live" in (api as object)).toBe(false);
34
+ });
35
+ });
@@ -47,6 +47,27 @@ describe("transport — retry", () => {
47
47
  expect(error?.code).toBe("TransportError");
48
48
  expect(n).toBe(2);
49
49
  });
50
+
51
+ test("structured 5xx envelope is returned (not TransportError)", async () => {
52
+ const api = createClient<PingApp>("http://app.test", {
53
+ fetch: async () =>
54
+ Response.json(
55
+ {
56
+ data: null,
57
+ error: {
58
+ code: "InternalError",
59
+ data: {},
60
+ message: "password policy failed: minLength 12",
61
+ },
62
+ },
63
+ { status: 500 },
64
+ ),
65
+ });
66
+
67
+ const { error } = await api.sys.ping();
68
+ expect(error?.code).toBe("InternalError");
69
+ expect(error?.message).toMatch(/password policy/i);
70
+ });
50
71
  });
51
72
 
52
73
  describe("transport — timeout", () => {
@@ -44,6 +44,11 @@ export function createTransport(base: string, opts: ClientOptions = {}): Transpo
44
44
  await opts.auth.refresh();
45
45
  continue;
46
46
  }
47
+ if (res.status >= 500) {
48
+ const structured = await decodeIfEnvelope(res);
49
+ if (structured) return structured;
50
+ throw new Error(`HTTP ${res.status}`);
51
+ }
47
52
  return decode(res);
48
53
  } catch (err) {
49
54
  const transient = isTransient(err);
@@ -110,21 +115,38 @@ async function once(
110
115
  ctrl && opts.timeout !== undefined ? setTimeout(() => ctrl.abort(), opts.timeout) : undefined;
111
116
 
112
117
  try {
113
- const res = await fetchFn(url, {
118
+ return await fetchFn(url, {
114
119
  method,
115
120
  headers,
116
121
  body,
117
122
  signal: ctrl?.signal,
118
123
  });
119
- if (res.status >= 500) {
120
- throw new Error(`HTTP ${res.status}`);
121
- }
122
- return res;
123
124
  } finally {
124
125
  if (timer !== undefined) clearTimeout(timer);
125
126
  }
126
127
  }
127
128
 
129
+ /**
130
+ * Decode a JSON `{ data, error }` envelope from a 5xx response, or `null`
131
+ * when the body is not a structured OKE failure (retry as transport error).
132
+ *
133
+ * @param res - HTTP response (body consumed)
134
+ */
135
+ async function decodeIfEnvelope(res: Response): Promise<ClientResult | null> {
136
+ const text = await res.text();
137
+ if (!text) return null;
138
+ let json: unknown;
139
+ try {
140
+ json = JSON.parse(text);
141
+ } catch {
142
+ return null;
143
+ }
144
+ if (json !== null && typeof json === "object" && "data" in json && "error" in json) {
145
+ return json as ClientResult;
146
+ }
147
+ return null;
148
+ }
149
+
128
150
  function rpcRequest(
129
151
  base: string,
130
152
  key: string,
@@ -114,6 +114,7 @@ const TABLE_ARG_METHODS = new Set([
114
114
  "findById",
115
115
  "exists",
116
116
  "update",
117
+ "upsert",
117
118
  "increment",
118
119
  "delete",
119
120
  ]);
@@ -9,7 +9,7 @@ export default defineConfig({
9
9
  files: { prod: "s3" },
10
10
  index: { prod: "pgvector" },
11
11
  },
12
- signal: { prod: "postgres" },
12
+ signal: { prod: "redis" },
13
13
  vault: { prod: "openbao" },
14
14
  channel: { email: { prod: "smtp" } },
15
15
  ai: { prod: { driver: "anthropic" } },
@@ -26,7 +26,7 @@ describe("fillDockerFromProd / defineConfig", () => {
26
26
  });
27
27
 
28
28
  test("vault prod → docker copies the prod driver", () => {
29
- expect(fillDockerFromProd({ local: "dotenv", prod: "openbao" })?.docker).toBe("openbao");
29
+ expect(fillDockerFromProd({ local: "env", prod: "openbao" })?.docker).toBe("openbao");
30
30
  });
31
31
 
32
32
  test("defineConfig fills every element from prod", () => {
@@ -37,9 +37,9 @@ describe("fillDockerFromProd / defineConfig", () => {
37
37
  kv: { local: "memory", prod: "redis" },
38
38
  files: { local: "fs", prod: "s3" },
39
39
  },
40
- signal: { local: "memory", prod: "postgres" },
41
- clock: { local: "memory", prod: "postgres" },
42
- vault: { local: "dotenv", prod: "openbao" },
40
+ signal: { local: "memory", prod: "redis" },
41
+ clock: { local: "memory", prod: "file" },
42
+ vault: { local: "env", prod: "openbao" },
43
43
  channel: {
44
44
  email: { local: "console", prod: "smtp" },
45
45
  },
@@ -49,8 +49,8 @@ describe("fillDockerFromProd / defineConfig", () => {
49
49
  expect(cfg.drivers?.store?.sql?.docker).toBe("postgres");
50
50
  expect(cfg.drivers?.store?.kv?.docker).toBe("redis");
51
51
  expect(cfg.drivers?.store?.files?.docker).toBe("s3");
52
- expect(cfg.drivers?.signal?.docker).toBe("postgres");
53
- expect(cfg.drivers?.clock?.docker).toBe("postgres");
52
+ expect(cfg.drivers?.signal?.docker).toBe("redis");
53
+ expect(cfg.drivers?.clock?.docker).toBe("file");
54
54
  expect(cfg.drivers?.vault?.docker).toBe("openbao");
55
55
  expect(cfg.drivers?.channel?.email?.docker).toBe("smtp");
56
56
  expect(cfg.drivers?.ai?.docker).toBe("anthropic");
@@ -73,6 +73,8 @@ export interface DriversConfig {
73
73
  readonly store?: StoreDriversConfig;
74
74
  readonly signal?: EnvDriverMap;
75
75
  readonly clock?: EnvDriverMap;
76
+ /** Durable-run journal: `memory` · `file` · `postgres`. */
77
+ readonly journal?: EnvDriverMap;
76
78
  readonly vault?: EnvDriverMap;
77
79
  readonly channel?: ChannelDriversConfig;
78
80
  readonly ai?: EnvDriverMap;
@@ -283,6 +285,7 @@ export function normalizeDriversConfig(
283
285
  ...(store !== undefined ? { store } : {}),
284
286
  signal: normalizeEnvDriverMap(drivers.signal as RawEnvDriverMap),
285
287
  clock: normalizeEnvDriverMap(drivers.clock as RawEnvDriverMap),
288
+ journal: normalizeEnvDriverMap(drivers.journal as RawEnvDriverMap),
286
289
  vault: normalizeEnvDriverMap(drivers.vault as RawEnvDriverMap),
287
290
  ...(channel !== undefined ? { channel } : {}),
288
291
  ai: normalizeEnvDriverMap(drivers.ai as RawEnvDriverMap),
@@ -333,6 +336,7 @@ export function fillDriversDockerFromProd(
333
336
  ...(store !== undefined ? { store } : {}),
334
337
  signal: fillDockerFromProd(drivers.signal),
335
338
  clock: fillDockerFromProd(drivers.clock),
339
+ journal: fillDockerFromProd(drivers.journal),
336
340
  vault: fillDockerFromProd(drivers.vault),
337
341
  ...(channel !== undefined ? { channel } : {}),
338
342
  ai: fillDockerFromProd(drivers.ai),
@@ -344,7 +348,7 @@ export function fillDriversDockerFromProd(
344
348
  * Define an `oke.config.ts` document.
345
349
  *
346
350
  * Missing `docker` driver pins are filled from `prod` so `oke dev -d` uses the
347
- * same server protocols as production (vault → dotenv for `docker/.env.docker`).
351
+ * same server protocols as production (vault → `env` for `docker/.env.docker`).
348
352
  * Legacy `dev`/`stack` keys are normalized to `local`/`docker`.
349
353
  *
350
354
  * @param config - Driver / tenancy / i18n / topology
@@ -53,6 +53,51 @@ describe("console kernel", () => {
53
53
  }
54
54
  });
55
55
 
56
+ test("claim rejects weak passwords with ClaimFailed password_policy (not 500)", async () => {
57
+ const handle = await startConsoleApp({
58
+ cwd,
59
+ secret: "test-secret-console",
60
+ silentClaim: true,
61
+ });
62
+ try {
63
+ const weak = await handle.app.fetch(
64
+ new Request("http://console.test/console/setup/claim", {
65
+ method: "POST",
66
+ headers: { "content-type": "application/json" },
67
+ body: JSON.stringify({
68
+ claimCode: handle.state.claim.code,
69
+ email: "ops@example.com",
70
+ name: "Ops",
71
+ password: "short1A",
72
+ }),
73
+ }),
74
+ );
75
+ // Zod min(12) rejects before createOperator — still not an opaque 500.
76
+ expect(weak.status).toBe(422);
77
+ const shortOkZod = await handle.app.fetch(
78
+ new Request("http://console.test/console/setup/claim", {
79
+ method: "POST",
80
+ headers: { "content-type": "application/json" },
81
+ body: JSON.stringify({
82
+ claimCode: handle.state.claim.code,
83
+ email: "ops@example.com",
84
+ name: "Ops",
85
+ password: "longenoughword",
86
+ }),
87
+ }),
88
+ );
89
+ expect(shortOkZod.status).toBe(400);
90
+ const body = (await shortOkZod.json()) as {
91
+ error: { code: string; message?: string; data?: { reason?: string } };
92
+ };
93
+ expect(body.error.code).toBe("ClaimFailed");
94
+ expect(body.error.data?.reason).toBe("password_policy");
95
+ expect(body.error.message).toMatch(/12 characters/i);
96
+ } finally {
97
+ await handle.app.stop();
98
+ }
99
+ });
100
+
56
101
  test("claim creates first operator and closes the wizard permanently", async () => {
57
102
  const handle = await startConsoleApp({
58
103
  cwd,