okengine 0.6.0 → 0.7.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 (162) hide show
  1. package/AGENTS.md +2 -2
  2. package/README.md +98 -71
  3. package/package.json +4 -4
  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 +90 -20
  8. package/site/content/docs/elements/clock.mdx +56 -24
  9. package/site/content/docs/elements/flow.mdx +2 -0
  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 +464 -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 +49 -13
  16. package/site/content/docs/get-started/introduction.mdx +1 -1
  17. package/site/content/docs/reference/cli.md +30 -2
  18. package/site/content/docs/reference/configuration.mdx +18 -17
  19. package/site/content/docs/reference/environment-variables.mdx +44 -18
  20. package/site/content/docs/reference/errors.mdx +12 -11
  21. package/site/content/docs/reference/fx.mdx +15 -13
  22. package/site/content/docs/reference/security.md +3 -1
  23. package/src/cli/ai-setup/ai-setup.test.ts +144 -0
  24. package/src/cli/ai-setup/apply.ts +214 -0
  25. package/src/cli/ai-setup/catalog.ts +263 -0
  26. package/src/cli/ai-setup/detect-ollama.ts +166 -0
  27. package/src/cli/ai-setup/index.ts +228 -0
  28. package/src/cli/ai-setup/prompts.ts +649 -0
  29. package/src/cli/ai-setup/recommend.test.ts +100 -0
  30. package/src/cli/ai-setup/recommend.ts +203 -0
  31. package/src/cli/ai.ts +38 -0
  32. package/src/cli/db-auto-push.test.ts +6 -1
  33. package/src/cli/db-auto-push.ts +8 -3
  34. package/src/cli/dev-db-push.test.ts +59 -0
  35. package/src/cli/dev.test.ts +40 -0
  36. package/src/cli/dev.ts +55 -1
  37. package/src/cli/docker-clean.test.ts +179 -0
  38. package/src/cli/docker-clean.ts +406 -0
  39. package/src/cli/docker.ts +16 -4
  40. package/src/cli/ensure-drizzle-config.ts +1 -1
  41. package/src/cli/hero-meta.test.ts +6 -5
  42. package/src/cli/hero-meta.ts +7 -1
  43. package/src/cli/index.ts +5 -0
  44. package/src/cli/load-config.ts +4 -4
  45. package/src/cli/openbao-bootstrap.test.ts +1 -0
  46. package/src/cli/openbao-bootstrap.ts +9 -2
  47. package/src/cli/openbao-restart.integration.test.ts +106 -97
  48. package/src/cli/registry.ts +79 -1
  49. package/src/client/live-gap.test.ts +35 -0
  50. package/src/client/transport.test.ts +21 -0
  51. package/src/client/transport.ts +27 -5
  52. package/src/compiler/fixtures/skyport/oke.config.ts +1 -1
  53. package/src/config/define-config.test.ts +6 -6
  54. package/src/config/index.ts +1 -1
  55. package/src/console/server/console.test.ts +45 -0
  56. package/src/console/server/flows.ts +39 -9
  57. package/src/console/server/i18n.ts +41 -0
  58. package/src/console/server/vault.ts +12 -38
  59. package/src/console/ui/dist/assets/index-ClX0ZCe5.js +10 -0
  60. package/src/console/ui/dist/assets/{panel-access-BGv45snf.js → panel-access-ri1X9Otj.js} +1 -1
  61. package/src/console/ui/dist/assets/{panel-ai-B2S7LEii.js → panel-ai-DLHjRoJk.js} +1 -1
  62. package/src/console/ui/dist/assets/{panel-architecture-D7UJh91v.js → panel-architecture-DI3bQF3J.js} +1 -1
  63. package/src/console/ui/dist/assets/{panel-channels-9T3ybqRu.js → panel-channels-BDYWX29p.js} +1 -1
  64. package/src/console/ui/dist/assets/{panel-clock-Cb1UXGRQ.js → panel-clock-i-JpTjVB.js} +1 -1
  65. package/src/console/ui/dist/assets/{panel-diff-DmYbKWmN.js → panel-diff-CFuWxiXy.js} +1 -1
  66. package/src/console/ui/dist/assets/{panel-flows-PiHwT55z.js → panel-flows-B8TRhcoe.js} +1 -1
  67. package/src/console/ui/dist/assets/{panel-gates-BQGYXvjT.js → panel-gates-CfkBgco7.js} +1 -1
  68. package/src/console/ui/dist/assets/{panel-overview-BBnRO18l.js → panel-overview-DONDzBd2.js} +1 -1
  69. package/src/console/ui/dist/assets/{panel-plugins-D0PsmVw2.js → panel-plugins-DDJwTehL.js} +1 -1
  70. package/src/console/ui/dist/assets/{panel-runs-CWuRDe0r.js → panel-runs-D5zf-D9c.js} +1 -1
  71. package/src/console/ui/dist/assets/{panel-signals-Bbg4ewpP.js → panel-signals-Dsxdu_AR.js} +1 -1
  72. package/src/console/ui/dist/assets/{panel-store-CPCbsDRa.js → panel-store-hy7O8HOs.js} +1 -1
  73. package/src/console/ui/dist/assets/{panel-traces-DVAzuA_S.js → panel-traces-CBiAe5go.js} +1 -1
  74. package/src/console/ui/dist/assets/{panel-vault-D1_MvOmo.js → panel-vault-Za1GyJfM.js} +1 -1
  75. package/src/console/ui/dist/index.html +1 -1
  76. package/src/console/ui/shell/components/ui.tsx +5 -1
  77. package/src/console/ui/shell/setup/Wizard.tsx +26 -5
  78. package/src/docker/cleanup.test.ts +193 -0
  79. package/src/docker/cleanup.ts +355 -0
  80. package/src/docker/docker.test.ts +4 -0
  81. package/src/docker/dockerfile.integration.test.ts +126 -119
  82. package/src/docker/index.ts +19 -0
  83. package/src/docker/recipes/ollama.ts +10 -5
  84. package/src/docker/stack.integration.test.ts +118 -102
  85. package/src/drivers/ai-ollama-tools.integration.test.ts +8 -6
  86. package/src/drivers/ai-ollama.integration.test.ts +3 -19
  87. package/src/drivers/ai-ollama.ts +45 -2
  88. package/src/drivers/ai-providers.test.ts +31 -0
  89. package/src/drivers/channel-fcm.ts +49 -53
  90. package/src/drivers/channel-msegat.ts +61 -0
  91. package/src/drivers/channel-sently-map.ts +57 -0
  92. package/src/drivers/channel-sently.test.ts +99 -0
  93. package/src/drivers/channel-sndr.ts +28 -0
  94. package/src/drivers/channel-taqnyat.ts +57 -0
  95. package/src/drivers/channel-types.ts +79 -2
  96. package/src/drivers/channel-unifonic.ts +26 -43
  97. package/src/drivers/channel-wa-cloud.ts +33 -47
  98. package/src/drivers/channel-webpush.ts +39 -239
  99. package/src/drivers/index.ts +7 -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/costs.test.ts +2 -2
  107. package/src/elements/channel/costs.ts +14 -2
  108. package/src/elements/channel/mime.ts +11 -0
  109. package/src/elements/channel/runtime.ts +94 -0
  110. package/src/elements/channel/sndr-webhooks.test.ts +26 -0
  111. package/src/elements/channel.ts +10 -1
  112. package/src/elements/clock/chaos-child.ts +150 -0
  113. package/src/elements/clock/chaos.test.ts +204 -0
  114. package/src/elements/clock/reconcile.ts +110 -0
  115. package/src/elements/clock/runtime.ts +11 -0
  116. package/src/elements/clock.test.ts +164 -2
  117. package/src/elements/clock.ts +1 -0
  118. package/src/elements/gate/runtime.ts +6 -0
  119. package/src/elements/index.ts +9 -0
  120. package/src/elements/signal/chaos-child.ts +41 -4
  121. package/src/elements/signal/declare.ts +4 -1
  122. package/src/elements/signal/delivery-modes.test.ts +179 -0
  123. package/src/elements/signal/key-ordering.test.ts +306 -0
  124. package/src/elements/signal/lease-reclaim.test.ts +123 -0
  125. package/src/elements/signal/optional-emit.test.ts +76 -0
  126. package/src/elements/signal/order-lifecycle.test.ts +130 -0
  127. package/src/elements/signal/orphan-messages.test.ts +152 -0
  128. package/src/elements/signal/runtime.ts +11 -4
  129. package/src/elements/signal/schema-emit.test.ts +107 -0
  130. package/src/elements/signal.test.ts +89 -5
  131. package/src/elements/store/files-fx.ts +104 -0
  132. package/src/elements/store/files-image.test.ts +251 -0
  133. package/src/elements/store/files-image.ts +494 -0
  134. package/src/elements/store/runtime.ts +60 -0
  135. package/src/elements/store.ts +24 -0
  136. package/src/elements/vault/boot-chain.ts +150 -0
  137. package/src/elements/vault/runtime.ts +8 -0
  138. package/src/kernel/app.ts +3 -1
  139. package/src/kernel/boot-bind/channel.test.ts +68 -3
  140. package/src/kernel/boot-bind/channel.ts +93 -2
  141. package/src/kernel/boot-bind/clock.ts +52 -7
  142. package/src/kernel/boot-bind/gate.ts +71 -3
  143. package/src/kernel/boot-bind/honor-config.test.ts +247 -0
  144. package/src/kernel/boot-bind/signal.ts +64 -7
  145. package/src/kernel/boot-bind/vault.ts +27 -8
  146. package/src/kernel/boot.ts +10 -2
  147. package/src/kernel/errors.ts +8 -2
  148. package/src/kernel/fx.test.ts +13 -0
  149. package/src/kernel/fx.ts +27 -4
  150. package/src/plugins/auth-delivery.mailpit.integration.test.ts +10 -4
  151. package/src/release/exports.test.ts +26 -0
  152. package/src/release/exports.ts +64 -5
  153. package/src/release/index.ts +5 -0
  154. package/src/release/measure.exports.test.ts +13 -1
  155. package/src/release/measure.ts +76 -13
  156. package/src/release/official-plugins.ts +46 -0
  157. package/src/release/readme.test.ts +30 -2
  158. package/src/runtime/dev-request-log.test.ts +20 -1
  159. package/src/runtime/dev-request-log.ts +38 -0
  160. package/src/term.test.ts +15 -0
  161. package/src/term.ts +8 -3
  162. package/src/console/ui/dist/assets/index-CjxwRGVv.js +0 -10
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
  }
@@ -47,14 +47,13 @@ export function defaultImagesFromConfig(config: OkeConfig): Readonly<Record<stri
47
47
  const index = dockerOrProdId(config.drivers?.store?.index);
48
48
  const kv = dockerOrProdId(config.drivers?.store?.kv);
49
49
  const signal = dockerOrProdId(config.drivers?.signal);
50
- const clock = dockerOrProdId(config.drivers?.clock);
51
50
 
51
+ // Only real SQL-consuming facets pull a SQL image. Clock/signal no longer
52
+ // advertise a fictional postgres CronStore / LISTEN bus via compose.
52
53
  const needsSql =
53
54
  sql === "postgres" ||
54
55
  sql === "pgvector" ||
55
56
  index === "pgvector" ||
56
- signal === "postgres" ||
57
- clock === "postgres" ||
58
57
  (config.drivers?.prod ?? []).some((p) => p === "postgres" || p === "pgvector");
59
58
 
60
59
  if (needsSql) {
@@ -62,7 +61,8 @@ export function defaultImagesFromConfig(config: OkeConfig): Readonly<Record<stri
62
61
  sql === "pgvector" || index === "pgvector" ? DEFAULT_PGVECTOR_IMAGE : DEFAULT_SQL_IMAGE;
63
62
  }
64
63
 
65
- const needsKv = kv === "redis" || (config.drivers?.prod ?? []).includes("redis");
64
+ const needsKv =
65
+ kv === "redis" || signal === "redis" || (config.drivers?.prod ?? []).includes("redis");
66
66
  if (needsKv) {
67
67
  out["store.kv"] = DEFAULT_KV_IMAGE;
68
68
  }
@@ -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();
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Proves the acceptance criterion — set a secret, kill/restart the container
5
5
  * on the same Raft volume, re-run the bootstrap unseal, and read the secret
6
- * back. Skips when no Docker daemon is available.
6
+ * back. Opt-in via `OKE_TEST_DOCKER=1` plus a live Docker daemon.
7
7
  */
8
8
 
9
9
  import { describe, expect, test } from "bun:test";
@@ -13,10 +13,9 @@ import { join } from "node:path";
13
13
  import { deriveInfrastructure, writeDerivedFiles } from "../docker/index.ts";
14
14
  import { ensureOpenBao } from "./openbao-bootstrap.ts";
15
15
 
16
- async function dockerAvailable(): Promise<boolean> {
16
+ function dockerAvailable(): boolean {
17
17
  try {
18
- const proc = Bun.spawn(["docker", "info"], { stdout: "pipe", stderr: "pipe" });
19
- return (await proc.exited) === 0;
18
+ return Bun.spawnSync(["docker", "info"], { stdout: "pipe", stderr: "pipe" }).exitCode === 0;
20
19
  } catch {
21
20
  return false;
22
21
  }
@@ -31,106 +30,116 @@ async function compose(project: string, dir: string, args: readonly string[]): P
31
30
  return proc.exited;
32
31
  }
33
32
 
34
- describe("openbao restart persistence (real container)", () => {
35
- test("secret set before restart is readable after restart + unseal", async () => {
36
- if (!(await dockerAvailable())) {
37
- console.warn("skipping: docker daemon not available");
38
- return;
39
- }
40
-
41
- const dir = await mkdtemp(join(tmpdir(), "oke-openbao-it-"));
42
- const dockerDir = join(dir, "docker");
43
- const project = `oke-bao-${Date.now()}`;
44
- const composeFiles = ["compose.yml", "compose.vault.yml"];
45
- try {
46
- const derived = deriveInfrastructure({
47
- images: { vault: "openbao/openbao:2.6.1" },
48
- app: "baotest",
49
- includeApp: false,
50
- composeDir: "docker",
51
- });
52
- await writeDerivedFiles(derived, dockerDir, { writeStackEnv: true });
53
- const spec = derived.specs.find((s) => s.role === "vault")!;
54
- const url = `http://127.0.0.1:${spec.hostPort}`;
55
-
56
- const up = await compose(project, dockerDir, [
57
- ...composeFiles.flatMap((f) => ["-f", f]),
58
- "up",
59
- "-d",
60
- ]);
61
- expect(up).toBe(0);
33
+ const WANT = process.env.OKE_TEST_DOCKER === "1";
34
+ const DOCKER = WANT && dockerAvailable();
35
+ if (!DOCKER) {
36
+ console.log(
37
+ WANT
38
+ ? "skip: openbao restart e2e (docker daemon not available)"
39
+ : "skip: openbao restart e2e (OKE_TEST_DOCKER≠1)",
40
+ );
41
+ }
42
+ const live = DOCKER ? test : test.skip;
62
43
 
63
- const names = ["STRIPE_KEY"];
64
- let first;
44
+ describe("openbao restart persistence (real container)", () => {
45
+ live(
46
+ "secret set before restart is readable after restart + unseal",
47
+ async () => {
48
+ const dir = await mkdtemp(join(tmpdir(), "oke-openbao-it-"));
49
+ const dockerDir = join(dir, "docker");
50
+ const project = `oke-bao-${Date.now()}`;
51
+ const composeFiles = ["compose.yml", "compose.vault.yml"];
65
52
  try {
66
- first = await ensureOpenBao({ cwd: dir, url, names });
67
- } catch (err) {
68
- const logs = Bun.spawn(
69
- [
70
- "docker",
71
- "compose",
72
- "-p",
73
- project,
74
- ...composeFiles.flatMap((f) => ["-f", f]),
75
- "logs",
76
- "vault",
77
- ],
78
- { cwd: dockerDir, stdout: "pipe", stderr: "pipe" },
79
- );
80
- console.error(await new Response(logs.stdout).text());
81
- console.error(await new Response(logs.stderr).text());
82
- await logs.exited;
83
- throw err;
84
- }
85
- expect(first.appToken.length).toBeGreaterThan(0);
53
+ const derived = deriveInfrastructure({
54
+ images: { vault: "openbao/openbao:2.6.1" },
55
+ app: "baotest",
56
+ includeApp: false,
57
+ composeDir: "docker",
58
+ });
59
+ await writeDerivedFiles(derived, dockerDir, { writeStackEnv: true });
60
+ const spec = derived.specs.find((s) => s.role === "vault")!;
61
+ const url = `http://127.0.0.1:${spec.hostPort}`;
86
62
 
87
- const write = await fetch(`${url}/v1/secret/data/STRIPE_KEY`, {
88
- method: "POST",
89
- headers: { "X-Vault-Token": first.appToken, "content-type": "application/json" },
90
- body: JSON.stringify({ data: { value: "sk_restart_persistent" } }),
91
- });
92
- if (!write.ok) console.error("write failed", write.status, await write.text());
93
- expect(write.ok).toBe(true);
63
+ const up = await compose(project, dockerDir, [
64
+ ...composeFiles.flatMap((f) => ["-f", f]),
65
+ "up",
66
+ "-d",
67
+ ]);
68
+ expect(up).toBe(0);
94
69
 
95
- // Kill + start the same volume (a real restart, not a fresh stack).
96
- const restart = await compose(project, dockerDir, [
97
- ...composeFiles.flatMap((f) => ["-f", f]),
98
- "restart",
99
- ]);
100
- expect(restart).toBe(0);
101
- // Poll the API until the server is back (sealed or unsealed).
102
- let apiUp = false;
103
- for (let i = 0; i < 60; i++) {
70
+ const names = ["STRIPE_KEY"];
71
+ let first;
104
72
  try {
105
- const probe = await fetch(`${url}/v1/sys/seal-status`);
106
- if (probe.ok) {
107
- apiUp = true;
108
- break;
73
+ first = await ensureOpenBao({ cwd: dir, url, names });
74
+ } catch (err) {
75
+ const logs = Bun.spawn(
76
+ [
77
+ "docker",
78
+ "compose",
79
+ "-p",
80
+ project,
81
+ ...composeFiles.flatMap((f) => ["-f", f]),
82
+ "logs",
83
+ "vault",
84
+ ],
85
+ { cwd: dockerDir, stdout: "pipe", stderr: "pipe" },
86
+ );
87
+ console.error(await new Response(logs.stdout).text());
88
+ console.error(await new Response(logs.stderr).text());
89
+ await logs.exited;
90
+ throw err;
91
+ }
92
+ expect(first.appToken.length).toBeGreaterThan(0);
93
+
94
+ const write = await fetch(`${url}/v1/secret/data/STRIPE_KEY`, {
95
+ method: "POST",
96
+ headers: { "X-Vault-Token": first.appToken, "content-type": "application/json" },
97
+ body: JSON.stringify({ data: { value: "sk_restart_persistent" } }),
98
+ });
99
+ if (!write.ok) console.error("write failed", write.status, await write.text());
100
+ expect(write.ok).toBe(true);
101
+
102
+ // Kill + start the same volume (a real restart, not a fresh stack).
103
+ const restart = await compose(project, dockerDir, [
104
+ ...composeFiles.flatMap((f) => ["-f", f]),
105
+ "restart",
106
+ ]);
107
+ expect(restart).toBe(0);
108
+ // Poll the API until the server is back (sealed or unsealed).
109
+ let apiUp = false;
110
+ for (let i = 0; i < 60; i++) {
111
+ try {
112
+ const probe = await fetch(`${url}/v1/sys/seal-status`);
113
+ if (probe.ok) {
114
+ apiUp = true;
115
+ break;
116
+ }
117
+ } catch {
118
+ // not up yet
109
119
  }
110
- } catch {
111
- // not up yet
120
+ await Bun.sleep(500);
112
121
  }
113
- await Bun.sleep(500);
114
- }
115
- expect(apiUp).toBe(true);
122
+ expect(apiUp).toBe(true);
116
123
 
117
- const second = await ensureOpenBao({ cwd: dir, url, names });
118
- expect(second.initializedNow).toBe(false);
119
- expect(second.appToken).toBe(first.appToken);
124
+ const second = await ensureOpenBao({ cwd: dir, url, names });
125
+ expect(second.initializedNow).toBe(false);
126
+ expect(second.appToken).toBe(first.appToken);
120
127
 
121
- const read = await fetch(`${url}/v1/secret/data/STRIPE_KEY`, {
122
- headers: { "X-Vault-Token": second.appToken },
123
- });
124
- expect(read.ok).toBe(true);
125
- const body = (await read.json()) as { data?: { data?: { value?: string } } };
126
- expect(body.data?.data?.value).toBe("sk_restart_persistent");
127
- } finally {
128
- await compose(project, dockerDir, [
129
- ...composeFiles.flatMap((f) => ["-f", f]),
130
- "down",
131
- "-v",
132
- ]).catch(() => 0);
133
- await rm(dir, { recursive: true, force: true }).catch(() => {});
134
- }
135
- }, 180_000);
128
+ const read = await fetch(`${url}/v1/secret/data/STRIPE_KEY`, {
129
+ headers: { "X-Vault-Token": second.appToken },
130
+ });
131
+ expect(read.ok).toBe(true);
132
+ const body = (await read.json()) as { data?: { data?: { value?: string } } };
133
+ expect(body.data?.data?.value).toBe("sk_restart_persistent");
134
+ } finally {
135
+ await compose(project, dockerDir, [
136
+ ...composeFiles.flatMap((f) => ["-f", f]),
137
+ "down",
138
+ "-v",
139
+ ]).catch(() => 0);
140
+ await rm(dir, { recursive: true, force: true }).catch(() => {});
141
+ }
142
+ },
143
+ 180_000,
144
+ );
136
145
  });
@@ -287,7 +287,7 @@ export const OKE_COMMANDS: readonly CliCommand[] = [
287
287
  },
288
288
  {
289
289
  name: "docker",
290
- summary: "Dockerfile + compose.<role>.yml",
290
+ summary: "derive compose · clean leftover stacks",
291
291
  leaf: true,
292
292
  flags: [
293
293
  {
@@ -300,6 +300,24 @@ export const OKE_COMMANDS: readonly CliCommand[] = [
300
300
  MANIFEST,
301
301
  HELP,
302
302
  ],
303
+ subcommands: [
304
+ {
305
+ name: "clean",
306
+ summary: "Remove oke-dev stacks (containers, networks, volumes)",
307
+ flags: [
308
+ {
309
+ long: "--yes",
310
+ short: "-y",
311
+ summary: "Non-interactive confirm",
312
+ },
313
+ {
314
+ long: "--all",
315
+ summary: "Every oke-dev-* stack (required with --yes outside a project)",
316
+ },
317
+ HELP,
318
+ ],
319
+ },
320
+ ],
303
321
  },
304
322
  {
305
323
  name: "images",
@@ -346,6 +364,56 @@ export const OKE_COMMANDS: readonly CliCommand[] = [
346
364
  leaf: true,
347
365
  flags: [MANIFEST, HELP],
348
366
  },
367
+ {
368
+ name: "ai",
369
+ summary: "configure AI driver + models",
370
+ subcommands: [
371
+ {
372
+ name: "setup",
373
+ summary: "Interactive / flagged AI provider + model setup",
374
+ flags: [
375
+ {
376
+ long: "--provider",
377
+ takesValue: true,
378
+ valueName: "id",
379
+ summary: "ollama · openai · anthropic · gemini · lmstudio · openrouter · custom",
380
+ },
381
+ {
382
+ long: "--chat",
383
+ takesValue: true,
384
+ valueName: "model",
385
+ summary: "Chat model id",
386
+ },
387
+ {
388
+ long: "--vision",
389
+ takesValue: true,
390
+ valueName: "model",
391
+ summary: "Vision model id",
392
+ },
393
+ {
394
+ long: "--embed",
395
+ takesValue: true,
396
+ valueName: "model",
397
+ summary: "Embedding model id",
398
+ },
399
+ {
400
+ long: "--pull",
401
+ summary: "Pull missing Ollama models (default)",
402
+ },
403
+ {
404
+ long: "--no-pull",
405
+ summary: "Skip ollama pull",
406
+ },
407
+ {
408
+ long: "--yes",
409
+ short: "-y",
410
+ summary: "Non-interactive (requires --provider)",
411
+ },
412
+ HELP,
413
+ ],
414
+ },
415
+ ],
416
+ },
349
417
  {
350
418
  name: "branch",
351
419
  summary: "fork journaled state",
@@ -459,6 +527,16 @@ export function flagTokensFor(command: CliCommand, sub?: string): readonly strin
459
527
  }
460
528
 
461
529
  function formatCommandUsage(cmd: CliCommand): string {
530
+ // Leaf + subcommands (e.g. `oke docker` derive · `oke docker clean`).
531
+ if (cmd.leaf && cmd.subcommands && cmd.subcommands.length > 0) {
532
+ const flagHint = cmd.flags
533
+ ?.filter((f) => f.long !== "--help")
534
+ .slice(0, 2)
535
+ .map((f) => formatFlagHint(f))
536
+ .join(" ");
537
+ const subs = cmd.subcommands.map((s) => s.name).join("|");
538
+ return `oke ${cmd.name}${flagHint ? ` ${flagHint}` : ""} · ${subs}`;
539
+ }
462
540
  if (cmd.subcommands?.length === 1) {
463
541
  const sub = cmd.subcommands[0]!;
464
542
  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,
@@ -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");
@@ -344,7 +344,7 @@ export function fillDriversDockerFromProd(
344
344
  * Define an `oke.config.ts` document.
345
345
  *
346
346
  * 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`).
347
+ * same server protocols as production (vault → `env` for `docker/.env.docker`).
348
348
  * Legacy `dev`/`stack` keys are normalized to `local`/`docker`.
349
349
  *
350
350
  * @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,