okengine 0.10.2 → 0.11.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 (343) hide show
  1. package/package.json +76 -10
  2. package/site/content/docs/ai/mcp.mdx +0 -1
  3. package/site/content/docs/deployment/docker-swarm.mdx +48 -111
  4. package/site/content/docs/deployment/docker.mdx +43 -36
  5. package/site/content/docs/deployment/index.mdx +9 -9
  6. package/site/content/docs/deployment/kubernetes.mdx +4 -4
  7. package/site/content/docs/elements/ai.mdx +7 -13
  8. package/site/content/docs/elements/channel.mdx +9 -13
  9. package/site/content/docs/elements/clock.mdx +8 -14
  10. package/site/content/docs/elements/flow.mdx +46 -54
  11. package/site/content/docs/elements/gate.mdx +6 -11
  12. package/site/content/docs/elements/signal.mdx +2 -8
  13. package/site/content/docs/elements/store.mdx +79 -93
  14. package/site/content/docs/elements/vault.mdx +89 -76
  15. package/site/content/docs/get-started/basic-usage.mdx +8 -14
  16. package/site/content/docs/get-started/installation.mdx +36 -36
  17. package/site/content/docs/get-started/introduction.mdx +11 -0
  18. package/site/content/docs/get-started/why.mdx +3 -4
  19. package/site/content/docs/index.mdx +0 -1
  20. package/site/content/docs/meta.json +0 -1
  21. package/site/content/docs/plugins/anonymous.mdx +1 -1
  22. package/site/content/docs/plugins/magic-link.mdx +11 -11
  23. package/site/content/docs/plugins/otp.mdx +2 -2
  24. package/site/content/docs/plugins/passkey.mdx +1 -1
  25. package/site/content/docs/plugins/two-factor.mdx +1 -1
  26. package/site/content/docs/plugins/username.mdx +1 -1
  27. package/site/content/docs/recipes/caddy.mdx +1 -1
  28. package/site/content/docs/recipes/cockroachdb.mdx +2 -2
  29. package/site/content/docs/recipes/dragonfly.mdx +1 -1
  30. package/site/content/docs/recipes/index.mdx +1 -2
  31. package/site/content/docs/recipes/llama-cpp.mdx +21 -23
  32. package/site/content/docs/recipes/mailpit.mdx +11 -11
  33. package/site/content/docs/recipes/meilisearch.mdx +10 -10
  34. package/site/content/docs/recipes/meta.json +0 -1
  35. package/site/content/docs/recipes/ollama.mdx +1 -1
  36. package/site/content/docs/recipes/pgdog.mdx +12 -9
  37. package/site/content/docs/recipes/postgres.mdx +3 -3
  38. package/site/content/docs/recipes/redis.mdx +2 -2
  39. package/site/content/docs/recipes/rustfs.mdx +10 -7
  40. package/site/content/docs/recipes/sglang.mdx +1 -1
  41. package/site/content/docs/recipes/supabase-docker.mdx +1 -1
  42. package/site/content/docs/recipes/timescale.mdx +2 -2
  43. package/site/content/docs/recipes/valkey.mdx +1 -1
  44. package/site/content/docs/recipes/vllm.mdx +1 -1
  45. package/site/content/docs/recipes/yugabytedb.mdx +2 -2
  46. package/site/content/docs/reference/cli.md +61 -38
  47. package/site/content/docs/reference/configuration.mdx +145 -70
  48. package/site/content/docs/reference/environment-variables.mdx +36 -28
  49. package/site/content/docs/reference/errors.mdx +28 -29
  50. package/site/content/docs/reference/fx.mdx +14 -7
  51. package/site/content/docs/reference/i18n.mdx +17 -6
  52. package/site/content/docs/reference/index.mdx +5 -0
  53. package/site/content/docs/reference/meta.json +1 -0
  54. package/site/content/docs/reference/migrating-environments.mdx +158 -0
  55. package/site/content/docs/reference/plugins.mdx +2 -2
  56. package/src/auth/gate-auth.test.ts +12 -10
  57. package/src/cli/ai-setup/ai-setup.test.ts +30 -16
  58. package/src/cli/ai-setup/apply.ts +111 -15
  59. package/src/cli/ai-setup/catalog.ts +3 -3
  60. package/src/cli/ai-setup/index.ts +1 -1
  61. package/src/cli/ai-setup/prompts.ts +1 -1
  62. package/src/cli/ai-setup/recommend.test.ts +1 -1
  63. package/src/cli/ask-seed.test.ts +3 -3
  64. package/src/cli/build.ts +8 -9
  65. package/src/cli/db-auto-push.test.ts +6 -3
  66. package/src/cli/db-auto-push.ts +7 -5
  67. package/src/cli/db-seed.ts +7 -8
  68. package/src/cli/db.test.ts +70 -71
  69. package/src/cli/db.ts +86 -12
  70. package/src/cli/dev-controller.test.ts +35 -0
  71. package/src/cli/dev-controller.ts +298 -0
  72. package/src/cli/dev-controls.ts +11 -65
  73. package/src/cli/dev-db-push.test.ts +47 -15
  74. package/src/cli/dev-schema-sync.test.ts +11 -11
  75. package/src/cli/dev-schema-sync.ts +10 -10
  76. package/src/cli/dev-session-lock.test.ts +106 -0
  77. package/src/cli/dev-session-lock.ts +209 -0
  78. package/src/cli/dev.test.ts +246 -114
  79. package/src/cli/dev.ts +153 -205
  80. package/src/cli/doc-staleness.test.ts +3 -6
  81. package/src/cli/docker-clean.test.ts +33 -1
  82. package/src/cli/docker-clean.ts +28 -33
  83. package/src/cli/docker-cli.test.ts +12 -11
  84. package/src/cli/docker.ts +95 -20
  85. package/src/cli/doctor.test.ts +40 -0
  86. package/src/cli/doctor.ts +142 -9
  87. package/src/cli/drizzle-env.test.ts +11 -13
  88. package/src/cli/drizzle-env.ts +18 -11
  89. package/src/cli/ensure-drizzle-config.ts +11 -14
  90. package/src/cli/hero-meta.test.ts +15 -15
  91. package/src/cli/hero-meta.ts +10 -9
  92. package/src/cli/index.ts +36 -6
  93. package/src/cli/load-config.images.test.ts +44 -10
  94. package/src/cli/load-config.source.test.ts +50 -0
  95. package/src/cli/load-config.ts +35 -29
  96. package/src/cli/meilisearch-local.ts +1 -2
  97. package/src/cli/mode.ts +7 -58
  98. package/src/cli/registry.ts +175 -19
  99. package/src/cli/resolve-dev-sql-env.test.ts +8 -16
  100. package/src/cli/resolve-dev-sql-env.ts +13 -15
  101. package/src/cli/safe-defaults.test.ts +29 -11
  102. package/src/cli/schema.ts +65 -16
  103. package/src/cli/test.ts +58 -0
  104. package/src/cli/tui/App.tsx +252 -0
  105. package/src/cli/tui/DevLive.tsx +116 -0
  106. package/src/cli/tui/components/ConfirmDialog.tsx +48 -0
  107. package/src/cli/tui/components/HeaderBar.tsx +67 -0
  108. package/src/cli/tui/components/LogStream.tsx +47 -0
  109. package/src/cli/tui/components/PanelCard.tsx +39 -0
  110. package/src/cli/tui/components/SelectRow.tsx +37 -0
  111. package/src/cli/tui/components/SlashPalette.tsx +172 -0
  112. package/src/cli/tui/components/Spinner.tsx +32 -0
  113. package/src/cli/tui/components/StatusBar.tsx +51 -0
  114. package/src/cli/tui/components/StatusDot.tsx +37 -0
  115. package/src/cli/tui/components/TabBar.tsx +49 -0
  116. package/src/cli/tui/components/Wordmark.tsx +43 -0
  117. package/src/cli/tui/format.ts +38 -0
  118. package/src/cli/tui/keys.test.ts +18 -0
  119. package/src/cli/tui/keys.ts +26 -0
  120. package/src/cli/tui/launch.test.ts +18 -0
  121. package/src/cli/tui/launch.ts +57 -0
  122. package/src/cli/tui/panels/Dashboard.tsx +201 -0
  123. package/src/cli/tui/panels/Database.tsx +198 -0
  124. package/src/cli/tui/panels/Dev.tsx +257 -0
  125. package/src/cli/tui/panels/Docker.tsx +357 -0
  126. package/src/cli/tui/panels/Navigator.test.ts +22 -0
  127. package/src/cli/tui/panels/Navigator.tsx +213 -0
  128. package/src/cli/tui/slash-catalog.test.ts +31 -0
  129. package/src/cli/tui/slash-catalog.ts +184 -0
  130. package/src/cli/tui/slash-run.ts +54 -0
  131. package/src/cli/tui/state/ports.ts +95 -0
  132. package/src/cli/tui/theme.ts +24 -0
  133. package/src/cli/vault-cmd.test.ts +402 -0
  134. package/src/cli/vault-cmd.ts +727 -69
  135. package/src/cli/vault-secure-input.test.ts +56 -0
  136. package/src/cli/vault-secure-input.ts +124 -0
  137. package/src/compiler/effects-infer.ts +7 -0
  138. package/src/compiler/extract.ts +12 -0
  139. package/src/compiler/fixtures/skyport/oke.config.ts +7 -5
  140. package/src/compiler/fixtures/skyport/src/flows/payments/index.ts +1 -1
  141. package/src/compiler/fixtures/skyport.expected.json +1 -1
  142. package/src/compiler/generate-adopt.ts +27 -1
  143. package/src/compiler-entry.ts +7 -0
  144. package/src/config/define-config.test.ts +111 -47
  145. package/src/config/driver-defaults.test.ts +115 -0
  146. package/src/config/driver-defaults.ts +156 -0
  147. package/src/config/index.ts +246 -131
  148. package/src/config/resolve-driver.test.ts +14 -19
  149. package/src/console/server/app.ts +4 -2
  150. package/src/console/server/console.test.ts +58 -1
  151. package/src/console/server/flows.ts +22 -2
  152. package/src/console/server/index.ts +3 -0
  153. package/src/console/server/operator-db.test.ts +55 -13
  154. package/src/console/server/operator-db.ts +305 -263
  155. package/src/console/server/serve.ts +16 -7
  156. package/src/console/server/state.ts +31 -9
  157. package/src/console/server/vault.test.ts +120 -5
  158. package/src/console/server/vault.ts +208 -12
  159. package/src/console/ui/dist/assets/{index-D-9KFce5.js → index--Y0Uaz-m.js} +2 -2
  160. package/src/console/ui/dist/assets/panel-vault-BkZdJPX8.js +1 -0
  161. package/src/console/ui/dist/assets/style-C3L349iZ.css +3 -0
  162. package/src/console/ui/dist/index.html +2 -2
  163. package/src/console/ui/shell/client.ts +16 -0
  164. package/src/console/ui/shell/panels/vault/VaultPanel.tsx +54 -1
  165. package/src/console/ui/vault/VaultA11yView.tsx +33 -1
  166. package/src/console/ui/vault/backend.test.ts +75 -0
  167. package/src/console/ui/vault/backend.ts +117 -0
  168. package/src/console/ui/vault/fixture.ts +21 -2
  169. package/src/console/ui/vault/index.ts +11 -1
  170. package/src/console/ui/vault/types.ts +26 -0
  171. package/src/docker/compose.ts +164 -61
  172. package/src/docker/derive.ts +106 -12
  173. package/src/docker/docker.test.ts +156 -110
  174. package/src/docker/helpers.ts +98 -0
  175. package/src/docker/images-config.test.ts +163 -0
  176. package/src/docker/index.ts +30 -5
  177. package/src/docker/recipes/index.ts +0 -3
  178. package/src/docker/recipes/llama-cpp.ts +4 -4
  179. package/src/docker/recipes/pgdog.ts +9 -2
  180. package/src/docker/resources.test.ts +71 -0
  181. package/src/docker/resources.ts +176 -0
  182. package/src/docker/stack-id.test.ts +11 -4
  183. package/src/docker/stack-id.ts +42 -32
  184. package/src/docker/stack.integration.test.ts +4 -15
  185. package/src/docker/types.ts +35 -3
  186. package/src/drivers/ai-ollama.integration.test.ts +2 -2
  187. package/src/drivers/conformance.test.ts +11 -12
  188. package/src/drivers/drizzle-dialect.test.ts +0 -4
  189. package/src/drivers/drizzle-dialect.ts +7 -13
  190. package/src/drivers/index.ts +24 -7
  191. package/src/drivers/journal-postgres.test.ts +3 -1
  192. package/src/drivers/journal-postgres.ts +4 -2
  193. package/src/drivers/meilisearch.ts +1 -1
  194. package/src/drivers/pglite.ts +28 -5
  195. package/src/drivers/types.ts +9 -11
  196. package/src/drivers/vault-aws-secrets-manager.test.ts +159 -0
  197. package/src/drivers/vault-aws-secrets-manager.ts +333 -0
  198. package/src/drivers/vault-builtin.test.ts +110 -0
  199. package/src/drivers/vault-builtin.ts +184 -0
  200. package/src/drivers/vault-driver-removal.test.ts +13 -0
  201. package/src/drivers/vault-managed.test.ts +95 -0
  202. package/src/drivers/vault-managed.ts +101 -25
  203. package/src/drivers/vault-types.ts +25 -5
  204. package/src/elements/ai/runtime.ts +1 -1
  205. package/src/elements/clock/chaos.test.ts +4 -1
  206. package/src/elements/clock/durable.ts +57 -4
  207. package/src/elements/clock.test.ts +13 -14
  208. package/src/elements/gate/constants.ts +18 -0
  209. package/src/elements/gate.test.ts +1 -1
  210. package/src/elements/gate.ts +2 -2
  211. package/src/elements/store/domain-ddl.test.ts +5 -12
  212. package/src/elements/store/emit-drizzle.ts +44 -19
  213. package/src/elements/store/index-boot.test.ts +15 -116
  214. package/src/elements/store/load-plugin-tables.ts +1 -1
  215. package/src/elements/store/resource-list-docs.fixture.ts +12 -6
  216. package/src/elements/store/resource-list-docs.test.ts +64 -44
  217. package/src/elements/store/resource.test.ts +17 -10
  218. package/src/elements/store/runtime.ts +9 -12
  219. package/src/elements/store/schema-decl.test.ts +16 -32
  220. package/src/elements/store/seed.test.ts +1 -2
  221. package/src/elements/store/seed.ts +7 -8
  222. package/src/elements/store/sql-condition.test.ts +2 -2
  223. package/src/elements/store/sql-session.test.ts +8 -10
  224. package/src/elements/store/sql-session.ts +10 -2
  225. package/src/elements/store.test.ts +13 -13
  226. package/src/elements/vault/audit.ts +233 -0
  227. package/src/elements/vault/boot-chain.test.ts +78 -0
  228. package/src/elements/vault/boot-chain.ts +43 -39
  229. package/src/elements/vault/builtin-adapter.test.ts +479 -0
  230. package/src/elements/vault/builtin-adapter.ts +1184 -0
  231. package/src/elements/vault/crypto.test.ts +237 -0
  232. package/src/elements/vault/crypto.ts +431 -0
  233. package/src/elements/vault/declare.ts +164 -3
  234. package/src/elements/vault/errors.ts +56 -0
  235. package/src/elements/vault/kms-unseal.test.ts +76 -0
  236. package/src/elements/vault/kms-unseal.ts +181 -0
  237. package/src/elements/vault/path.ts +90 -0
  238. package/src/elements/vault/resilience.test.ts +102 -0
  239. package/src/elements/vault/resilience.ts +107 -0
  240. package/src/elements/vault/runtime.ts +36 -4
  241. package/src/elements/vault/security-checklist.test.ts +130 -0
  242. package/src/elements/vault/storage.ts +448 -0
  243. package/src/elements/vault/types.ts +288 -0
  244. package/src/elements/vault/unseal.ts +150 -0
  245. package/src/elements/vault.test.ts +72 -4
  246. package/src/elements/vault.ts +91 -2
  247. package/src/full.ts +248 -0
  248. package/src/http.ts +41 -0
  249. package/src/i18n/interpolate-simple.ts +37 -0
  250. package/src/i18n/messages.ts +51 -4
  251. package/src/i18n-entry.ts +7 -0
  252. package/src/index.ts +35 -145
  253. package/src/journal-entry.ts +20 -0
  254. package/src/kernel/adopt-barrel-fresh.test.ts +25 -10
  255. package/src/kernel/app.ts +40 -42
  256. package/src/kernel/auto-registry.test.ts +2 -2
  257. package/src/kernel/boot-bind/ai.test.ts +5 -5
  258. package/src/kernel/boot-bind/ai.ts +2 -1
  259. package/src/kernel/boot-bind/channel.test.ts +6 -6
  260. package/src/kernel/boot-bind/channel.ts +5 -4
  261. package/src/kernel/boot-bind/clock.ts +5 -5
  262. package/src/kernel/boot-bind/gate.ts +6 -7
  263. package/src/kernel/boot-bind/honor-config.test.ts +42 -42
  264. package/src/kernel/boot-bind/journal.ts +5 -5
  265. package/src/kernel/boot-bind/signal.ts +3 -1
  266. package/src/kernel/boot-bind/store.test.ts +55 -37
  267. package/src/kernel/boot-bind/store.ts +24 -34
  268. package/src/kernel/boot-bind/vault.ts +19 -4
  269. package/src/kernel/boot.test.ts +12 -4
  270. package/src/kernel/boot.ts +31 -19
  271. package/src/kernel/budget-entry.ts +1 -1
  272. package/src/kernel/compensate.test.ts +288 -1
  273. package/src/kernel/compensate.ts +126 -0
  274. package/src/kernel/concurrency.test.ts +10 -9
  275. package/src/kernel/effects-stamping.test.ts +27 -2
  276. package/src/kernel/element-registries.ts +5 -3
  277. package/src/kernel/flow.ts +4 -2
  278. package/src/kernel/fx.test.ts +119 -3
  279. package/src/kernel/fx.ts +215 -40
  280. package/src/kernel/index.ts +14 -0
  281. package/src/kernel/journal.ts +130 -7
  282. package/src/kernel/pipeline.test.ts +20 -6
  283. package/src/kernel/redacted.test.ts +35 -0
  284. package/src/kernel/redacted.ts +24 -2
  285. package/src/kernel/router/create-default.ts +16 -0
  286. package/src/kernel/router/create-edge.ts +18 -0
  287. package/src/kernel/router/create.ts +23 -0
  288. package/src/kernel/router/index.ts +23 -0
  289. package/src/kernel/router/linear.ts +88 -0
  290. package/src/kernel/router/regexp.ts +188 -0
  291. package/src/kernel/router/smart.ts +120 -0
  292. package/src/kernel/router/trie.ts +108 -0
  293. package/src/kernel/router/types.ts +56 -0
  294. package/src/kernel/router.ts +18 -532
  295. package/src/kernel-entry.ts +43 -0
  296. package/src/manifest/fixtures/skyport.excerpt.json +1 -1
  297. package/src/manifest/fixtures/skyport.manifest.json +1 -1
  298. package/src/plugins/config-source.test.ts +7 -7
  299. package/src/release/build-lib.ts +58 -0
  300. package/src/release/exports.test.ts +4 -2
  301. package/src/release/exports.ts +30 -4
  302. package/src/release/measure.ts +101 -22
  303. package/src/runs/duckdb.ts +7 -2
  304. package/src/shared/optional-peer.ts +41 -0
  305. package/src/term.test.ts +3 -44
  306. package/src/test/create-test-app.ts +12 -0
  307. package/src/test/provisions.integration.test.ts +1 -1
  308. package/src/test/reset-element-registries.ts +2 -0
  309. package/src/upgrade/codemods.test.ts +7 -20
  310. package/src/upgrade/codemods.ts +93 -60
  311. package/site/content/docs/console/access.mdx +0 -29
  312. package/site/content/docs/console/ai.mdx +0 -35
  313. package/site/content/docs/console/architecture.mdx +0 -35
  314. package/site/content/docs/console/channels.mdx +0 -37
  315. package/site/content/docs/console/clock.mdx +0 -51
  316. package/site/content/docs/console/flows.mdx +0 -31
  317. package/site/content/docs/console/gates.mdx +0 -73
  318. package/site/content/docs/console/index.mdx +0 -54
  319. package/site/content/docs/console/manifest-diff.mdx +0 -34
  320. package/site/content/docs/console/meta.json +0 -24
  321. package/site/content/docs/console/overview.mdx +0 -40
  322. package/site/content/docs/console/plugins.mdx +0 -41
  323. package/site/content/docs/console/privacy.mdx +0 -32
  324. package/site/content/docs/console/runs.mdx +0 -50
  325. package/site/content/docs/console/signals.mdx +0 -40
  326. package/site/content/docs/console/store.mdx +0 -32
  327. package/site/content/docs/console/tenancy.mdx +0 -32
  328. package/site/content/docs/console/traces.mdx +0 -38
  329. package/site/content/docs/console/vault.mdx +0 -41
  330. package/site/content/docs/recipes/openbao.mdx +0 -141
  331. package/src/cli/ask-dev-mode.ts +0 -40
  332. package/src/cli/dev-mode.test.ts +0 -45
  333. package/src/cli/dev-mode.ts +0 -78
  334. package/src/cli/openbao-bootstrap.test.ts +0 -150
  335. package/src/cli/openbao-bootstrap.ts +0 -321
  336. package/src/cli/openbao-restart.integration.test.ts +0 -145
  337. package/src/console/ui/dist/assets/panel-vault-Za1GyJfM.js +0 -1
  338. package/src/console/ui/dist/assets/style-C8MxEWPd.css +0 -3
  339. package/src/docker/recipes/openbao.ts +0 -47
  340. package/src/drivers/libsql.ts +0 -179
  341. package/src/drivers/sqlite.ts +0 -44
  342. package/src/drivers/vault-openbao.test.ts +0 -97
  343. package/src/drivers/vault-openbao.ts +0 -143
@@ -7,8 +7,11 @@ import { mkdtemp } from "node:fs/promises";
7
7
  import { tmpdir } from "node:os";
8
8
  import { join } from "node:path";
9
9
  import {
10
- COMPOSE_ALL,
10
+ COMPOSE_BASE,
11
11
  COMPOSE_OVERRIDE,
12
+ DOCKER_COMPOSE,
13
+ DOCKER_COMPOSE_OVERRIDE,
14
+ DOCKER_STACK,
12
15
  assertNoCredentialsInYaml,
13
16
  buildCaddyfile,
14
17
  buildPgDogToml,
@@ -120,7 +123,7 @@ describe("image recipes", () => {
120
123
  images: { "store.kv": image },
121
124
  credentials: { "store.kv": fixedCreds["store.kv"] },
122
125
  });
123
- const kvYml = derived.files.find((f) => f.path === "compose.store.kv.yml")?.content ?? "";
126
+ const kvYml = derived.files.find((f) => f.path === DOCKER_COMPOSE)?.content ?? "";
124
127
  expect(kvYml).toContain(image);
125
128
  expect(kvYml).toContain("valkey-server");
126
129
  expect(derived.stackEnv.REDIS_URL).toContain("redis://");
@@ -160,7 +163,7 @@ describe("image recipes", () => {
160
163
  images: { "store.kv": image },
161
164
  credentials: { "store.kv": fixedCreds["store.kv"] },
162
165
  });
163
- const kvYml = derived.files.find((f) => f.path === "compose.store.kv.yml")?.content ?? "";
166
+ const kvYml = derived.files.find((f) => f.path === DOCKER_COMPOSE)?.content ?? "";
164
167
  expect(kvYml).toContain(image);
165
168
  expect(kvYml).toContain("memlock");
166
169
  expect(kvYml).toContain("HEALTHCHECK_PORT");
@@ -201,7 +204,7 @@ describe("image recipes", () => {
201
204
  });
202
205
  expect(derived.specs[0]!.port).toBe(26257);
203
206
  expect(derived.specs[0]!.hostPort).toBe(5432);
204
- const sqlYml = derived.files.find((f) => f.path === "compose.store.sql.yml")?.content ?? "";
207
+ const sqlYml = derived.files.find((f) => f.path === DOCKER_COMPOSE)?.content ?? "";
205
208
  expect(sqlYml).toContain("26257");
206
209
  expect(sqlYml).toContain("COCKROACH_");
207
210
  expect(derived.stackEnv.DATABASE_URL).toContain("sslmode=require");
@@ -237,7 +240,7 @@ describe("image recipes", () => {
237
240
  credentials: { "store.sql": fixedCreds["store.sql"] },
238
241
  });
239
242
  expect(derived.specs[0]!.port).toBe(5433);
240
- const sqlYml = derived.files.find((f) => f.path === "compose.store.sql.yml")?.content ?? "";
243
+ const sqlYml = derived.files.find((f) => f.path === DOCKER_COMPOSE)?.content ?? "";
241
244
  expect(sqlYml).toContain("5433");
242
245
  expect(sqlYml).toContain("YSQL_");
243
246
  });
@@ -251,7 +254,7 @@ describe("image recipes", () => {
251
254
  images: { "store.sql": image },
252
255
  credentials: { "store.sql": fixedCreds["store.sql"] },
253
256
  });
254
- const sqlYml = derived.files.find((f) => f.path === "compose.store.sql.yml")?.content ?? "";
257
+ const sqlYml = derived.files.find((f) => f.path === DOCKER_COMPOSE)?.content ?? "";
255
258
  expect(sqlYml).toContain("POSTGRES_USER");
256
259
  expect(derived.stackEnv.DATABASE_URL).toContain("postgres://");
257
260
  });
@@ -268,8 +271,8 @@ describe("image recipes", () => {
268
271
  credentials: fixedCreds["store.sql"],
269
272
  });
270
273
  expect(applied.dependsOn?.["store-sql"]?.condition).toBe("service_healthy");
271
- expect(applied.volumes).toContain("./pgdog.toml:/pgdog/pgdog.toml:ro");
272
- expect(applied.volumes).toContain("./users.toml:/pgdog/users.toml:ro");
274
+ expect(applied.volumes).toContain("./pgdog/pgdog.toml:/pgdog/pgdog.toml:ro");
275
+ expect(applied.volumes).toContain("./pgdog/users.toml:/pgdog/users.toml:ro");
273
276
  expect(applied.healthcheck?.test.join(" ")).toContain("pg_isready");
274
277
  });
275
278
 
@@ -605,7 +608,7 @@ describe("image recipes", () => {
605
608
  });
606
609
 
607
610
  describe("deriveInfrastructure", () => {
608
- test("emits Dockerfile + per-role compose + four-layer merge order", () => {
611
+ test("emits Dockerfile + single docker-compose.yml by default", () => {
609
612
  const result = deriveInfrastructure({
610
613
  images: {
611
614
  "store.sql": "pgvector/pgvector:pg17",
@@ -617,31 +620,24 @@ describe("deriveInfrastructure", () => {
617
620
 
618
621
  const paths = result.files.map((f) => f.path);
619
622
  expect(paths).toContain("Dockerfile");
620
- expect(paths).toContain("compose.yml");
621
- expect(paths).toContain("compose.store.sql.yml");
622
- expect(paths).toContain("compose.store.kv.yml");
623
- expect(paths).toContain(COMPOSE_ALL);
623
+ expect(paths).toContain(DOCKER_COMPOSE);
624
+ expect(paths).not.toContain(COMPOSE_BASE);
625
+ expect(paths).not.toContain("compose.store.sql.yml");
624
626
  expect(paths).not.toContain(COMPOSE_OVERRIDE);
627
+ expect(paths).not.toContain(DOCKER_COMPOSE_OVERRIDE);
625
628
 
626
- expect(result.composeFiles).toEqual([
627
- "compose.yml",
628
- "compose.store.kv.yml",
629
- "compose.store.sql.yml",
630
- COMPOSE_OVERRIDE,
631
- ]);
632
- expect(result.composeFiles).not.toContain(COMPOSE_ALL);
633
-
634
- const sqlYml = result.files.find((f) => f.path === "compose.store.sql.yml")!.content;
635
- expect(sqlYml).toContain("pgvector/pgvector:pg17");
636
- expect(sqlYml).toContain("POSTGRES_PASSWORD");
637
- expect(sqlYml).toContain("${OKE_STORE_SQL_PASSWORD}");
638
- expect(sqlYml).toContain(".env.docker");
639
- expect(sqlYml).not.toContain(fixedCreds["store.sql"].password);
629
+ expect(result.composeFiles).toEqual([DOCKER_COMPOSE, DOCKER_COMPOSE_OVERRIDE]);
640
630
 
641
- const baseYml = result.files.find((f) => f.path === "compose.yml")!.content;
642
- expect(baseYml).toContain('context: ".."');
643
- expect(baseYml).toContain("app:");
644
- expect(baseYml).toContain("oke-skyport:latest");
631
+ const yml = result.files.find((f) => f.path === DOCKER_COMPOSE)!.content;
632
+ expect(yml).toContain("pgvector/pgvector:pg17");
633
+ expect(yml).toContain("valkey/valkey:8-alpine");
634
+ expect(yml).toContain("POSTGRES_PASSWORD");
635
+ expect(yml).toContain("${OKE_STORE_SQL_PASSWORD}");
636
+ expect(yml).toContain(".env.docker");
637
+ expect(yml).not.toContain(fixedCreds["store.sql"].password);
638
+ expect(yml).toContain('context: ".."');
639
+ expect(yml).toContain("app:");
640
+ expect(yml).toContain("oke-skyport:latest");
645
641
 
646
642
  for (const f of result.files) {
647
643
  assertNoCredentialsInYaml(f.content, Object.values(fixedCreds));
@@ -651,6 +647,32 @@ describe("deriveInfrastructure", () => {
651
647
  expect(result.stackEnv.OKE_STORE_SQL_PASSWORD).toBe(fixedCreds["store.sql"].password);
652
648
  });
653
649
 
650
+ test("compose YAML spaces services and adds role comments", () => {
651
+ const result = deriveInfrastructure({
652
+ images: {
653
+ ai: "ghcr.io/ggml-org/llama.cpp:server",
654
+ "channel.email": "axllent/mailpit:v1.22.3",
655
+ pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.51",
656
+ "store.sql": "postgres:16",
657
+ },
658
+ credentials: { "store.sql": fixedCreds["store.sql"] },
659
+ includeApp: false,
660
+ app: "dev",
661
+ });
662
+ const yml = result.files.find((f) => f.path === DOCKER_COMPOSE)!.content;
663
+ expect(yml).toContain("# Generated by `oke docker`");
664
+ expect(yml).toContain("# ai — local inference");
665
+ expect(yml).toContain("# channel.email — Mailpit");
666
+ expect(yml).toContain("# pgdog — connection pooler");
667
+ expect(yml).toContain("# store.sql — Postgres");
668
+ // Blank line between adjacent service blocks (comment → key → … → blank → comment).
669
+ expect(yml).toMatch(/# ai —[^\n]+\n ai:\n[\s\S]+\n\n # channel\.email —/);
670
+ expect(yml).toMatch(/\nnetworks:\n[\s\S]+\n\nservices:\n/);
671
+ expect(serviceNamesFromComposeYaml(yml)).toEqual(
672
+ new Set(["ai", "channel-email", "pgdog", "store-sql"]),
673
+ );
674
+ });
675
+
654
676
  test("includeApp false omits app service (infra-only stack)", () => {
655
677
  const result = deriveInfrastructure({
656
678
  images: { "store.sql": "postgres:16" },
@@ -658,30 +680,75 @@ describe("deriveInfrastructure", () => {
658
680
  includeApp: false,
659
681
  app: "dev",
660
682
  });
661
- const base = result.files.find((f) => f.path === "compose.yml")!.content;
683
+ const base = result.files.find((f) => f.path === DOCKER_COMPOSE)!.content;
662
684
  expect(base).toContain("oke-dev");
663
685
  expect(base).toContain("networks:");
664
686
  expect(base).not.toContain("app:");
665
687
  expect(base).not.toContain("build:");
666
688
  });
667
689
 
668
- test("prod overlay adds deploy.replicas and is layer 3", () => {
690
+ test("prod folds readiness + budgeted deploy into docker-compose.yml", () => {
669
691
  const result = deriveInfrastructure({
670
692
  images: { "store.sql": "postgres:16" },
671
693
  credentials: { "store.sql": fixedCreds["store.sql"] },
672
694
  prod: true,
695
+ serverCpus: 4,
696
+ serverMemoryGb: 8,
673
697
  });
698
+ expect(result.files.some((f) => f.path === "compose.prod.yml")).toBe(false);
699
+ expect(result.composeFiles).toEqual([DOCKER_COMPOSE, DOCKER_COMPOSE_OVERRIDE]);
700
+ const yml = result.files.find((f) => f.path === DOCKER_COMPOSE)!.content;
701
+ expect(yml).toContain("replicas");
702
+ expect(yml).toContain("/_/ready");
703
+ expect(yml).toContain("update_config");
704
+ expect(yml).toContain("restart_policy");
705
+ expect(yml).toContain("stop_grace_period");
706
+ expect(yml).toContain("start-first");
707
+ expect(yml).toContain("on-failure");
708
+ expect(yml).toContain("cpus:");
709
+ expect(yml).toContain("memory:");
710
+ });
711
+
712
+ test("layout split emits per-role files + compose.prod.yml", () => {
713
+ const result = deriveInfrastructure({
714
+ images: { "store.sql": "postgres:16" },
715
+ credentials: { "store.sql": fixedCreds["store.sql"] },
716
+ prod: true,
717
+ layout: "split",
718
+ });
719
+ expect(result.files.some((f) => f.path === COMPOSE_BASE)).toBe(true);
720
+ expect(result.files.some((f) => f.path === "compose.store.sql.yml")).toBe(true);
674
721
  expect(result.files.some((f) => f.path === "compose.prod.yml")).toBe(true);
675
- expect(result.composeFiles).toContain("compose.prod.yml");
676
- expect(result.composeFiles.at(-1)).toBe(COMPOSE_OVERRIDE);
722
+ expect(result.files.some((f) => f.path === DOCKER_COMPOSE)).toBe(false);
723
+ expect(result.composeFiles).toEqual([
724
+ COMPOSE_BASE,
725
+ "compose.store.sql.yml",
726
+ "compose.prod.yml",
727
+ COMPOSE_OVERRIDE,
728
+ ]);
677
729
  const prod = result.files.find((f) => f.path === "compose.prod.yml")!.content;
678
730
  expect(prod).toContain("replicas");
679
731
  expect(prod).toContain("/_/ready");
680
- expect(prod).toContain("update_config");
681
- expect(prod).toContain("restart_policy");
682
- expect(prod).toContain("stop_grace_period");
683
- expect(prod).toContain("start-first");
684
- expect(prod).toContain("on-failure");
732
+ expect(prod).toContain("cpus:");
733
+ });
734
+
735
+ test("layout stack emits docker-stack.yml with overlay network", () => {
736
+ const result = deriveInfrastructure({
737
+ images: { "store.sql": "postgres:16" },
738
+ credentials: { "store.sql": fixedCreds["store.sql"] },
739
+ prod: true,
740
+ layout: "stack",
741
+ app: "skyport",
742
+ });
743
+ expect(result.files.map((f) => f.path)).toContain(DOCKER_STACK);
744
+ expect(result.files.some((f) => f.path === DOCKER_COMPOSE)).toBe(false);
745
+ expect(result.composeFiles).toEqual([DOCKER_STACK]);
746
+ const yml = result.files.find((f) => f.path === DOCKER_STACK)!.content;
747
+ expect(yml).toContain("overlay");
748
+ expect(yml).toContain("oke-skyport:latest");
749
+ expect(yml).not.toContain("build:");
750
+ expect(yml).toContain("/_/ready");
751
+ expect(yml).toContain("cpus:");
685
752
  });
686
753
 
687
754
  test("when postgres + pgdog are both pinned, DATABASE_URL points at PgDog", () => {
@@ -702,18 +769,18 @@ describe("deriveInfrastructure", () => {
702
769
  // Direct Postgres URL stays on the role key for ops / escape hatch.
703
770
  expect(result.stackEnv.OKE_STORE_SQL_URL).toContain(":5432/");
704
771
 
705
- const pgdogYml = result.files.find((f) => f.path === "compose.pgdog.yml")!.content;
772
+ const pgdogYml = result.files.find((f) => f.path === DOCKER_COMPOSE)!.content;
706
773
  expect(pgdogYml).toContain("ghcr.io/pgdogdev/pgdog:v0.1.51");
707
774
  expect(pgdogYml).toContain("store-sql");
708
775
  expect(pgdogYml).toContain("service_healthy");
709
776
  expect(pgdogYml).not.toContain(fixedCreds["store.sql"].password);
710
777
 
711
- const pgdogToml = result.files.find((f) => f.path === "pgdog.toml")!.content;
778
+ const pgdogToml = result.files.find((f) => f.path === "pgdog/pgdog.toml")!.content;
712
779
  expect(pgdogToml).toContain('pooler_mode = "transaction"');
713
780
  expect(pgdogToml).toContain('host = "store-sql"');
714
781
  expect(pgdogToml).not.toContain(fixedCreds["store.sql"].password);
715
782
 
716
- const usersToml = result.files.find((f) => f.path === "users.toml")!.content;
783
+ const usersToml = result.files.find((f) => f.path === "pgdog/users.toml")!.content;
717
784
  expect(usersToml).toContain('password = "s3cret-sql-password-xyz"');
718
785
  expect(usersToml).toContain('database = "oke"');
719
786
  });
@@ -725,7 +792,7 @@ describe("deriveInfrastructure", () => {
725
792
  });
726
793
  expect(result.stackEnv.DATABASE_URL).toContain(":5432/");
727
794
  expect(result.stackEnv.OKE_PGDOG_URL).toBeUndefined();
728
- expect(result.files.some((f) => f.path === "pgdog.toml")).toBe(false);
795
+ expect(result.files.some((f) => f.path === "pgdog/pgdog.toml")).toBe(false);
729
796
  });
730
797
 
731
798
  test("Dockerfile CMD is oke start", () => {
@@ -746,7 +813,7 @@ describe("deriveInfrastructure", () => {
746
813
  });
747
814
  expect(written.some((p) => p.endsWith(COMPOSE_OVERRIDE))).toBe(false);
748
815
  expect(await Bun.file(join(dockerDir, ".env.docker")).exists()).toBe(true);
749
- expect(await Bun.file(join(dockerDir, "compose.yml")).exists()).toBe(true);
816
+ expect(await Bun.file(join(dockerDir, DOCKER_COMPOSE)).exists()).toBe(true);
750
817
  const envText = await Bun.file(join(dockerDir, ".env.docker")).text();
751
818
  expect(envText).toContain("DATABASE_URL=");
752
819
  expect(envText).toContain("# docker/.env.docker — generated by");
@@ -762,7 +829,7 @@ describe("deriveInfrastructure", () => {
762
829
  },
763
830
  app: "skyport",
764
831
  });
765
- const yml = result.files.find((f) => f.path === "compose.store.index.yml")!.content;
832
+ const yml = result.files.find((f) => f.path === DOCKER_COMPOSE)!.content;
766
833
  expect(yml).toContain("getmeili/meilisearch:v1.37");
767
834
  expect(yml).toContain("${OKE_STORE_INDEX_KEY}");
768
835
  expect(yml).toContain("meili_data");
@@ -777,7 +844,7 @@ describe("deriveInfrastructure", () => {
777
844
  images: { ai: OLLAMA_IMAGE },
778
845
  app: "skyport",
779
846
  });
780
- const yml = result.files.find((f) => f.path === "compose.ai.yml")!.content;
847
+ const yml = result.files.find((f) => f.path === DOCKER_COMPOSE)!.content;
781
848
  expect(yml).toContain(OLLAMA_IMAGE);
782
849
  expect(yml).toContain("OKE_AI_MODEL");
783
850
  expect(yml).toContain("qwen3.5:9b");
@@ -795,7 +862,7 @@ describe("deriveInfrastructure", () => {
795
862
  app: "skyport",
796
863
  controls: { OKE_AI_MODEL: "gemma4:e4b-q4_K_M" },
797
864
  });
798
- const yml = result.files.find((f) => f.path === "compose.ai.yml")!.content;
865
+ const yml = result.files.find((f) => f.path === DOCKER_COMPOSE)!.content;
799
866
  expect(yml).toContain(LLAMA_CPP_IMAGE);
800
867
  expect(yml).toContain("127.0.0.1:8080:8080");
801
868
  expect(yml).toContain("llama-entrypoint.py");
@@ -814,8 +881,8 @@ describe("deriveInfrastructure", () => {
814
881
 
815
882
  test("ai vllm and sglang emit loopback publish + GPU deploy", () => {
816
883
  for (const [image, port, path] of [
817
- [VLLM_IMAGE, 8000, "compose.ai.yml"],
818
- [SGLANG_IMAGE, 30000, "compose.ai.yml"],
884
+ [VLLM_IMAGE, 8000, DOCKER_COMPOSE],
885
+ [SGLANG_IMAGE, 30000, DOCKER_COMPOSE],
819
886
  ] as const) {
820
887
  const result = deriveInfrastructure({ images: { ai: image }, app: "skyport" });
821
888
  const yml = result.files.find((f) => f.path === path)!.content;
@@ -947,18 +1014,18 @@ describe("deriveInfrastructure", () => {
947
1014
  credentials: { "store.sql": fixedCreds["store.sql"] },
948
1015
  app: "skyport",
949
1016
  });
950
- const base = result.files.find((f) => f.path === "compose.yml")!.content;
951
- expect(base).toContain("app:");
952
- expect(base).not.toContain("6530:6530");
953
- expect(base).not.toContain("proxy:");
954
- expect(base).toContain("store-sql");
955
- expect(base).not.toMatch(/depends_on:[\s\S]*proxy/);
956
-
957
- const proxyYml = result.files.find((f) => f.path === "compose.proxy.yml")!.content;
958
- expect(proxyYml).toContain("caddy:2-alpine");
959
- expect(proxyYml).toContain("80:80");
960
- expect(proxyYml).toContain("443:443");
961
- expect(proxyYml).toContain("./Caddyfile:/etc/caddy/Caddyfile:ro");
1017
+ const yml = result.files.find((f) => f.path === DOCKER_COMPOSE)!.content;
1018
+ expect(yml).toContain("app:");
1019
+ expect(yml).not.toContain("6530:6530");
1020
+ expect(yml).toContain("proxy:");
1021
+ expect(yml).toContain("store-sql");
1022
+ expect(yml).toContain("caddy:2-alpine");
1023
+ expect(yml).toContain("80:80");
1024
+ expect(yml).toContain("443:443");
1025
+ expect(yml).toContain("./Caddyfile:/etc/caddy/Caddyfile:ro");
1026
+ // App depends_on must not wait on the edge proxy.
1027
+ const appBlock = yml.split("store-sql:")[0]!;
1028
+ expect(appBlock).not.toMatch(/depends_on:[\s\S]*proxy/);
962
1029
 
963
1030
  const caddyfile = result.files.find((f) => f.path === "Caddyfile")!.content;
964
1031
  expect(caddyfile).toContain("reverse_proxy app:6530");
@@ -977,27 +1044,24 @@ describe("deriveInfrastructure", () => {
977
1044
  app: "skyport",
978
1045
  prod: true,
979
1046
  });
980
- const base = result.files.find((f) => f.path === "compose.yml")!.content;
981
- expect(base).not.toContain("6530:6530");
982
-
983
- const proxyYml = result.files.find((f) => f.path === "compose.proxy.yml")!.content;
984
- expect(proxyYml).toContain("traefik:v3.3");
985
- expect(proxyYml).toContain(SOCKET_PROXY_IMAGE);
986
- expect(proxyYml).toContain(SOCKET_PROXY_SERVICE);
987
- expect(proxyYml).toContain("/var/run/docker.sock:/var/run/docker.sock:ro");
988
- expect(proxyYml).toContain("tcp://socket-proxy:2375");
989
- expect(proxyYml).toContain("traefik.enable");
990
- expect(proxyYml).toContain("traefik.http.routers.app.rule");
991
- expect(proxyYml).toContain("loadbalancer.server.port");
1047
+ const yml = result.files.find((f) => f.path === DOCKER_COMPOSE)!.content;
1048
+ expect(yml).not.toContain("6530:6530");
1049
+ expect(yml).toContain("traefik:v3.3");
1050
+ expect(yml).toContain(SOCKET_PROXY_IMAGE);
1051
+ expect(yml).toContain(SOCKET_PROXY_SERVICE);
1052
+ expect(yml).toContain("/var/run/docker.sock:/var/run/docker.sock:ro");
1053
+ expect(yml).toContain("tcp://socket-proxy:2375");
1054
+ expect(yml).toContain("traefik.enable");
1055
+ expect(yml).toContain("traefik.http.routers.app.rule");
1056
+ expect(yml).toContain("loadbalancer.server.port");
992
1057
  // Raw socket must not be on the Traefik service itself.
993
- const traefikBlock = proxyYml.split("socket-proxy:")[0]!;
1058
+ const traefikBlock = yml.split("socket-proxy:")[0]!;
994
1059
  expect(traefikBlock).toContain("traefik:v3.3");
995
1060
  expect(traefikBlock).not.toContain("docker.sock");
996
1061
 
997
1062
  expect(result.files.some((f) => f.path === "Caddyfile")).toBe(false);
998
1063
  expect(result.stackEnv.OKE_PROXY_URL).toBe("https://127.0.0.1");
999
- expect(result.composeFiles).toContain("compose.proxy.yml");
1000
- expect(result.composeFiles).toContain("compose.prod.yml");
1064
+ expect(result.composeFiles).toEqual([DOCKER_COMPOSE, DOCKER_COMPOSE_OVERRIDE]);
1001
1065
  });
1002
1066
 
1003
1067
  test("without proxy role, app still publishes 6530 (default unchanged)", () => {
@@ -1006,13 +1070,13 @@ describe("deriveInfrastructure", () => {
1006
1070
  credentials: { "store.sql": fixedCreds["store.sql"] },
1007
1071
  app: "skyport",
1008
1072
  });
1009
- const base = result.files.find((f) => f.path === "compose.yml")!.content;
1073
+ const base = result.files.find((f) => f.path === DOCKER_COMPOSE)!.content;
1010
1074
  expect(base).toContain("6530:6530");
1011
- expect(result.files.some((f) => f.path === "compose.proxy.yml")).toBe(false);
1075
+ expect(base).not.toContain("\n proxy:");
1012
1076
  expect(result.stackEnv.OKE_PROXY_URL).toBeUndefined();
1013
1077
  });
1014
1078
 
1015
- test("compose.all.yml merges every service from separate layer files", () => {
1079
+ test("docker-compose.yml includes every role + companion service when prod", () => {
1016
1080
  const result = deriveInfrastructure({
1017
1081
  images: {
1018
1082
  "store.sql": "postgres:16",
@@ -1027,35 +1091,17 @@ describe("deriveInfrastructure", () => {
1027
1091
  prod: true,
1028
1092
  });
1029
1093
 
1030
- const allFile = result.files.find((f) => f.path === COMPOSE_ALL);
1031
- expect(allFile).toBeDefined();
1032
- const allYml = allFile!.content;
1033
- const allServices = serviceNamesFromComposeYaml(allYml);
1034
-
1035
- const expected = new Set<string>();
1036
- for (const f of result.files) {
1037
- if (f.path === COMPOSE_ALL) continue;
1038
- if (f.path !== "compose.yml" && !/^compose\..+\.yml$/.test(f.path)) continue;
1039
- for (const name of serviceNamesFromComposeYaml(f.content)) {
1040
- expected.add(name);
1041
- }
1042
- }
1043
-
1044
- expect(expected.size).toBeGreaterThan(0);
1045
- for (const name of expected) {
1046
- expect(allServices.has(name)).toBe(true);
1047
- }
1048
- // Companion + role services survive the merge (not dropped).
1049
- expect(allServices.has("app")).toBe(true);
1050
- expect(allServices.has("store-sql")).toBe(true);
1051
- expect(allServices.has("store-kv")).toBe(true);
1052
- expect(allServices.has("proxy")).toBe(true);
1053
- expect(allServices.has(SOCKET_PROXY_SERVICE)).toBe(true);
1054
- // Prod overlay fields land on app in the merged document.
1055
- expect(allYml).toContain("/_/ready");
1056
- expect(allYml).toContain("stop_grace_period");
1057
- expect(allYml).toContain("update_config");
1058
- // Still not in the layered `-f` list.
1059
- expect(result.composeFiles).not.toContain(COMPOSE_ALL);
1094
+ const yml = result.files.find((f) => f.path === DOCKER_COMPOSE)!.content;
1095
+ const services = serviceNamesFromComposeYaml(yml);
1096
+ expect(services.has("app")).toBe(true);
1097
+ expect(services.has("store-sql")).toBe(true);
1098
+ expect(services.has("store-kv")).toBe(true);
1099
+ expect(services.has("proxy")).toBe(true);
1100
+ expect(services.has(SOCKET_PROXY_SERVICE)).toBe(true);
1101
+ expect(yml).toContain("/_/ready");
1102
+ expect(yml).toContain("stop_grace_period");
1103
+ expect(yml).toContain("update_config");
1104
+ expect(yml).toContain("cpus:");
1105
+ expect(result.composeFiles).toEqual([DOCKER_COMPOSE, DOCKER_COMPOSE_OVERRIDE]);
1060
1106
  });
1061
1107
  });
@@ -160,3 +160,101 @@ export function toYaml(value: unknown, indent = 0): string {
160
160
  }
161
161
  return yamlQuote(String(value));
162
162
  }
163
+
164
+ /** Preferred top-level key order for Compose documents. */
165
+ const COMPOSE_TOP_LEVEL_ORDER = [
166
+ "name",
167
+ "networks",
168
+ "services",
169
+ "volumes",
170
+ "secrets",
171
+ "configs",
172
+ ] as const;
173
+
174
+ /**
175
+ * Options for {@link composeToYaml}.
176
+ */
177
+ export type ComposeYamlOptions = {
178
+ /**
179
+ * File header comments (without leading `# ` prefix — pass full `# …` lines).
180
+ * Pass an empty array to omit the header.
181
+ */
182
+ readonly header?: readonly string[];
183
+ /**
184
+ * Per-service comment body (without `#`). When set, each service is preceded
185
+ * by ` # <comment>` and a blank line separates services.
186
+ */
187
+ readonly serviceComment?: (serviceName: string) => string | undefined;
188
+ };
189
+
190
+ /**
191
+ * Render a Compose document with readable gaps: blank lines between top-level
192
+ * sections and between services, plus optional per-service comments.
193
+ *
194
+ * @param doc - Compose mapping (`name`, `networks`, `services`, …)
195
+ * @param options - Header / service comment hooks
196
+ */
197
+ export function composeToYaml(
198
+ doc: Readonly<Record<string, unknown>>,
199
+ options: ComposeYamlOptions = {},
200
+ ): string {
201
+ const header = options.header ?? [
202
+ "# Generated by `oke docker` / `oke dev --docker`.",
203
+ "# Local overrides: docker-compose.override.yml (do not commit secrets).",
204
+ ];
205
+ const lines: string[] = [...header];
206
+ if (header.length > 0) lines.push("");
207
+
208
+ const present = Object.keys(doc).filter((k) => doc[k] !== undefined);
209
+ const ordered = [
210
+ ...COMPOSE_TOP_LEVEL_ORDER.filter((k) => present.includes(k)),
211
+ ...present.filter((k) => !(COMPOSE_TOP_LEVEL_ORDER as readonly string[]).includes(k)),
212
+ ];
213
+
214
+ for (let i = 0; i < ordered.length; i++) {
215
+ const key = ordered[i]!;
216
+ const value = doc[key];
217
+ if (i > 0) lines.push("");
218
+
219
+ if (key === "services" && isPlainObject(value)) {
220
+ lines.push("services:");
221
+ const services = Object.entries(value).filter(([, v]) => v !== undefined);
222
+ for (let j = 0; j < services.length; j++) {
223
+ const [name, body] = services[j]!;
224
+ if (j > 0) lines.push("");
225
+ const comment = options.serviceComment?.(name);
226
+ if (comment) lines.push(` # ${comment}`);
227
+ if (body !== null && typeof body === "object") {
228
+ const nested = toYaml(body, 2);
229
+ if (nested === "{}" || nested === "[]") {
230
+ lines.push(` ${name}: ${nested}`);
231
+ } else {
232
+ lines.push(` ${name}:`);
233
+ lines.push(nested);
234
+ }
235
+ } else {
236
+ lines.push(` ${name}: ${toYaml(body, 0)}`);
237
+ }
238
+ }
239
+ continue;
240
+ }
241
+
242
+ if (value !== null && typeof value === "object") {
243
+ const nested = toYaml(value, 1);
244
+ if (nested === "{}" || nested === "[]") {
245
+ lines.push(`${key}: ${nested}`);
246
+ } else {
247
+ lines.push(`${key}:`);
248
+ lines.push(nested);
249
+ }
250
+ } else {
251
+ lines.push(`${key}: ${toYaml(value, 0)}`);
252
+ }
253
+ }
254
+
255
+ return `${lines.join("\n")}\n`;
256
+ }
257
+
258
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
259
+ return value !== null && typeof value === "object" && !Array.isArray(value);
260
+ }