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.
- package/AGENTS.md +2 -2
- package/README.md +73 -181
- package/package.json +3 -3
- package/site/content/docs/console/clock.mdx +25 -5
- package/site/content/docs/console/signals.mdx +13 -4
- package/site/content/docs/elements/ai.mdx +29 -2
- package/site/content/docs/elements/channel.mdx +42 -25
- package/site/content/docs/elements/clock.mdx +59 -25
- package/site/content/docs/elements/flow.mdx +8 -2
- package/site/content/docs/elements/gate.mdx +8 -1
- package/site/content/docs/elements/signal.mdx +173 -29
- package/site/content/docs/elements/store.mdx +595 -73
- package/site/content/docs/elements/vault.mdx +15 -5
- package/site/content/docs/get-started/basic-usage.mdx +13 -6
- package/site/content/docs/get-started/installation.mdx +52 -14
- package/site/content/docs/get-started/introduction.mdx +1 -1
- package/site/content/docs/plugins/magic-link.mdx +42 -0
- package/site/content/docs/plugins/phone-number.mdx +78 -17
- package/site/content/docs/plugins/two-factor.mdx +1 -0
- package/site/content/docs/reference/cli.md +32 -2
- package/site/content/docs/reference/configuration.mdx +15 -13
- package/site/content/docs/reference/environment-variables.mdx +32 -18
- package/site/content/docs/reference/errors.mdx +12 -11
- package/site/content/docs/reference/fx.mdx +15 -13
- package/site/content/docs/reference/security.md +3 -1
- package/src/cli/ai-setup/ai-setup.test.ts +144 -0
- package/src/cli/ai-setup/apply.ts +214 -0
- package/src/cli/ai-setup/catalog.ts +263 -0
- package/src/cli/ai-setup/detect-ollama.ts +166 -0
- package/src/cli/ai-setup/index.ts +228 -0
- package/src/cli/ai-setup/prompts.ts +649 -0
- package/src/cli/ai-setup/recommend.test.ts +100 -0
- package/src/cli/ai-setup/recommend.ts +203 -0
- package/src/cli/ai.ts +38 -0
- package/src/cli/db-auto-push.test.ts +6 -1
- package/src/cli/db-auto-push.ts +8 -3
- package/src/cli/db-seed.ts +359 -0
- package/src/cli/db.test.ts +341 -3
- package/src/cli/db.ts +75 -8
- package/src/cli/dev-db-push.test.ts +59 -0
- package/src/cli/dev.test.ts +40 -0
- package/src/cli/dev.ts +55 -1
- package/src/cli/docker-clean.test.ts +179 -0
- package/src/cli/docker-clean.ts +406 -0
- package/src/cli/docker.ts +16 -4
- package/src/cli/ensure-drizzle-config.ts +1 -1
- package/src/cli/hero-meta.test.ts +6 -5
- package/src/cli/hero-meta.ts +7 -1
- package/src/cli/index.ts +5 -0
- package/src/cli/load-config.images.test.ts +22 -0
- package/src/cli/load-config.ts +7 -2
- package/src/cli/openbao-bootstrap.test.ts +1 -0
- package/src/cli/openbao-bootstrap.ts +9 -2
- package/src/cli/registry.ts +116 -2
- package/src/client/live-gap.test.ts +35 -0
- package/src/client/transport.test.ts +21 -0
- package/src/client/transport.ts +27 -5
- package/src/compiler/effects-infer.ts +1 -0
- package/src/compiler/fixtures/skyport/oke.config.ts +1 -1
- package/src/config/define-config.test.ts +6 -6
- package/src/config/index.ts +5 -1
- package/src/console/server/console.test.ts +45 -0
- package/src/console/server/flows.ts +39 -9
- package/src/console/server/i18n.ts +41 -0
- package/src/console/server/vault.ts +12 -38
- package/src/console/ui/dist/assets/index-ClX0ZCe5.js +10 -0
- package/src/console/ui/dist/assets/{panel-access-BGv45snf.js → panel-access-ri1X9Otj.js} +1 -1
- package/src/console/ui/dist/assets/{panel-ai-B2S7LEii.js → panel-ai-DLHjRoJk.js} +1 -1
- package/src/console/ui/dist/assets/{panel-architecture-D7UJh91v.js → panel-architecture-DI3bQF3J.js} +1 -1
- package/src/console/ui/dist/assets/{panel-channels-9T3ybqRu.js → panel-channels-BDYWX29p.js} +1 -1
- package/src/console/ui/dist/assets/{panel-clock-Cb1UXGRQ.js → panel-clock-i-JpTjVB.js} +1 -1
- package/src/console/ui/dist/assets/{panel-diff-DmYbKWmN.js → panel-diff-CFuWxiXy.js} +1 -1
- package/src/console/ui/dist/assets/{panel-flows-PiHwT55z.js → panel-flows-B8TRhcoe.js} +1 -1
- package/src/console/ui/dist/assets/{panel-gates-BQGYXvjT.js → panel-gates-CfkBgco7.js} +1 -1
- package/src/console/ui/dist/assets/{panel-overview-BBnRO18l.js → panel-overview-DONDzBd2.js} +1 -1
- package/src/console/ui/dist/assets/{panel-plugins-D0PsmVw2.js → panel-plugins-DDJwTehL.js} +1 -1
- package/src/console/ui/dist/assets/{panel-runs-CWuRDe0r.js → panel-runs-D5zf-D9c.js} +1 -1
- package/src/console/ui/dist/assets/{panel-signals-Bbg4ewpP.js → panel-signals-Dsxdu_AR.js} +1 -1
- package/src/console/ui/dist/assets/{panel-store-CPCbsDRa.js → panel-store-hy7O8HOs.js} +1 -1
- package/src/console/ui/dist/assets/{panel-traces-DVAzuA_S.js → panel-traces-CBiAe5go.js} +1 -1
- package/src/console/ui/dist/assets/{panel-vault-D1_MvOmo.js → panel-vault-Za1GyJfM.js} +1 -1
- package/src/console/ui/dist/index.html +1 -1
- package/src/console/ui/shell/components/ui.tsx +5 -1
- package/src/console/ui/shell/setup/Wizard.tsx +26 -5
- package/src/docker/cleanup.test.ts +193 -0
- package/src/docker/cleanup.ts +355 -0
- package/src/docker/docker.test.ts +4 -0
- package/src/docker/index.ts +19 -0
- package/src/docker/recipes/ollama.ts +10 -5
- package/src/drivers/ai-ollama.ts +45 -2
- package/src/drivers/ai-providers.test.ts +31 -0
- package/src/drivers/channel-sently.test.ts +8 -0
- package/src/drivers/channel-taqnyat-mail.ts +34 -0
- package/src/drivers/channel-types.ts +71 -0
- package/src/drivers/clock-postgres.test.ts +258 -0
- package/src/drivers/clock-postgres.ts +410 -0
- package/src/drivers/index.ts +21 -0
- package/src/drivers/journal-postgres.test.ts +175 -0
- package/src/drivers/journal-postgres.ts +492 -0
- package/src/drivers/signal-engine.ts +98 -25
- package/src/drivers/signal-nats.ts +4 -4
- package/src/drivers/signal-postgres.ts +158 -64
- package/src/drivers/signal-redis.ts +4 -4
- package/src/drivers/signal-types.ts +61 -2
- package/src/drivers/vault-driver-removal.test.ts +5 -1
- package/src/elements/channel/runtime.ts +51 -0
- package/src/elements/channel.test.ts +71 -0
- package/src/elements/clock/chaos-child.ts +389 -0
- package/src/elements/clock/chaos.test.ts +204 -0
- package/src/elements/clock/durable.ts +7 -0
- package/src/elements/clock/reconcile.ts +110 -0
- package/src/elements/clock/runtime.ts +13 -0
- package/src/elements/clock.test.ts +164 -2
- package/src/elements/clock.ts +2 -1
- package/src/elements/gate/runtime.ts +6 -0
- package/src/elements/signal/chaos-child.ts +41 -4
- package/src/elements/signal/declare.ts +4 -1
- package/src/elements/signal/delivery-modes.test.ts +179 -0
- package/src/elements/signal/key-ordering.test.ts +306 -0
- package/src/elements/signal/lease-reclaim.test.ts +123 -0
- package/src/elements/signal/optional-emit.test.ts +76 -0
- package/src/elements/signal/order-lifecycle.test.ts +130 -0
- package/src/elements/signal/orphan-messages.test.ts +152 -0
- package/src/elements/signal/runtime.ts +11 -4
- package/src/elements/signal/schema-emit.test.ts +107 -0
- package/src/elements/signal.test.ts +89 -5
- package/src/elements/store/files-fx.ts +104 -0
- package/src/elements/store/files-image.test.ts +251 -0
- package/src/elements/store/files-image.ts +494 -0
- package/src/elements/store/runtime.ts +60 -0
- package/src/elements/store/seed.test.ts +27 -0
- package/src/elements/store/seed.ts +68 -0
- package/src/elements/store/sql-session.test.ts +39 -0
- package/src/elements/store/sql-session.ts +55 -0
- package/src/elements/store/upsert-app.test.ts +103 -0
- package/src/elements/store.ts +29 -0
- package/src/elements/vault/boot-chain.ts +150 -0
- package/src/elements/vault/runtime.ts +8 -0
- package/src/index.ts +15 -0
- package/src/kernel/app.ts +168 -15
- package/src/kernel/boot-bind/channel.test.ts +16 -0
- package/src/kernel/boot-bind/channel.ts +13 -0
- package/src/kernel/boot-bind/clock.ts +63 -7
- package/src/kernel/boot-bind/gate.ts +71 -3
- package/src/kernel/boot-bind/honor-config.test.ts +348 -0
- package/src/kernel/boot-bind/journal.ts +89 -0
- package/src/kernel/boot-bind/signal.ts +64 -7
- package/src/kernel/boot-bind/vault.ts +27 -8
- package/src/kernel/boot.test.ts +6 -4
- package/src/kernel/boot.ts +63 -15
- package/src/kernel/concurrency.ts +1 -1
- package/src/kernel/errors.ts +8 -2
- package/src/kernel/fx.test.ts +19 -0
- package/src/kernel/fx.ts +153 -9
- package/src/kernel/index.ts +6 -0
- package/src/kernel/journal-boot.test.ts +397 -0
- package/src/kernel/journal-suspend.ts +35 -0
- package/src/kernel/journal.test.ts +142 -0
- package/src/kernel/journal.ts +202 -27
- package/src/plugins/auth-methods.security.test.ts +10 -7
- package/src/plugins/phone-number.ts +67 -10
- package/src/plugins/taqnyat.live.test.ts +174 -0
- package/src/runtime/dev-request-log.test.ts +20 -1
- package/src/runtime/dev-request-log.ts +38 -0
- package/src/term.test.ts +15 -0
- package/src/term.ts +8 -3
- 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
|
|
17
|
-
| ------------------- |
|
|
18
|
-
| `DATABASE_URL` | Postgres connection (`postgres`
|
|
19
|
-
| `OKE_STORE_SQL_URL` | Explicit SQL URL override
|
|
20
|
-
| `OKE_SQLITE_URL` | SQLite file path (`sqlite` driver)
|
|
21
|
-
| `OKE_LIBSQL_URL` | libSQL URL or file path (`libsql` driver)
|
|
22
|
-
| `OKE_PGLITE_URL` | PGlite data dir or `memory://` (`pglite` driver)
|
|
23
|
-
| `OKE_SQL_DRIVER` | Force the sql driver id at boot
|
|
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
|
|
100
|
-
|
|
|
101
|
-
| `ANTHROPIC_API_KEY`
|
|
102
|
-
| `ANTHROPIC_MODEL`
|
|
103
|
-
| `OPENAI_API_KEY`
|
|
104
|
-
| `OPENAI_BASE_URL`
|
|
105
|
-
| `OKE_AI_DRIVER`
|
|
106
|
-
| `OKE_AI_URL`
|
|
107
|
-
| `OKE_AI_MODEL`
|
|
108
|
-
| `
|
|
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 |
|
|
42
|
-
| `
|
|
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(
|
|
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
|
|
35
|
-
|
|
|
36
|
-
| `fx.emit(signal, payload?)` | `emit` |
|
|
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
|
+
}
|