okengine 0.9.0 → 0.10.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 (175) hide show
  1. package/AGENTS.md +1 -1
  2. package/manifest.v1.schema.json +9 -1
  3. package/package.json +3 -3
  4. package/site/content/docs/console/runs.mdx +10 -0
  5. package/site/content/docs/console/traces.mdx +4 -0
  6. package/site/content/docs/elements/ai.mdx +54 -32
  7. package/site/content/docs/elements/channel.mdx +105 -14
  8. package/site/content/docs/elements/clock.mdx +1 -0
  9. package/site/content/docs/elements/flow.mdx +42 -8
  10. package/site/content/docs/get-started/basic-usage.mdx +5 -1
  11. package/site/content/docs/get-started/installation.mdx +9 -5
  12. package/site/content/docs/meta.json +2 -0
  13. package/site/content/docs/plugins/otp.mdx +58 -46
  14. package/site/content/docs/providers/azure-redis.mdx +85 -0
  15. package/site/content/docs/providers/cockroachdb.mdx +86 -0
  16. package/site/content/docs/providers/digitalocean-caching.mdx +88 -0
  17. package/site/content/docs/providers/dragonfly-cloud.mdx +82 -0
  18. package/site/content/docs/providers/elasticache.mdx +92 -0
  19. package/site/content/docs/providers/index.mdx +101 -0
  20. package/site/content/docs/providers/memorystore.mdx +89 -0
  21. package/site/content/docs/providers/meta.json +20 -0
  22. package/site/content/docs/providers/neon.mdx +115 -0
  23. package/site/content/docs/providers/redis-cloud.mdx +93 -0
  24. package/site/content/docs/providers/supabase.mdx +117 -0
  25. package/site/content/docs/providers/upstash.mdx +91 -0
  26. package/site/content/docs/providers/yugabytedb.mdx +95 -0
  27. package/site/content/docs/recipes/caddy.mdx +128 -0
  28. package/site/content/docs/recipes/cockroachdb.mdx +127 -0
  29. package/site/content/docs/recipes/dragonfly.mdx +125 -0
  30. package/site/content/docs/recipes/index.mdx +146 -0
  31. package/site/content/docs/recipes/llama-cpp.mdx +153 -0
  32. package/site/content/docs/recipes/mailpit.mdx +136 -0
  33. package/site/content/docs/recipes/meilisearch.mdx +139 -0
  34. package/site/content/docs/recipes/meta.json +31 -0
  35. package/site/content/docs/recipes/ollama.mdx +142 -0
  36. package/site/content/docs/recipes/openbao.mdx +141 -0
  37. package/site/content/docs/recipes/pgdog.mdx +137 -0
  38. package/site/content/docs/recipes/postgres.mdx +136 -0
  39. package/site/content/docs/recipes/redis.mdx +130 -0
  40. package/site/content/docs/recipes/rustfs.mdx +148 -0
  41. package/site/content/docs/recipes/sglang.mdx +105 -0
  42. package/site/content/docs/recipes/supabase-docker.mdx +145 -0
  43. package/site/content/docs/recipes/timescale.mdx +129 -0
  44. package/site/content/docs/recipes/traefik.mdx +136 -0
  45. package/site/content/docs/recipes/valkey.mdx +121 -0
  46. package/site/content/docs/recipes/vllm.mdx +106 -0
  47. package/site/content/docs/recipes/yugabytedb.mdx +135 -0
  48. package/site/content/docs/reference/cli.md +42 -6
  49. package/site/content/docs/reference/configuration.mdx +5 -3
  50. package/site/content/docs/reference/environment-variables.mdx +12 -12
  51. package/site/content/docs/reference/fx.mdx +38 -4
  52. package/src/auth/otp-capability.ts +14 -14
  53. package/src/cli/ai-setup/ai-setup.test.ts +32 -12
  54. package/src/cli/ai-setup/apply.ts +65 -23
  55. package/src/cli/ai-setup/catalog.ts +1316 -35
  56. package/src/cli/ai-setup/detect-ollama.ts +47 -0
  57. package/src/cli/ai-setup/index.ts +44 -7
  58. package/src/cli/ai-setup/prompts.ts +430 -486
  59. package/src/cli/ai-setup/recommend.test.ts +150 -50
  60. package/src/cli/ai-setup/recommend.ts +118 -101
  61. package/src/cli/ask-seed.test.ts +68 -0
  62. package/src/cli/ask-seed.ts +79 -0
  63. package/src/cli/db-seed-redact.test.ts +25 -0
  64. package/src/cli/db-seed.ts +36 -7
  65. package/src/cli/db.test.ts +1 -1
  66. package/src/cli/db.ts +2 -2
  67. package/src/cli/dev-controls.test.ts +107 -0
  68. package/src/cli/dev-controls.ts +354 -0
  69. package/src/cli/dev-schema-sync.ts +12 -4
  70. package/src/cli/dev.test.ts +50 -30
  71. package/src/cli/dev.ts +607 -66
  72. package/src/cli/drizzle-env.test.ts +40 -6
  73. package/src/cli/drizzle-env.ts +22 -1
  74. package/src/cli/ensure-drizzle-config.ts +6 -4
  75. package/src/cli/hero-meta.test.ts +13 -1
  76. package/src/cli/hero-meta.ts +28 -6
  77. package/src/cli/index.ts +5 -0
  78. package/src/cli/registry.ts +24 -0
  79. package/src/cli/replay.test.ts +105 -0
  80. package/src/cli/replay.ts +206 -0
  81. package/src/compiler/extract.ts +3 -3
  82. package/src/config/index.ts +3 -3
  83. package/src/console/server/claim.ts +3 -1
  84. package/src/console/ui/dist/assets/{index-ClX0ZCe5.js → index-D-9KFce5.js} +2 -2
  85. package/src/console/ui/dist/assets/panel-overview-CW0CQEc2.js +1 -0
  86. package/src/console/ui/dist/assets/panel-runs-BxuyDI3x.js +1 -0
  87. package/src/console/ui/dist/assets/{panel-signals-Dsxdu_AR.js → panel-signals-BXofbj7v.js} +1 -1
  88. package/src/console/ui/dist/assets/{panel-store-hy7O8HOs.js → panel-store-B8mH-arU.js} +1 -1
  89. package/src/console/ui/dist/assets/{panel-traces-CBiAe5go.js → panel-traces-DSA-ZWnf.js} +1 -1
  90. package/src/console/ui/dist/index.html +1 -1
  91. package/src/console/ui/overview/slo.ts +31 -3
  92. package/src/console/ui/overview/types.ts +6 -0
  93. package/src/console/ui/runs/errors.test.ts +53 -0
  94. package/src/console/ui/runs/errors.ts +88 -0
  95. package/src/console/ui/runs/index.ts +8 -0
  96. package/src/console/ui/runs/search.ts +16 -0
  97. package/src/console/ui/shell/panels/runs/RunsPanel.tsx +72 -1
  98. package/src/docker/ai-model-status.test.ts +101 -0
  99. package/src/docker/ai-model-status.ts +276 -0
  100. package/src/docker/compose-health.test.ts +115 -0
  101. package/src/docker/compose-health.ts +234 -0
  102. package/src/docker/compose.ts +24 -8
  103. package/src/docker/derive.ts +35 -9
  104. package/src/docker/docker.test.ts +304 -12
  105. package/src/docker/helpers.ts +56 -0
  106. package/src/docker/index.ts +52 -0
  107. package/src/docker/ollama-pull.ts +232 -0
  108. package/src/docker/recipes/cockroach.ts +24 -0
  109. package/src/docker/recipes/index.ts +32 -0
  110. package/src/docker/recipes/llama-cpp.ts +273 -0
  111. package/src/docker/recipes/ollama.ts +19 -23
  112. package/src/docker/recipes/pgdog.ts +4 -1
  113. package/src/docker/recipes/postgres.ts +5 -18
  114. package/src/docker/recipes/sglang.ts +55 -0
  115. package/src/docker/recipes/supabase.ts +21 -0
  116. package/src/docker/recipes/timescale.ts +18 -0
  117. package/src/docker/recipes/vllm.ts +44 -0
  118. package/src/docker/recipes/yugabyte.ts +23 -0
  119. package/src/docker/stack-id.test.ts +22 -0
  120. package/src/docker/stack-id.ts +38 -4
  121. package/src/docker/types.ts +15 -0
  122. package/src/drivers/ai-ollama.integration.test.ts +7 -4
  123. package/src/drivers/signal-engine.ts +10 -0
  124. package/src/drivers/signal-postgres.ts +24 -2
  125. package/src/drivers/signal-types.ts +10 -0
  126. package/src/elements/channel/fallback.test.ts +91 -0
  127. package/src/elements/channel/hard-bounce.test.ts +82 -0
  128. package/src/elements/channel/injection.test.ts +153 -0
  129. package/src/elements/channel/locale-ar.test.ts +102 -0
  130. package/src/elements/channel/otp-delivery.ts +5 -2
  131. package/src/elements/channel/receipts-ledger.test.ts +100 -0
  132. package/src/elements/channel/runtime.ts +24 -1
  133. package/src/elements/channel/test-helpers.ts +60 -0
  134. package/src/elements/channel/whatsapp-compliance.test.ts +124 -0
  135. package/src/elements/store/cache.ts +1 -1
  136. package/src/elements/store/emit-drizzle.ts +43 -9
  137. package/src/elements/store/runtime.ts +1 -1
  138. package/src/elements/store/schema-decl.test.ts +2 -0
  139. package/src/elements/store/schema-decl.ts +15 -0
  140. package/src/elements/store/seed.ts +1 -1
  141. package/src/index.ts +1 -0
  142. package/src/kernel/app.ts +118 -11
  143. package/src/kernel/boot-bind/signal.ts +6 -2
  144. package/src/kernel/boot-bind/store.ts +2 -1
  145. package/src/kernel/boot.test.ts +6 -5
  146. package/src/kernel/boot.ts +8 -3
  147. package/src/kernel/compensate.test.ts +131 -0
  148. package/src/kernel/correlation.test.ts +108 -0
  149. package/src/kernel/effect-timing.test.ts +82 -0
  150. package/src/kernel/flow.ts +38 -0
  151. package/src/kernel/fx-runs.test.ts +49 -0
  152. package/src/kernel/fx.test.ts +14 -0
  153. package/src/kernel/fx.ts +111 -1
  154. package/src/manifest/types.ts +8 -2
  155. package/src/plugins/auth-delivery.mailpit.integration.test.ts +1 -1
  156. package/src/plugins/auth-methods.security.test.ts +3 -3
  157. package/src/plugins/auth-methods.test.ts +1 -1
  158. package/src/plugins/index.ts +2 -2
  159. package/src/plugins/otp.test.ts +21 -19
  160. package/src/plugins/otp.ts +36 -34
  161. package/src/plugins/taqnyat.live.test.ts +3 -3
  162. package/src/runs/collect.ts +3 -0
  163. package/src/runs/export-otlp.ts +64 -0
  164. package/src/runs/index.ts +16 -0
  165. package/src/runs/parquet.ts +9 -0
  166. package/src/runs/types.ts +5 -0
  167. package/src/runs/window.test.ts +68 -0
  168. package/src/runs/window.ts +148 -0
  169. package/src/runtime/boot-warn.test.ts +45 -0
  170. package/src/runtime/boot-warn.ts +17 -0
  171. package/src/runtime/dev-request-log.ts +5 -5
  172. package/src/term.test.ts +95 -10
  173. package/src/term.ts +369 -14
  174. package/src/console/ui/dist/assets/panel-overview-DONDzBd2.js +0 -1
  175. package/src/console/ui/dist/assets/panel-runs-D5zf-D9c.js +0 -1
@@ -15,6 +15,7 @@ import {
15
15
  buildPgDogUsersToml,
16
16
  builtinRecipes,
17
17
  caddy,
18
+ cockroach,
18
19
  deriveInfrastructure,
19
20
  dragonfly,
20
21
  emitDockerfile,
@@ -26,11 +27,25 @@ import {
26
27
  resolveStack,
27
28
  SOCKET_PROXY_IMAGE,
28
29
  SOCKET_PROXY_SERVICE,
30
+ timescale,
29
31
  traefik,
30
32
  traefikAppLabels,
31
33
  valkey,
32
34
  writeDerivedFiles,
35
+ yugabyte,
36
+ ensureOllamaModel,
37
+ llamaCpp,
38
+ LLAMA_CPP_IMAGE,
39
+ LLAMA_CPP_MIN_SAFE_BUILD,
40
+ OllamaPullError,
41
+ OLLAMA_IMAGE,
42
+ OLLAMA_MIN_SAFE_VERSION,
43
+ sglang,
44
+ SGLANG_IMAGE,
45
+ vllm,
46
+ VLLM_IMAGE,
33
47
  type ImageRecipe,
48
+ type OllamaFetch,
34
49
  type ServiceSpec,
35
50
  } from "./index.ts";
36
51
 
@@ -152,6 +167,95 @@ describe("image recipes", () => {
152
167
  expect(derived.stackEnv.REDIS_URL).toContain("redis://");
153
168
  });
154
169
 
170
+ test("cockroach matches official image, port 26257, COCKROACH_* env, sslmode=require URL", () => {
171
+ const image = "cockroachdb/cockroach:v25.2.0";
172
+ expect(recipeFor(image).id).toBe("cockroach");
173
+ expect(cockroach.match(image)).toBe(true);
174
+ expect(postgres.match(image)).toBe(false);
175
+ const spec: ServiceSpec = {
176
+ role: "store.sql",
177
+ serviceName: "store-sql",
178
+ image,
179
+ port: 26257,
180
+ hostPort: 5432,
181
+ credentials: fixedCreds["store.sql"],
182
+ };
183
+ const applied = recipeFor(spec.image).apply(spec);
184
+ expect(applied.command).toEqual(["start-single-node", "--accept-sql-without-tls"]);
185
+ expect(applied.environment?.COCKROACH_USER).toBe("${OKE_STORE_SQL_USER}");
186
+ expect(applied.environment?.COCKROACH_PASSWORD).toBe("${OKE_STORE_SQL_PASSWORD}");
187
+ expect(applied.environment?.COCKROACH_DATABASE).toBe("${OKE_STORE_SQL_DB}");
188
+ expect(applied.extraPorts).toEqual([{ host: 8080, container: 8080 }]);
189
+ expect(applied.volumes?.[0]).toContain("/cockroach/cockroach-data");
190
+ const url = recipeFor(spec.image).url(spec, {
191
+ host: "127.0.0.1",
192
+ port: 5432,
193
+ ...fixedCreds["store.sql"],
194
+ });
195
+ expect(url).toContain("sslmode=require");
196
+ expect(url).toContain(":5432/");
197
+
198
+ const derived = deriveInfrastructure({
199
+ images: { "store.sql": image },
200
+ credentials: { "store.sql": fixedCreds["store.sql"] },
201
+ });
202
+ expect(derived.specs[0]!.port).toBe(26257);
203
+ expect(derived.specs[0]!.hostPort).toBe(5432);
204
+ const sqlYml = derived.files.find((f) => f.path === "compose.store.sql.yml")?.content ?? "";
205
+ expect(sqlYml).toContain("26257");
206
+ expect(sqlYml).toContain("COCKROACH_");
207
+ expect(derived.stackEnv.DATABASE_URL).toContain("sslmode=require");
208
+ });
209
+
210
+ test("yugabyte matches official image, YSQL on 5433, YSQL_* env", () => {
211
+ const image = "yugabytedb/yugabyte:2025.1.0.0-b100";
212
+ expect(recipeFor(image).id).toBe("yugabyte");
213
+ expect(yugabyte.match(image)).toBe(true);
214
+ const spec: ServiceSpec = {
215
+ role: "store.sql",
216
+ serviceName: "store-sql",
217
+ image,
218
+ port: 5433,
219
+ hostPort: 5432,
220
+ credentials: fixedCreds["store.sql"],
221
+ };
222
+ const applied = recipeFor(spec.image).apply(spec);
223
+ expect(String(applied.command)).toContain("yugabyted");
224
+ expect(applied.environment?.YSQL_USER).toBe("${OKE_STORE_SQL_USER}");
225
+ expect(applied.environment?.YSQL_PASSWORD).toBe("${OKE_STORE_SQL_PASSWORD}");
226
+ expect(applied.environment?.YSQL_DB).toBe("${OKE_STORE_SQL_DB}");
227
+ expect(applied.volumes?.[0]).toContain("/home/yugabyte/yb_data");
228
+ const url = recipeFor(spec.image).url(spec, {
229
+ host: "127.0.0.1",
230
+ port: 5432,
231
+ ...fixedCreds["store.sql"],
232
+ });
233
+ expect(url).toStartWith("postgres://oke:");
234
+
235
+ const derived = deriveInfrastructure({
236
+ images: { "store.sql": image },
237
+ credentials: { "store.sql": fixedCreds["store.sql"] },
238
+ });
239
+ expect(derived.specs[0]!.port).toBe(5433);
240
+ const sqlYml = derived.files.find((f) => f.path === "compose.store.sql.yml")?.content ?? "";
241
+ expect(sqlYml).toContain("5433");
242
+ expect(sqlYml).toContain("YSQL_");
243
+ });
244
+
245
+ test("timescale matches ahead of generic postgres with the same POSTGRES_* contract", () => {
246
+ const image = "timescale/timescaledb:latest-pg17";
247
+ expect(recipeFor(image).id).toBe("timescale");
248
+ expect(timescale.match(image)).toBe(true);
249
+ expect(postgres.match(image)).toBe(false);
250
+ const derived = deriveInfrastructure({
251
+ images: { "store.sql": image },
252
+ credentials: { "store.sql": fixedCreds["store.sql"] },
253
+ });
254
+ const sqlYml = derived.files.find((f) => f.path === "compose.store.sql.yml")?.content ?? "";
255
+ expect(sqlYml).toContain("POSTGRES_USER");
256
+ expect(derived.stackEnv.DATABASE_URL).toContain("postgres://");
257
+ });
258
+
155
259
  test("pgdog matches the official image and waits on store-sql", () => {
156
260
  expect(recipeFor("ghcr.io/pgdogdev/pgdog:v0.1.51").id).toBe("pgdog");
157
261
  expect(pgdog.match("ghcr.io/pgdogdev/pgdog:main")).toBe(true);
@@ -215,25 +319,27 @@ describe("image recipes", () => {
215
319
  expect(url).toBe("http://127.0.0.1:7700");
216
320
  });
217
321
 
218
- test("ollama matches the official image, pulls configured model, emits http URL", () => {
219
- expect(recipeFor("ollama/ollama:latest").id).toBe("ollama");
322
+ test("ollama matches the official image, serves on 11434, emits http URL", () => {
323
+ expect(recipeFor(OLLAMA_IMAGE).id).toBe("ollama");
324
+ expect(OLLAMA_IMAGE).not.toContain("latest");
325
+ expect(OLLAMA_MIN_SAFE_VERSION).toBe("0.17.1");
220
326
  const spec: ServiceSpec = {
221
327
  role: "ai",
222
328
  serviceName: "ai",
223
- image: "ollama/ollama:latest",
329
+ image: OLLAMA_IMAGE,
224
330
  port: 11434,
225
331
  hostPort: 11434,
226
332
  credentials: { user: "oke", password: "unused", database: "oke" },
227
333
  };
228
334
  const applied = recipeFor(spec.image).apply(spec);
229
335
  expect(applied.environment?.OKE_AI_MODEL).toBe("${OKE_AI_MODEL:-qwen3.5:9b}");
336
+ expect(applied.environment?.OLLAMA_HOST).toBe("0.0.0.0:11434");
337
+ expect(applied.publishBind).toBe("127.0.0.1");
230
338
  expect(applied.volumes).toContain("ai-data:/root/.ollama");
231
339
  expect(applied.healthcheck?.test.join(" ")).toContain("ollama list");
232
- expect(String(applied.command)).toContain("ollama pull");
233
- // Compose must see `$$` so `$pid` / `$i` are not treated as project env.
234
- expect(String(applied.command)).toContain("pid=$$!");
235
- expect(String(applied.command)).toContain("wait $$pid");
236
- expect(String(applied.command)).not.toMatch(/(?<!\$)\$pid\b/);
340
+ // Pull is host-side via ensureOllamaModel — never a boot `ollama pull` CLI.
341
+ expect(applied.command).toBeUndefined();
342
+ expect(applied.entrypoint).toBeUndefined();
237
343
  const url = recipeFor(spec.image).url(spec, {
238
344
  host: "127.0.0.1",
239
345
  port: 11434,
@@ -244,6 +350,153 @@ describe("image recipes", () => {
244
350
  expect(url).toBe("http://127.0.0.1:11434");
245
351
  });
246
352
 
353
+ test("llama-cpp is the default local AI recipe — OpenAI /v1, loopback publish, pinned ≥ b8146", () => {
354
+ expect(recipeFor(LLAMA_CPP_IMAGE).id).toBe("llama-cpp");
355
+ expect(LLAMA_CPP_IMAGE).not.toContain("latest");
356
+ const build = Number(/server-b(\d+)/.exec(LLAMA_CPP_IMAGE)?.[1]);
357
+ expect(build).toBeGreaterThanOrEqual(LLAMA_CPP_MIN_SAFE_BUILD);
358
+ const spec: ServiceSpec = {
359
+ role: "ai",
360
+ serviceName: "ai",
361
+ image: LLAMA_CPP_IMAGE,
362
+ port: 8080,
363
+ hostPort: 8080,
364
+ credentials: { user: "oke", password: "unused", database: "oke" },
365
+ };
366
+ const applied = llamaCpp.apply(spec);
367
+ expect(applied.publishBind).toBe("127.0.0.1");
368
+ // Never put docker-repo / models-preset / interpolated OKE_AI_MODEL in
369
+ // service environment — router children recurse / OOM; Compose ${} would
370
+ // also override env_file from an empty host shell.
371
+ expect(applied.environment?.LLAMA_ARG_MODELS_PRESET).toBeUndefined();
372
+ expect(applied.environment?.LLAMA_ARG_DOCKER_REPO).toBeUndefined();
373
+ expect(applied.environment?.OKE_AI_MODEL).toBeUndefined();
374
+ expect(applied.entrypoint).toEqual(["/usr/bin/python3", "/oke/llama-entrypoint.py"]);
375
+ expect(applied.command).toBeUndefined();
376
+ expect(applied.volumes?.some((v) => v.includes("llama-entrypoint.py"))).toBe(true);
377
+ expect(applied.extraPorts).toBeUndefined();
378
+ expect(llamaCpp.url(spec, { host: "127.0.0.1", port: 8080, ...spec.credentials })).toBe(
379
+ "http://127.0.0.1:8080/v1",
380
+ );
381
+ });
382
+
383
+ test("vllm recipe is GPU-aware, OpenAI /v1, loopback publish, never latest", () => {
384
+ expect(recipeFor(VLLM_IMAGE).id).toBe("vllm");
385
+ expect(VLLM_IMAGE).not.toContain("latest");
386
+ const spec: ServiceSpec = {
387
+ role: "ai",
388
+ serviceName: "ai",
389
+ image: VLLM_IMAGE,
390
+ port: 8000,
391
+ hostPort: 8000,
392
+ credentials: { user: "oke", password: "unused", database: "oke" },
393
+ };
394
+ const applied = vllm.apply(spec);
395
+ expect(applied.publishBind).toBe("127.0.0.1");
396
+ expect(applied.ipc).toBe("host");
397
+ expect(JSON.stringify(applied.deploy)).toContain("nvidia");
398
+ expect(vllm.url(spec, { host: "127.0.0.1", port: 8000, ...spec.credentials })).toBe(
399
+ "http://127.0.0.1:8000/v1",
400
+ );
401
+ });
402
+
403
+ test("sglang recipe is GPU-aware, OpenAI /v1, loopback publish, never latest", () => {
404
+ expect(recipeFor(SGLANG_IMAGE).id).toBe("sglang");
405
+ expect(SGLANG_IMAGE).not.toContain("latest");
406
+ const spec: ServiceSpec = {
407
+ role: "ai",
408
+ serviceName: "ai",
409
+ image: SGLANG_IMAGE,
410
+ port: 30000,
411
+ hostPort: 30000,
412
+ credentials: { user: "oke", password: "unused", database: "oke" },
413
+ };
414
+ const applied = sglang.apply(spec);
415
+ expect(applied.publishBind).toBe("127.0.0.1");
416
+ expect(applied.ipc).toBe("host");
417
+ expect(JSON.stringify(applied.deploy)).toContain("nvidia");
418
+ expect(sglang.url(spec, { host: "127.0.0.1", port: 30000, ...spec.credentials })).toBe(
419
+ "http://127.0.0.1:30000/v1",
420
+ );
421
+ });
422
+
423
+ test("ensureOllamaModel POSTs /api/pull to the container base URL (not a host CLI)", async () => {
424
+ const calls: { url: string; method: string; body?: string }[] = [];
425
+ const fetchFn: OllamaFetch = async (input, init) => {
426
+ const url = String(input);
427
+ const method = (init?.method ?? "GET").toUpperCase();
428
+ const body = typeof init?.body === "string" ? init.body : undefined;
429
+ calls.push({ url, method, ...(body !== undefined ? { body } : {}) });
430
+ if (url.endsWith("/api/tags") && method === "GET") {
431
+ return new Response(JSON.stringify({ models: [] }), { status: 200 });
432
+ }
433
+ if (url.endsWith("/api/pull") && method === "POST") {
434
+ expect(body).toContain('"model":"qwen3.5:9b"');
435
+ expect(body).toContain('"stream":true');
436
+ return new Response(`${JSON.stringify({ status: "success" })}\n`, { status: 200 });
437
+ }
438
+ return new Response("unexpected", { status: 500 });
439
+ };
440
+
441
+ await ensureOllamaModel({
442
+ url: "http://127.0.0.1:11434",
443
+ model: "qwen3.5:9b",
444
+ fetch: fetchFn,
445
+ readyTimeoutMs: 2_000,
446
+ pullTimeoutMs: 5_000,
447
+ });
448
+
449
+ expect(
450
+ calls.some((c) => c.url === "http://127.0.0.1:11434/api/tags" && c.method === "GET"),
451
+ ).toBe(true);
452
+ const pull = calls.find((c) => c.url === "http://127.0.0.1:11434/api/pull");
453
+ expect(pull?.method).toBe("POST");
454
+ expect(pull?.body).toBe(JSON.stringify({ model: "qwen3.5:9b", stream: true }));
455
+ // No host-side `ollama` binary assumption — only HTTP to the given URL.
456
+ expect(calls.every((c) => c.url.startsWith("http://127.0.0.1:11434/"))).toBe(true);
457
+ });
458
+
459
+ test("ensureOllamaModel skips pull when /api/tags already lists the model", async () => {
460
+ const calls: string[] = [];
461
+ const statuses: string[] = [];
462
+ const fetchFn: OllamaFetch = async (input, init) => {
463
+ const url = String(input);
464
+ const method = (init?.method ?? "GET").toUpperCase();
465
+ calls.push(`${method} ${url}`);
466
+ if (url.endsWith("/api/tags")) {
467
+ return new Response(JSON.stringify({ models: [{ name: "gemma4:e4b" }] }), {
468
+ status: 200,
469
+ });
470
+ }
471
+ return new Response("should not pull", { status: 500 });
472
+ };
473
+
474
+ await ensureOllamaModel({
475
+ url: "http://127.0.0.1:23100",
476
+ model: "gemma4:e4b",
477
+ fetch: fetchFn,
478
+ readyTimeoutMs: 2_000,
479
+ onStatus: (line) => statuses.push(line),
480
+ });
481
+
482
+ expect(calls.some((c) => c.includes("/api/pull"))).toBe(false);
483
+ expect(statuses.some((s) => /already has gemma4:e4b/.test(s))).toBe(true);
484
+ });
485
+
486
+ test("ensureOllamaModel fails loud when the container API never becomes ready", async () => {
487
+ const fetchFn: OllamaFetch = async () => {
488
+ throw new Error("connection refused");
489
+ };
490
+ await expect(
491
+ ensureOllamaModel({
492
+ url: "http://127.0.0.1:59999",
493
+ model: "qwen3.5:9b",
494
+ fetch: fetchFn,
495
+ readyTimeoutMs: 800,
496
+ }),
497
+ ).rejects.toBeInstanceOf(OllamaPullError);
498
+ });
499
+
247
500
  test("a new image recipe is ≤15 lines", async () => {
248
501
  const src = await Bun.file(`${import.meta.dir}/recipes/postgres.ts`).text();
249
502
  const exportLines = src
@@ -519,18 +772,57 @@ describe("deriveInfrastructure", () => {
519
772
  expect(result.stackEnv.DATABASE_URL).toBeUndefined();
520
773
  });
521
774
 
522
- test("ai ollama emits OKE_AI_URL and documents the default model control", () => {
775
+ test("ai ollama emits OKE_AI_URL, loopback publish, and never binds 0.0.0.0 on the host", () => {
523
776
  const result = deriveInfrastructure({
524
- images: { ai: "ollama/ollama:latest" },
777
+ images: { ai: OLLAMA_IMAGE },
525
778
  app: "skyport",
526
779
  });
527
780
  const yml = result.files.find((f) => f.path === "compose.ai.yml")!.content;
528
- expect(yml).toContain("ollama/ollama:latest");
781
+ expect(yml).toContain(OLLAMA_IMAGE);
529
782
  expect(yml).toContain("OKE_AI_MODEL");
530
783
  expect(yml).toContain("qwen3.5:9b");
784
+ expect(yml).toContain("127.0.0.1:11434:11434");
785
+ expect(yml).not.toMatch(/ports:\s*\n\s*-\s*"?11434:11434"?/);
531
786
  expect(result.stackEnv.OKE_AI_URL).toBe("http://127.0.0.1:11434");
532
787
  const envText = formatStackEnv(result.stackEnv);
533
- expect(envText).toContain("# ── ai — Ollama");
788
+ expect(envText).toContain("# ── ai — local inference");
789
+ expect(envText.match(/^OKE_AI_URL=/gm)?.length).toBe(1);
790
+ });
791
+
792
+ test("ai llama-cpp (default) emits /v1 URL and loopback-only publish", () => {
793
+ const result = deriveInfrastructure({
794
+ images: { ai: LLAMA_CPP_IMAGE },
795
+ app: "skyport",
796
+ controls: { OKE_AI_MODEL: "gemma4:e4b-q4_K_M" },
797
+ });
798
+ const yml = result.files.find((f) => f.path === "compose.ai.yml")!.content;
799
+ expect(yml).toContain(LLAMA_CPP_IMAGE);
800
+ expect(yml).toContain("127.0.0.1:8080:8080");
801
+ expect(yml).toContain("llama-entrypoint.py");
802
+ expect(yml).toContain("/oke/llama-entrypoint.py");
803
+ expect(yml).not.toMatch(/ports:\s*\n\s*-\s*"?8080:8080"?/);
804
+ expect(result.stackEnv.OKE_AI_URL).toBe("http://127.0.0.1:8080/v1");
805
+ expect(result.stackEnv.OKE_AI_MODEL).toBe("gemma4:e4b-q4_K_M");
806
+ const entry = result.files.find((f) => f.path === "llama-entrypoint.py")!.content;
807
+ expect(entry).toContain("llama download");
808
+ expect(entry).toContain("org.cncf.model.filepath");
809
+ expect(entry).toContain("CNCF / Hub registry pull");
810
+ expect(entry).toContain("llama-server");
811
+ expect(entry).toContain("--alias");
812
+ expect(yml).not.toContain("--models-preset");
813
+ });
814
+
815
+ test("ai vllm and sglang emit loopback publish + GPU deploy", () => {
816
+ for (const [image, port, path] of [
817
+ [VLLM_IMAGE, 8000, "compose.ai.yml"],
818
+ [SGLANG_IMAGE, 30000, "compose.ai.yml"],
819
+ ] as const) {
820
+ const result = deriveInfrastructure({ images: { ai: image }, app: "skyport" });
821
+ const yml = result.files.find((f) => f.path === path)!.content;
822
+ expect(yml).toContain(`127.0.0.1:${port}:${port}`);
823
+ expect(yml).toContain("nvidia");
824
+ expect(result.stackEnv.OKE_AI_URL).toBe(`http://127.0.0.1:${port}/v1`);
825
+ }
534
826
  });
535
827
 
536
828
  test("emits protocol-specific env keys plus optional control notes", () => {
@@ -15,6 +15,62 @@ export function credEnv(spec: ServiceSpec, field: "USER" | "PASSWORD" | "DB"): s
15
15
  return `\${${key}}`;
16
16
  }
17
17
 
18
+ /** Postgres-protocol env shape (`POSTGRES_*`) — shared by postgres / supabase / timescale. */
19
+ export function postgresEnv(s: ServiceSpec): Record<string, string> {
20
+ return {
21
+ POSTGRES_USER: credEnv(s, "USER"),
22
+ POSTGRES_PASSWORD: credEnv(s, "PASSWORD"),
23
+ POSTGRES_DB: credEnv(s, "DB"),
24
+ };
25
+ }
26
+
27
+ /** Postgres-protocol healthcheck — shared by postgres / supabase / timescale. */
28
+ export const postgresHealth = {
29
+ test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER"],
30
+ interval: "5s",
31
+ timeout: "3s",
32
+ retries: 10,
33
+ } as const;
34
+
35
+ /** CockroachDB single-node env (`COCKROACH_*`) — used only when the data dir is empty. */
36
+ export function cockroachEnv(s: ServiceSpec): Record<string, string> {
37
+ return {
38
+ COCKROACH_USER: credEnv(s, "USER"),
39
+ COCKROACH_PASSWORD: credEnv(s, "PASSWORD"),
40
+ COCKROACH_DATABASE: credEnv(s, "DB"),
41
+ };
42
+ }
43
+
44
+ /** CockroachDB readiness — DB Console `/health?ready=1`. */
45
+ export const cockroachHealth = {
46
+ test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/health?ready=1 >/dev/null 2>&1 || exit 1"],
47
+ interval: "5s",
48
+ timeout: "5s",
49
+ retries: 20,
50
+ start_period: "15s",
51
+ } as const;
52
+
53
+ /** YugabyteDB YSQL env — `YSQL_PASSWORD` enables authentication automatically. */
54
+ export function yugabyteEnv(s: ServiceSpec): Record<string, string> {
55
+ return {
56
+ YSQL_USER: credEnv(s, "USER"),
57
+ YSQL_PASSWORD: credEnv(s, "PASSWORD"),
58
+ YSQL_DB: credEnv(s, "DB"),
59
+ };
60
+ }
61
+
62
+ /** YugabyteDB YSQL readiness — cluster boot is slow; long `start_period`. */
63
+ export const yugabyteHealth = {
64
+ test: [
65
+ "CMD-SHELL",
66
+ 'PGPASSWORD="$$YSQL_PASSWORD" bin/ysqlsh -h 127.0.0.1 -p 5433 -U "$$YSQL_USER" -d "$$YSQL_DB" -c "SELECT 1" >/dev/null 2>&1 || exit 1',
67
+ ],
68
+ interval: "10s",
69
+ timeout: "10s",
70
+ retries: 30,
71
+ start_period: "90s",
72
+ } as const;
73
+
18
74
  /**
19
75
  * Compose service name for a role (`store.sql` → `store-sql`).
20
76
  *
@@ -15,6 +15,7 @@ export type {
15
15
  ImageRecipe,
16
16
  RecipeApplyResult,
17
17
  RecipeExtraPort,
18
+ RecipePublishBind,
18
19
  RecipeUlimit,
19
20
  ServiceCredentials,
20
21
  ServiceEndpoint,
@@ -39,21 +40,72 @@ export { deriveInfrastructure, writeDerivedFiles } from "./derive.ts";
39
40
  export { emitDockerfile } from "./dockerfile.ts";
40
41
  export { credEnv, envPrefix, serviceNameFor } from "./helpers.ts";
41
42
  export { generateCredentials } from "./credentials.ts";
43
+ export {
44
+ ensureOllamaModel,
45
+ normalizeOllamaPullUrl,
46
+ ollamaTagsInclude,
47
+ parseOllamaTagsNames,
48
+ OllamaPullError,
49
+ type EnsureOllamaModelOptions,
50
+ type OllamaFetch,
51
+ } from "./ollama-pull.ts";
52
+ export {
53
+ aiModelIdsMatch,
54
+ formatAiModelStatusMessage,
55
+ normalizeAiProbeUrl,
56
+ probeAiModelStatus,
57
+ startAiModelWatch,
58
+ statusFromOllamaTags,
59
+ statusFromOpenAiModels,
60
+ type AiModelFetch,
61
+ type AiModelPhase,
62
+ type AiModelProbeKind,
63
+ type AiModelStatus,
64
+ type ProbeAiModelStatusOptions,
65
+ type StartAiModelWatchOptions,
66
+ } from "./ai-model-status.ts";
67
+ export {
68
+ composeHealthByService,
69
+ composeRowToStatus,
70
+ parseComposePsJson,
71
+ readComposeHealth,
72
+ startComposeHealthWatch,
73
+ watchComposeHealth,
74
+ type ComposePsRow,
75
+ type ReadComposeHealthOptions,
76
+ type StartComposeHealthWatchOptions,
77
+ type WatchComposeHealthOptions,
78
+ } from "./compose-health.ts";
42
79
  export {
43
80
  builtinRecipes,
44
81
  caddy,
82
+ cockroach,
45
83
  dragonfly,
84
+ llamaCpp,
85
+ buildLlamaCppEntrypoint,
86
+ LLAMA_CPP_ENTRYPOINT_FILE,
87
+ LLAMA_CPP_ENTRYPOINT_MOUNT,
88
+ LLAMA_CPP_IMAGE,
89
+ LLAMA_CPP_MIN_SAFE_BUILD,
46
90
  mailpit,
47
91
  meilisearch,
48
92
  ollama,
93
+ OLLAMA_IMAGE,
94
+ OLLAMA_MIN_SAFE_VERSION,
49
95
  openbao,
50
96
  pgdog,
51
97
  postgres,
52
98
  recipeFor,
53
99
  redis,
54
100
  rustfs,
101
+ sglang,
102
+ SGLANG_IMAGE,
103
+ timescale,
55
104
  traefik,
56
105
  valkey,
106
+ vllm,
107
+ VLLM_IMAGE,
108
+ yugabyte,
57
109
  } from "./recipes/index.ts";
58
110
  export { CADDY_APP_SERVICE, buildCaddyfile } from "./recipes/caddy.ts";
59
111
  export { PGDOG_BACKEND_SERVICE, buildPgDogToml, buildPgDogUsersToml } from "./recipes/pgdog.ts";