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
@@ -13,14 +13,14 @@ OKE reads environment variables at boot for connection detail and secrets — ne
13
13
 
14
14
  ## SQL store
15
15
 
16
- | Variable | Used for | Default when unset |
17
- | ------------------- | ---------------------------------------------------- | ------------------------------- |
18
- | `DATABASE_URL` | Postgres connection (`postgres` driver, drizzle-kit) | `postgres://localhost:5432/oke` |
19
- | `OKE_STORE_SQL_URL` | Explicit SQL URL override | — |
20
- | `OKE_SQLITE_URL` | SQLite file path (`sqlite` driver) | `.oke/app.sqlite` |
21
- | `OKE_LIBSQL_URL` | libSQL URL or file path (`libsql` driver) | `.oke/app.libsql` |
22
- | `OKE_PGLITE_URL` | PGlite data dir or `memory://` (`pglite` driver) | `.oke/pgdata` |
23
- | `OKE_SQL_DRIVER` | Force the sql driver id at boot | config map |
16
+ | Variable | Used for | Default when unset |
17
+ | ------------------- | ----------------------------------------------------------------------------- | ------------------------------- |
18
+ | `DATABASE_URL` | Postgres connection (`postgres` store / clock / journal drivers, drizzle-kit) | `postgres://localhost:5432/oke` |
19
+ | `OKE_STORE_SQL_URL` | Explicit SQL URL override | — |
20
+ | `OKE_SQLITE_URL` | SQLite file path (`sqlite` driver) | `.oke/app.sqlite` |
21
+ | `OKE_LIBSQL_URL` | libSQL URL or file path (`libsql` driver) | `.oke/app.libsql` |
22
+ | `OKE_PGLITE_URL` | PGlite data dir or `memory://` (`pglite` driver) | `.oke/pgdata` |
23
+ | `OKE_SQL_DRIVER` | Force the sql driver id at boot | config map |
24
24
 
25
25
  ## Index store
26
26
 
@@ -74,6 +74,8 @@ Read when `drivers.channel.email` resolves to that driver id.
74
74
  | `RESEND_API_KEY` | `resend` API key |
75
75
  | `SNDR_API_KEY` | `sndr` API key |
76
76
  | `SNDR_BASE_URL` | Optional SNDR API origin (default `api.sndr.sh`) |
77
+ | `TAQNYAT_MAIL_TOKEN` | `taqnyat-mail` bearer token (Email-enabled) |
78
+ | `TAQNYAT_CAMPAIGN` | `taqnyat-mail` campaign name |
77
79
 
78
80
  ## Channel (SMS) — boot binder
79
81
 
@@ -94,18 +96,30 @@ Read when `drivers.channel.sms` resolves to that driver id (`console` opens noth
94
96
  WhatsApp (`wa-cloud`) and push (`webpush` / `fcm`) are not opened from env at
95
97
  boot — pass them on `BootOptions.channel.drivers` with their open options.
96
98
 
99
+ ## Live test gates (opt-in)
100
+
101
+ Provider-quota-burning live suites are double-gated: the medium flag **plus** that
102
+ provider's real credentials — credentials alone never send.
103
+
104
+ | Variable | Used for |
105
+ | ---------------- | --------------------------------------------------------- |
106
+ | `OKE_SMS_LIVE` | `=1` allows live SMS provider tests (e.g. Taqnyat OTP) |
107
+ | `OKE_EMAIL_LIVE` | `=1` allows live email provider tests (e.g. Taqnyat Mail) |
108
+
97
109
  ## AI providers
98
110
 
99
- | Variable | Used for | Default when unset |
100
- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- |
101
- | `ANTHROPIC_API_KEY` | `anthropic` driver credential | — |
102
- | `ANTHROPIC_MODEL` | Model override for the anthropic driver | — |
103
- | `OPENAI_API_KEY` | `openai-compatible` driver credential | — |
104
- | `OPENAI_BASE_URL` | `openai-compatible` base URL | OpenAI cloud |
105
- | `OKE_AI_DRIVER` | Force the AI driver id (honoured in docker mode) | config map |
106
- | `OKE_AI_URL` | Ollama (or openai-compatible) base URL | `http://127.0.0.1:11434` (ollama) |
107
- | `OKE_AI_MODEL` | Default model for `ollama` (any pulled model; `qwen3.5:9b` is a balanced local-dev starting point; on Apple Silicon try `qwen3.5:9b-mlx`) | `qwen3.5:9b` |
108
- | `OLLAMA_HOST` | Alternate Ollama host (`host:port` or URL) when `OKE_AI_URL` is unset | — |
111
+ | Variable | Used for | Default when unset |
112
+ | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- |
113
+ | `ANTHROPIC_API_KEY` | `anthropic` driver credential | — |
114
+ | `ANTHROPIC_MODEL` | Model override for the anthropic driver | — |
115
+ | `OPENAI_API_KEY` | `openai-compatible` driver credential | — |
116
+ | `OPENAI_BASE_URL` | `openai-compatible` base URL | OpenAI cloud |
117
+ | `OKE_AI_DRIVER` | Force the AI driver id (honoured in docker mode) | config map |
118
+ | `OKE_AI_URL` | Ollama (or openai-compatible) base URL | `http://127.0.0.1:11434` (ollama) |
119
+ | `OKE_AI_MODEL` | Default model for `ollama` (any pulled model; `qwen3.5:9b` is a balanced local-dev starting point; on Apple Silicon try `qwen3.5:9b-mlx`) | `qwen3.5:9b` |
120
+ | `OKE_AI_VISION_MODEL` | Vision model id written by `oke ai setup` (logical `ai.model("vision")`) | — |
121
+ | `OKE_AI_EMBED_MODEL` | Embedding model id written by `oke ai setup` | — |
122
+ | `OLLAMA_HOST` | Alternate Ollama host (`host:port` or URL) when `OKE_AI_URL` is unset | — |
109
123
 
110
124
  ## Framework behavior
111
125
 
@@ -29,17 +29,18 @@ string. Custom app codes stay message-less until registered.
29
29
 
30
30
  ## OKE numeric codes
31
31
 
32
- | Code | Name | Cause | Fix |
33
- | ------ | ----------------- | ------------------------------------------------ | ----------------------------------------------- |
34
- | `1001` | undeclared read | Flow reads a resource not in `effects.reads` | Add it to the flow's `effects.reads` |
35
- | `1002` | undeclared write | Flow writes a resource not in `effects.writes` | Add it to the flow's `effects.writes` |
36
- | `1003` | undeclared emit | Flow emits a signal not in `effects.emits` | Add it to the flow's `effects.emits` |
37
- | `1004` | undeclared send | Flow sends a template not in `effects.sends` | Add it to the flow's `effects.sends` |
38
- | `1005` | undeclared ask | Flow asks a prompt not in `effects.asks` | Add it to the flow's `effects.asks` |
39
- | `1006` | undeclared secret | Flow reads a secret not in `effects.secrets` | Add it to the flow's `effects.secrets` |
40
- | `1007` | undeclared call | Flow calls a flow not in `effects.calls` | Add it to the flow's `effects.calls` |
41
- | `1042` | orphan emit | Emitted signal has no subscriber | Add `on(signal, …)` or declare `optional: true` |
42
- | `1101` | schema missing | Domain table absent in docker/prod no auto-DDL | Run `oke db migrate` against this environment |
32
+ | Code | Name | Cause | Fix |
33
+ | ------ | ----------------- | ------------------------------------------------ | -------------------------------------------------- |
34
+ | `1001` | undeclared read | Flow reads a resource not in `effects.reads` | Add it to the flow's `effects.reads` |
35
+ | `1002` | undeclared write | Flow writes a resource not in `effects.writes` | Add it to the flow's `effects.writes` |
36
+ | `1003` | undeclared emit | Flow emits a signal not in `effects.emits` | Add it to the flow's `effects.emits` |
37
+ | `1004` | undeclared send | Flow sends a template not in `effects.sends` | Add it to the flow's `effects.sends` |
38
+ | `1005` | undeclared ask | Flow asks a prompt not in `effects.asks` | Add it to the flow's `effects.asks` |
39
+ | `1006` | undeclared secret | Flow reads a secret not in `effects.secrets` | Add it to the flow's `effects.secrets` |
40
+ | `1007` | undeclared call | Flow calls a flow not in `effects.calls` | Add it to the flow's `effects.calls` |
41
+ | `1042` | orphan emit | Emit with zero subscribers and `optional` false | Add `on(signal, …)` or declare `optional: true` |
42
+ | `1043` | signal schema | Emit payload failed the signal's `schema` | Pass a payload that matches `schema`, or remove it |
43
+ | `1101` | schema missing | Domain table absent in docker/prod — no auto-DDL | Run `oke db migrate` against this environment |
43
44
 
44
45
  <Callout title="Effects are usually inferred">
45
46
  The 1001–1007 family exists for flows that declare effects explicitly. Most apps never write an
@@ -16,24 +16,26 @@ flow({
16
16
 
17
17
  ## Stores
18
18
 
19
- | Signature | Records | Returns |
20
- | ------------------------------------------------------------------- | ------------ | --------------------------------------------------------- |
21
- | `fx.store(sqlDecl).select().from(t)…` | `read` | rows (`where` · `orderBy` · `limit` · `offset` chainable) |
22
- | `fx.store(sqlDecl).insert(t).values(v)` | `write` | `Promise<void>` |
23
- | `fx.store(sqlDecl).update(t).set(v).where(…)` | `write` | `Promise<void>` |
24
- | `fx.store(sqlDecl).delete(t).where(…)` | `write` | `Promise<void>` |
25
- | `fx.store(sqlDecl).findById(t, id)` | `read` | row \| undefined |
26
- | `fx.store(kv).get / set(key, value, ttl?) / delete / list(prefix?)` | read / write | per op |
27
- | `fx.store(files).put / get / delete / list(prefix?)` | read / write | per op |
28
- | `fx.store(index).upsert / search(vector, topK?) / delete` | read / write | per op |
19
+ | Signature | Records | Returns |
20
+ | ------------------------------------------------------------------- | ------------ | ------------------------------------------------------------------------------------------- |
21
+ | `fx.store(sqlDecl).select().from(t)…` | `read` | rows (`where` · `orderBy` · `limit` · `offset` chainable) |
22
+ | `fx.store(sqlDecl).insert(t).values(v)` | `write` | `Promise<void>` |
23
+ | `fx.store(sqlDecl).update(t).set(v).where(…)` | `write` | `Promise<void>` |
24
+ | `fx.store(sqlDecl).delete(t).where(…)` | `write` | `Promise<void>` |
25
+ | `fx.store(sqlDecl).findById(t, id)` | `read` | row \| undefined |
26
+ | `fx.store(kv).get / set(key, value, ttl?) / delete / list(prefix?)` | read / write | per op |
27
+ | `fx.store(files).put / get / delete / list(prefix?)` | read / write | per op |
28
+ | `fx.store(files).image(key\|bytes).…` | read / write | Bun.Image chain; terminals gate (see [Store](/docs/elements/store#images--image--putimage)) |
29
+ | `fx.store(files).putImage(key, data, opts?)` | `write` | original + variants (+ optional LQIP) |
30
+ | `fx.store(index).upsert / search(vector, topK?) / delete` | read / write | per op |
29
31
 
30
32
  See [Store](/docs/elements/store) for the query-builder surface.
31
33
 
32
34
  ## Signals
33
35
 
34
- | Signature | Records | Notes |
35
- | --------------------------- | ------- | ------------------------------------------------ |
36
- | `fx.emit(signal, payload?)` | `emit` | With the postgres driver, joins your transaction |
36
+ | Signature | Records | Notes |
37
+ | ------------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
38
+ | `fx.emit(signal, payload?, { key? })` | `emit` | Commits the signal outbox when the call resolves; optional `key` serializes `once` per key; throws **OKE1042** (orphan) or **OKE1043** (schema) |
37
39
 
38
40
  ## Flows
39
41
 
@@ -45,7 +45,9 @@ Our path is concrete: a booking name containing "ignore previous instructions an
45
45
  - **Plugin panels** run in a sandboxed iframe without `allow-same-origin`, communicating only over a `postMessage` bridge with their own CSP, no access to the operator session token, and exposure limited to that plugin's declared flows.
46
46
  - **Session and framing:** `frame-ancestors 'none'`, `SameSite=Strict`, step-up authentication before destructive actions.
47
47
  - **Secret write path:** TLS required, no autocomplete, never echoed, never logged, not retained in browser memory after submission.
48
- - **The setup claim code** is rate-limited and compared in constant time.
48
+ - **The setup claim code** is rate-limited and compared in constant time. The first-operator password
49
+ uses the same policy as Gate auth (minLength 12, letter + number); weak passwords return
50
+ `ClaimFailed` / `password_policy`, not an opaque 500.
49
51
 
50
52
  #### 10.5 Reversibility governs the confirmation pattern
51
53
 
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Unit tests for `oke ai setup` catalog, detect, and apply.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { applyAiSetup, renderAiTs, upsertAiDrivers, upsertEnv } from "./apply.ts";
10
+ import { cloudChatModels, recommendChatModel, recommendCloudChat } from "./catalog.ts";
11
+ import { isInstalled, parseOllamaList } from "./detect-ollama.ts";
12
+ import { parseAiSetupArgs } from "./index.ts";
13
+
14
+ describe("catalog", () => {
15
+ test("recommendChatModel respects RAM tiers", () => {
16
+ expect(recommendChatModel(8).id).toBe("gemma4:e4b");
17
+ expect(recommendChatModel(16).ramGb).toBeLessThanOrEqual(16);
18
+ expect(recommendChatModel(null).recommended).toBe(true);
19
+ });
20
+
21
+ test("cloud providers ship short curated chat lists + recommended", () => {
22
+ expect(cloudChatModels("openai").length).toBeGreaterThan(0);
23
+ expect(cloudChatModels("anthropic").some((m) => m.recommended)).toBe(true);
24
+ expect(recommendCloudChat("openai")).toBe("gpt-4o-mini");
25
+ expect(recommendCloudChat("anthropic")).toContain("claude");
26
+ });
27
+ });
28
+
29
+ describe("detect-ollama", () => {
30
+ test("parseOllamaList skips header", () => {
31
+ const out = parseOllamaList(`NAME ID SIZE
32
+ qwen3.5:9b abc 5.0 GB
33
+ nomic-embed-text def 274 MB
34
+ `);
35
+ expect(out).toEqual(["qwen3.5:9b", "nomic-embed-text"]);
36
+ });
37
+
38
+ test("isInstalled matches mlx suffixes", () => {
39
+ expect(isInstalled("qwen3.5:9b", ["qwen3.5:9b-mlx"])).toBe(true);
40
+ expect(isInstalled("gemma4:e4b", ["qwen3.5:9b"])).toBe(false);
41
+ });
42
+ });
43
+
44
+ describe("apply", () => {
45
+ test("upsertAiDrivers inserts after channel (sibling, not nested)", () => {
46
+ const src = `export default defineConfig({
47
+ drivers: {
48
+ channel: {
49
+ email: {
50
+ local: "console",
51
+ docker: "smtp",
52
+ test: "console",
53
+ prod: "smtp",
54
+ },
55
+ },
56
+ },
57
+ images: {},
58
+ });
59
+ `;
60
+ const next = upsertAiDrivers(src, "ollama");
61
+ expect(next).toMatch(/^ {4}ai:\s*\{/m);
62
+ expect(next).toContain('local: "ollama"');
63
+ const channel = next.match(/^ {4}channel:\s*\{[\s\S]*?\n {4}\},?\n/m)?.[0] ?? "";
64
+ expect(channel).toContain("email:");
65
+ expect(channel).not.toContain("ai:");
66
+ });
67
+
68
+ test("upsertEnv uncomment / set", () => {
69
+ const env = `# OKE_AI_DRIVER=mock\nOKE_AI_URL=http://x\n`;
70
+ expect(upsertEnv(env, "OKE_AI_DRIVER", "ollama")).toContain("OKE_AI_DRIVER=ollama");
71
+ });
72
+
73
+ test("renderAiTs includes vision + embed", () => {
74
+ const ts = renderAiTs({
75
+ driver: "ollama",
76
+ chatModel: "gemma4:e4b",
77
+ visionModel: "qwen3-vl:4b",
78
+ embedModel: "nomic-embed-text",
79
+ });
80
+ expect(ts).toContain('ai.model("smart"');
81
+ expect(ts).toContain('ai.model("vision"');
82
+ expect(ts).toContain("docsEmbed");
83
+ });
84
+
85
+ test("applyAiSetup writes config, env, ai.ts", () => {
86
+ const dir = mkdtempSync(join(tmpdir(), "oke-ai-setup-"));
87
+ try {
88
+ writeFileSync(
89
+ join(dir, "oke.config.ts"),
90
+ `import { defineConfig } from "okengine/config";
91
+ export default defineConfig({
92
+ drivers: {
93
+ channel: {
94
+ email: {
95
+ local: "console",
96
+ docker: "smtp",
97
+ test: "console",
98
+ prod: "smtp",
99
+ },
100
+ },
101
+ },
102
+ images: {
103
+ "store.sql": "postgres:18-alpine",
104
+ },
105
+ });
106
+ `,
107
+ "utf8",
108
+ );
109
+ mkdirSync(join(dir, "src"), { recursive: true });
110
+ writeFileSync(join(dir, "src", "app.ts"), `export const app = {};\n`, "utf8");
111
+
112
+ applyAiSetup(dir, {
113
+ driver: "ollama",
114
+ baseUrl: "http://127.0.0.1:11434",
115
+ chatModel: "gemma4:e4b",
116
+ visionModel: "qwen3-vl:4b",
117
+ embedModel: "nomic-embed-text",
118
+ });
119
+
120
+ const config = readFileSync(join(dir, "oke.config.ts"), "utf8");
121
+ expect(config).toContain('local: "ollama"');
122
+ expect(config).toContain('ai: "ollama/ollama:latest"');
123
+ const env = readFileSync(join(dir, ".env.local"), "utf8");
124
+ expect(env).toContain("OKE_AI_DRIVER=ollama");
125
+ expect(env).toContain("OKE_AI_MODEL=gemma4:e4b");
126
+ const aiTs = readFileSync(join(dir, "src", "ai.ts"), "utf8");
127
+ expect(aiTs).toContain("smart");
128
+ const app = readFileSync(join(dir, "src", "app.ts"), "utf8");
129
+ expect(app).toContain('import "./ai"');
130
+ } finally {
131
+ rmSync(dir, { recursive: true, force: true });
132
+ }
133
+ });
134
+ });
135
+
136
+ describe("parseAiSetupArgs", () => {
137
+ test("parses provider and models", () => {
138
+ const a = parseAiSetupArgs(["--provider", "ollama", "--chat=qwen3.5:9b", "--no-pull", "--yes"]);
139
+ expect(a.provider).toBe("ollama");
140
+ expect(a.chat).toBe("qwen3.5:9b");
141
+ expect(a.pull).toBe(false);
142
+ expect(a.yes).toBe(true);
143
+ });
144
+ });
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Write AI driver config, env, and `src/ai.ts` for `oke ai setup`.
3
+ */
4
+
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { dirname, join } from "node:path";
7
+
8
+ /** Choices applied to the project. */
9
+ export type AiSetupApplyInput = {
10
+ readonly driver: "ollama" | "anthropic" | "openai-compatible" | "mock";
11
+ readonly baseUrl?: string;
12
+ readonly chatModel?: string;
13
+ readonly visionModel?: string | null;
14
+ readonly embedModel?: string | null;
15
+ readonly apiKeyEnv?: string;
16
+ };
17
+
18
+ /** Options for {@link applyAiSetup}. */
19
+ export type AiSetupApplyOptions = {
20
+ /**
21
+ * When false, leave `drivers.ai` / `images.ai` alone (create-oke already
22
+ * wrote per-env pins). Default true for standalone `oke ai setup`.
23
+ */
24
+ readonly updateDrivers?: boolean;
25
+ };
26
+
27
+ /**
28
+ * Apply AI setup to a project root.
29
+ *
30
+ * @param cwd - Project root
31
+ * @param input - Selected provider / models
32
+ * @param options - Pin-preserving knobs for create-oke
33
+ */
34
+ export function applyAiSetup(
35
+ cwd: string,
36
+ input: AiSetupApplyInput,
37
+ options: AiSetupApplyOptions = {},
38
+ ): {
39
+ readonly configPath: string;
40
+ readonly envPath: string;
41
+ readonly aiTsPath: string;
42
+ } {
43
+ const updateDrivers = options.updateDrivers !== false;
44
+ const configPath = join(cwd, "oke.config.ts");
45
+ if (!existsSync(configPath)) {
46
+ throw new Error("oke ai setup: no oke.config.ts in the current directory");
47
+ }
48
+
49
+ if (updateDrivers) {
50
+ let config = readFileSync(configPath, "utf8");
51
+ config = upsertAiDrivers(config, input.driver);
52
+ if (input.driver === "ollama") {
53
+ config = upsertImage(config, "ai", "ollama/ollama:latest");
54
+ }
55
+ writeFileSync(configPath, config, "utf8");
56
+ }
57
+
58
+ const envPath = join(cwd, ".env.local");
59
+ const envExample = join(cwd, ".env.example");
60
+ let env = existsSync(envPath)
61
+ ? readFileSync(envPath, "utf8")
62
+ : existsSync(envExample)
63
+ ? readFileSync(envExample, "utf8")
64
+ : "";
65
+ env = upsertEnv(env, "OKE_AI_DRIVER", input.driver);
66
+ if (input.baseUrl) env = upsertEnv(env, "OKE_AI_URL", input.baseUrl);
67
+ if (input.chatModel) env = upsertEnv(env, "OKE_AI_MODEL", input.chatModel);
68
+ if (input.visionModel) env = upsertEnv(env, "OKE_AI_VISION_MODEL", input.visionModel);
69
+ if (input.embedModel) env = upsertEnv(env, "OKE_AI_EMBED_MODEL", input.embedModel);
70
+ if (input.apiKeyEnv) {
71
+ // Ensure the key name is present (value left blank for the user)
72
+ if (!new RegExp(`^${input.apiKeyEnv}=`, "m").test(env)) {
73
+ env = `${env.trimEnd()}\n${input.apiKeyEnv}=\n`;
74
+ }
75
+ }
76
+ writeFileSync(envPath, env.endsWith("\n") ? env : `${env}\n`, "utf8");
77
+
78
+ const aiTsPath = join(cwd, "src", "ai.ts");
79
+ mkdirSync(dirname(aiTsPath), { recursive: true });
80
+ writeFileSync(aiTsPath, renderAiTs(input), "utf8");
81
+ ensureAiImported(cwd);
82
+
83
+ return { configPath, envPath, aiTsPath };
84
+ }
85
+
86
+ /**
87
+ * @param source - oke.config.ts
88
+ * @param driver - AI driver id
89
+ */
90
+ export function upsertAiDrivers(source: string, driver: string): string {
91
+ const block = `{
92
+ local: "${driver}",
93
+ docker: "${driver === "mock" ? "mock" : driver}",
94
+ test: "mock",
95
+ prod: "${driver === "mock" ? "mock" : driver}",
96
+ }`;
97
+ // Top-level drivers.ai only (4-space indent) — never nest under channel.email.
98
+ const aiRe = /^( {4})ai:\s*\{[\s\S]*?\n\1\}/m;
99
+ if (aiRe.test(source)) {
100
+ return source.replace(aiRe, `$1ai: ${block}`);
101
+ }
102
+ const channelRe = /^( {4})channel:\s*\{[\s\S]*?\n\1\},?\n/m;
103
+ if (channelRe.test(source)) {
104
+ return source.replace(channelRe, (match) => `${match} ai: ${block},\n`);
105
+ }
106
+ const driversClose = /(drivers:\s*\{[\s\S]*?)(\n\s*\},)/;
107
+ if (!driversClose.test(source)) {
108
+ throw new Error("oke ai setup: could not find drivers block in oke.config.ts");
109
+ }
110
+ return source.replace(driversClose, `$1\n ai: ${block},$2`);
111
+ }
112
+
113
+ /**
114
+ * @param source - Config source
115
+ * @param key - Image role
116
+ * @param image - Image ref
117
+ */
118
+ export function upsertImage(source: string, key: string, image: string): string {
119
+ const keyLit = key.includes(".") ? `"${key}"` : key;
120
+ const line = ` ${keyLit}: "${image}",`;
121
+ const imagesRe = /images:\s*\{([\s\S]*?)\n\s*\}/;
122
+ const m = imagesRe.exec(source);
123
+ if (!m) return source;
124
+ const body = m[1]!;
125
+ if (new RegExp(`${keyLit}\\s*:`).test(body) || new RegExp(`"${key}"\\s*:`).test(body)) {
126
+ return source.replace(
127
+ new RegExp(`(["']?${key.replace(".", "\\.")}["']?\\s*:\\s*)"[^"]*"`),
128
+ `$1"${image}"`,
129
+ );
130
+ }
131
+ return source.replace(imagesRe, `images: {${body}\n${line}\n }`);
132
+ }
133
+
134
+ /**
135
+ * @param env - dotenv text
136
+ * @param key - Variable name
137
+ * @param value - Value
138
+ */
139
+ export function upsertEnv(env: string, key: string, value: string): string {
140
+ const line = `${key}=${value}`;
141
+ const re = new RegExp(`^#?\\s*${key}=.*$`, "m");
142
+ if (re.test(env)) return env.replace(re, line);
143
+ const aiSection = /(# ── AI[^\n]*\n)/;
144
+ if (aiSection.test(env)) {
145
+ return env.replace(aiSection, `$1${line}\n`);
146
+ }
147
+ return `${env.trimEnd()}\n\n# ── AI ──────────────────────────────────────────────────────\n${line}\n`;
148
+ }
149
+
150
+ /**
151
+ * @param input - Setup choices
152
+ */
153
+ export function renderAiTs(input: AiSetupApplyInput): string {
154
+ const chat = input.chatModel ?? "default";
155
+ const provider =
156
+ input.driver === "ollama"
157
+ ? "ollama"
158
+ : input.driver === "anthropic"
159
+ ? "anthropic"
160
+ : input.driver === "mock"
161
+ ? "mock"
162
+ : "openai-compatible";
163
+
164
+ const lines = [
165
+ `import { ai } from "okengine";`,
166
+ ``,
167
+ `export const smart = ai.model("smart", {`,
168
+ ` provider: "${provider}",`,
169
+ ` model: process.env.OKE_AI_MODEL ?? "${chat}",`,
170
+ `});`,
171
+ ];
172
+
173
+ if (input.visionModel) {
174
+ lines.push(
175
+ ``,
176
+ `export const vision = ai.model("vision", {`,
177
+ ` provider: "${provider}",`,
178
+ ` model: process.env.OKE_AI_VISION_MODEL ?? "${input.visionModel}",`,
179
+ `});`,
180
+ );
181
+ }
182
+
183
+ if (input.embedModel) {
184
+ lines.push(
185
+ ``,
186
+ `export const embedModel = ai.model("embed", {`,
187
+ ` provider: "${provider}",`,
188
+ ` model: process.env.OKE_AI_EMBED_MODEL ?? "${input.embedModel}",`,
189
+ `});`,
190
+ ``,
191
+ `export const docsEmbed = ai.embed("docs", { model: embedModel });`,
192
+ );
193
+ }
194
+
195
+ lines.push(``);
196
+ return `${lines.join("\n")}\n`;
197
+ }
198
+
199
+ /**
200
+ * Ensure `src/app.ts` (or `src/core.ts`) imports `./ai`.
201
+ *
202
+ * @param cwd - Project root
203
+ */
204
+ function ensureAiImported(cwd: string): void {
205
+ for (const rel of ["src/app.ts", "src/core.ts"]) {
206
+ const path = join(cwd, rel);
207
+ if (!existsSync(path)) continue;
208
+ const src = readFileSync(path, "utf8");
209
+ if (/from\s+["']\.\/ai["']/.test(src) || /import\s+["']\.\/ai["']/.test(src)) return;
210
+ const next = `import "./ai";\n${src}`;
211
+ writeFileSync(path, next, "utf8");
212
+ return;
213
+ }
214
+ }