@endora-commerce/cli 0.100.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 (356) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +37 -0
  3. package/dist/bin/endora.d.ts +28 -0
  4. package/dist/bin/endora.d.ts.map +1 -0
  5. package/dist/bin/endora.js +926 -0
  6. package/dist/bin/endora.js.map +1 -0
  7. package/dist/check/estate.d.ts +189 -0
  8. package/dist/check/estate.d.ts.map +1 -0
  9. package/dist/check/estate.js +1037 -0
  10. package/dist/check/estate.js.map +1 -0
  11. package/dist/check/hosts.d.ts +25 -0
  12. package/dist/check/hosts.d.ts.map +1 -0
  13. package/dist/check/hosts.js +1347 -0
  14. package/dist/check/hosts.js.map +1 -0
  15. package/dist/check/index.d.ts +60 -0
  16. package/dist/check/index.d.ts.map +1 -0
  17. package/dist/check/index.js +111 -0
  18. package/dist/check/index.js.map +1 -0
  19. package/dist/check/layout.d.ts +136 -0
  20. package/dist/check/layout.d.ts.map +1 -0
  21. package/dist/check/layout.js +262 -0
  22. package/dist/check/layout.js.map +1 -0
  23. package/dist/check/ledger.d.ts +98 -0
  24. package/dist/check/ledger.d.ts.map +1 -0
  25. package/dist/check/ledger.js +173 -0
  26. package/dist/check/ledger.js.map +1 -0
  27. package/dist/check/peer-owners.d.ts +116 -0
  28. package/dist/check/peer-owners.d.ts.map +1 -0
  29. package/dist/check/peer-owners.js +225 -0
  30. package/dist/check/peer-owners.js.map +1 -0
  31. package/dist/check/report.d.ts +33 -0
  32. package/dist/check/report.d.ts.map +1 -0
  33. package/dist/check/report.js +107 -0
  34. package/dist/check/report.js.map +1 -0
  35. package/dist/check/run.d.ts +147 -0
  36. package/dist/check/run.d.ts.map +1 -0
  37. package/dist/check/run.js +111 -0
  38. package/dist/check/run.js.map +1 -0
  39. package/dist/checks.d.ts +17 -0
  40. package/dist/checks.d.ts.map +1 -0
  41. package/dist/checks.js +17 -0
  42. package/dist/checks.js.map +1 -0
  43. package/dist/dev/index.d.ts +83 -0
  44. package/dist/dev/index.d.ts.map +1 -0
  45. package/dist/dev/index.js +298 -0
  46. package/dist/dev/index.js.map +1 -0
  47. package/dist/generate/divergence.d.ts +38 -0
  48. package/dist/generate/divergence.d.ts.map +1 -0
  49. package/dist/generate/divergence.js +237 -0
  50. package/dist/generate/divergence.js.map +1 -0
  51. package/dist/generate/index.d.ts +90 -0
  52. package/dist/generate/index.d.ts.map +1 -0
  53. package/dist/generate/index.js +369 -0
  54. package/dist/generate/index.js.map +1 -0
  55. package/dist/index.d.ts +23 -0
  56. package/dist/index.d.ts.map +1 -0
  57. package/dist/index.js +35 -0
  58. package/dist/index.js.map +1 -0
  59. package/dist/inputs/declaration.d.ts +18 -0
  60. package/dist/inputs/declaration.d.ts.map +1 -0
  61. package/dist/inputs/declaration.js +64 -0
  62. package/dist/inputs/declaration.js.map +1 -0
  63. package/dist/inputs/env-file.d.ts +73 -0
  64. package/dist/inputs/env-file.d.ts.map +1 -0
  65. package/dist/inputs/env-file.js +134 -0
  66. package/dist/inputs/env-file.js.map +1 -0
  67. package/dist/inputs/prompt.d.ts +21 -0
  68. package/dist/inputs/prompt.d.ts.map +1 -0
  69. package/dist/inputs/prompt.js +59 -0
  70. package/dist/inputs/prompt.js.map +1 -0
  71. package/dist/inputs/resolve.d.ts +163 -0
  72. package/dist/inputs/resolve.d.ts.map +1 -0
  73. package/dist/inputs/resolve.js +290 -0
  74. package/dist/inputs/resolve.js.map +1 -0
  75. package/dist/install/host.d.ts +27 -0
  76. package/dist/install/host.d.ts.map +1 -0
  77. package/dist/install/host.js +90 -0
  78. package/dist/install/host.js.map +1 -0
  79. package/dist/install/index.d.ts +173 -0
  80. package/dist/install/index.d.ts.map +1 -0
  81. package/dist/install/index.js +793 -0
  82. package/dist/install/index.js.map +1 -0
  83. package/dist/install/wizard.d.ts +144 -0
  84. package/dist/install/wizard.d.ts.map +1 -0
  85. package/dist/install/wizard.js +362 -0
  86. package/dist/install/wizard.js.map +1 -0
  87. package/dist/lib/admin-artefacts.d.ts +70 -0
  88. package/dist/lib/admin-artefacts.d.ts.map +1 -0
  89. package/dist/lib/admin-artefacts.js +354 -0
  90. package/dist/lib/admin-artefacts.js.map +1 -0
  91. package/dist/lib/admin-surfaces.d.ts +298 -0
  92. package/dist/lib/admin-surfaces.d.ts.map +1 -0
  93. package/dist/lib/admin-surfaces.js +669 -0
  94. package/dist/lib/admin-surfaces.js.map +1 -0
  95. package/dist/lib/delegated-composer.d.ts +85 -0
  96. package/dist/lib/delegated-composer.d.ts.map +1 -0
  97. package/dist/lib/delegated-composer.js +241 -0
  98. package/dist/lib/delegated-composer.js.map +1 -0
  99. package/dist/lib/divergence-artefacts.d.ts +305 -0
  100. package/dist/lib/divergence-artefacts.d.ts.map +1 -0
  101. package/dist/lib/divergence-artefacts.js +828 -0
  102. package/dist/lib/divergence-artefacts.js.map +1 -0
  103. package/dist/lib/divergence.d.ts +337 -0
  104. package/dist/lib/divergence.d.ts.map +1 -0
  105. package/dist/lib/divergence.js +1005 -0
  106. package/dist/lib/divergence.js.map +1 -0
  107. package/dist/lib/docs-artefacts.d.ts +395 -0
  108. package/dist/lib/docs-artefacts.d.ts.map +1 -0
  109. package/dist/lib/docs-artefacts.js +781 -0
  110. package/dist/lib/docs-artefacts.js.map +1 -0
  111. package/dist/lib/emitted-exports.d.ts +21 -0
  112. package/dist/lib/emitted-exports.d.ts.map +1 -0
  113. package/dist/lib/emitted-exports.js +96 -0
  114. package/dist/lib/emitted-exports.js.map +1 -0
  115. package/dist/lib/emitted-freshness.d.ts +112 -0
  116. package/dist/lib/emitted-freshness.d.ts.map +1 -0
  117. package/dist/lib/emitted-freshness.js +288 -0
  118. package/dist/lib/emitted-freshness.js.map +1 -0
  119. package/dist/lib/entity-index-artefact.d.ts +95 -0
  120. package/dist/lib/entity-index-artefact.d.ts.map +1 -0
  121. package/dist/lib/entity-index-artefact.js +210 -0
  122. package/dist/lib/entity-index-artefact.js.map +1 -0
  123. package/dist/lib/instance-build-inputs.d.ts +108 -0
  124. package/dist/lib/instance-build-inputs.d.ts.map +1 -0
  125. package/dist/lib/instance-build-inputs.js +165 -0
  126. package/dist/lib/instance-build-inputs.js.map +1 -0
  127. package/dist/lib/module-docs.d.ts +473 -0
  128. package/dist/lib/module-docs.d.ts.map +1 -0
  129. package/dist/lib/module-docs.js +711 -0
  130. package/dist/lib/module-docs.js.map +1 -0
  131. package/dist/lib/module-package-subpaths.d.ts +56 -0
  132. package/dist/lib/module-package-subpaths.d.ts.map +1 -0
  133. package/dist/lib/module-package-subpaths.js +223 -0
  134. package/dist/lib/module-package-subpaths.js.map +1 -0
  135. package/dist/lib/module-packages.d.ts +200 -0
  136. package/dist/lib/module-packages.d.ts.map +1 -0
  137. package/dist/lib/module-packages.js +580 -0
  138. package/dist/lib/module-packages.js.map +1 -0
  139. package/dist/lib/module-population.d.ts +129 -0
  140. package/dist/lib/module-population.d.ts.map +1 -0
  141. package/dist/lib/module-population.js +172 -0
  142. package/dist/lib/module-population.js.map +1 -0
  143. package/dist/lib/module-roots.d.ts +337 -0
  144. package/dist/lib/module-roots.d.ts.map +1 -0
  145. package/dist/lib/module-roots.js +586 -0
  146. package/dist/lib/module-roots.js.map +1 -0
  147. package/dist/lib/nested-checkouts.d.ts +33 -0
  148. package/dist/lib/nested-checkouts.d.ts.map +1 -0
  149. package/dist/lib/nested-checkouts.js +160 -0
  150. package/dist/lib/nested-checkouts.js.map +1 -0
  151. package/dist/lib/platform-root.d.ts +43 -0
  152. package/dist/lib/platform-root.d.ts.map +1 -0
  153. package/dist/lib/platform-root.js +134 -0
  154. package/dist/lib/platform-root.js.map +1 -0
  155. package/dist/lib/platform-surface.d.ts +235 -0
  156. package/dist/lib/platform-surface.d.ts.map +1 -0
  157. package/dist/lib/platform-surface.js +393 -0
  158. package/dist/lib/platform-surface.js.map +1 -0
  159. package/dist/lib/port-registrations.d.ts +223 -0
  160. package/dist/lib/port-registrations.d.ts.map +1 -0
  161. package/dist/lib/port-registrations.js +532 -0
  162. package/dist/lib/port-registrations.js.map +1 -0
  163. package/dist/lib/read-size.d.ts +154 -0
  164. package/dist/lib/read-size.d.ts.map +1 -0
  165. package/dist/lib/read-size.js +182 -0
  166. package/dist/lib/read-size.js.map +1 -0
  167. package/dist/lib/registration-owners.d.ts +79 -0
  168. package/dist/lib/registration-owners.d.ts.map +1 -0
  169. package/dist/lib/registration-owners.js +77 -0
  170. package/dist/lib/registration-owners.js.map +1 -0
  171. package/dist/lib/release-index.d.ts +53 -0
  172. package/dist/lib/release-index.d.ts.map +1 -0
  173. package/dist/lib/release-index.js +162 -0
  174. package/dist/lib/release-index.js.map +1 -0
  175. package/dist/lib/repeating-timers.d.ts +79 -0
  176. package/dist/lib/repeating-timers.d.ts.map +1 -0
  177. package/dist/lib/repeating-timers.js +189 -0
  178. package/dist/lib/repeating-timers.js.map +1 -0
  179. package/dist/lib/source-text.d.ts +34 -0
  180. package/dist/lib/source-text.d.ts.map +1 -0
  181. package/dist/lib/source-text.js +80 -0
  182. package/dist/lib/source-text.js.map +1 -0
  183. package/dist/lib/specifiers.d.ts +20 -0
  184. package/dist/lib/specifiers.d.ts.map +1 -0
  185. package/dist/lib/specifiers.js +130 -0
  186. package/dist/lib/specifiers.js.map +1 -0
  187. package/dist/lib/sql-tables.d.ts +166 -0
  188. package/dist/lib/sql-tables.d.ts.map +1 -0
  189. package/dist/lib/sql-tables.js +464 -0
  190. package/dist/lib/sql-tables.js.map +1 -0
  191. package/dist/lib/switchable-modules.d.ts +54 -0
  192. package/dist/lib/switchable-modules.d.ts.map +1 -0
  193. package/dist/lib/switchable-modules.js +104 -0
  194. package/dist/lib/switchable-modules.js.map +1 -0
  195. package/dist/lib/tailwind-sources.d.ts +136 -0
  196. package/dist/lib/tailwind-sources.d.ts.map +1 -0
  197. package/dist/lib/tailwind-sources.js +307 -0
  198. package/dist/lib/tailwind-sources.js.map +1 -0
  199. package/dist/lib/ui-layer.d.ts +54 -0
  200. package/dist/lib/ui-layer.d.ts.map +1 -0
  201. package/dist/lib/ui-layer.js +57 -0
  202. package/dist/lib/ui-layer.js.map +1 -0
  203. package/dist/lib/workspace-packages.d.ts +186 -0
  204. package/dist/lib/workspace-packages.d.ts.map +1 -0
  205. package/dist/lib/workspace-packages.js +351 -0
  206. package/dist/lib/workspace-packages.js.map +1 -0
  207. package/dist/new-instance/deploy.d.ts +211 -0
  208. package/dist/new-instance/deploy.d.ts.map +1 -0
  209. package/dist/new-instance/deploy.js +1381 -0
  210. package/dist/new-instance/deploy.js.map +1 -0
  211. package/dist/new-instance/docs-toolchain.d.ts +66 -0
  212. package/dist/new-instance/docs-toolchain.d.ts.map +1 -0
  213. package/dist/new-instance/docs-toolchain.js +69 -0
  214. package/dist/new-instance/docs-toolchain.js.map +1 -0
  215. package/dist/new-instance/host.d.ts +124 -0
  216. package/dist/new-instance/host.d.ts.map +1 -0
  217. package/dist/new-instance/host.js +276 -0
  218. package/dist/new-instance/host.js.map +1 -0
  219. package/dist/new-instance/index.d.ts +118 -0
  220. package/dist/new-instance/index.d.ts.map +1 -0
  221. package/dist/new-instance/index.js +567 -0
  222. package/dist/new-instance/index.js.map +1 -0
  223. package/dist/new-instance/modules.d.ts +188 -0
  224. package/dist/new-instance/modules.d.ts.map +1 -0
  225. package/dist/new-instance/modules.js +392 -0
  226. package/dist/new-instance/modules.js.map +1 -0
  227. package/dist/new-instance/template.d.ts +505 -0
  228. package/dist/new-instance/template.d.ts.map +1 -0
  229. package/dist/new-instance/template.js +1886 -0
  230. package/dist/new-instance/template.js.map +1 -0
  231. package/dist/new-module/emit.d.ts +67 -0
  232. package/dist/new-module/emit.d.ts.map +1 -0
  233. package/dist/new-module/emit.js +1393 -0
  234. package/dist/new-module/emit.js.map +1 -0
  235. package/dist/new-module/host.d.ts +67 -0
  236. package/dist/new-module/host.d.ts.map +1 -0
  237. package/dist/new-module/host.js +224 -0
  238. package/dist/new-module/host.js.map +1 -0
  239. package/dist/new-module/index.d.ts +32 -0
  240. package/dist/new-module/index.d.ts.map +1 -0
  241. package/dist/new-module/index.js +193 -0
  242. package/dist/new-module/index.js.map +1 -0
  243. package/dist/new-module/spec.d.ts +188 -0
  244. package/dist/new-module/spec.d.ts.map +1 -0
  245. package/dist/new-module/spec.js +404 -0
  246. package/dist/new-module/spec.js.map +1 -0
  247. package/dist/new-module/text.d.ts +15 -0
  248. package/dist/new-module/text.d.ts.map +1 -0
  249. package/dist/new-module/text.js +22 -0
  250. package/dist/new-module/text.js.map +1 -0
  251. package/dist/new-storefront/dockerfile.d.ts +20 -0
  252. package/dist/new-storefront/dockerfile.d.ts.map +1 -0
  253. package/dist/new-storefront/dockerfile.js +131 -0
  254. package/dist/new-storefront/dockerfile.js.map +1 -0
  255. package/dist/new-storefront/gitignore.d.ts +23 -0
  256. package/dist/new-storefront/gitignore.d.ts.map +1 -0
  257. package/dist/new-storefront/gitignore.js +39 -0
  258. package/dist/new-storefront/gitignore.js.map +1 -0
  259. package/dist/new-storefront/index.d.ts +93 -0
  260. package/dist/new-storefront/index.d.ts.map +1 -0
  261. package/dist/new-storefront/index.js +329 -0
  262. package/dist/new-storefront/index.js.map +1 -0
  263. package/dist/new-storefront/npmrc.d.ts +98 -0
  264. package/dist/new-storefront/npmrc.d.ts.map +1 -0
  265. package/dist/new-storefront/npmrc.js +217 -0
  266. package/dist/new-storefront/npmrc.js.map +1 -0
  267. package/dist/new-storefront/reference.d.ts +189 -0
  268. package/dist/new-storefront/reference.d.ts.map +1 -0
  269. package/dist/new-storefront/reference.js +430 -0
  270. package/dist/new-storefront/reference.js.map +1 -0
  271. package/dist/new-storefront/rewrite.d.ts +171 -0
  272. package/dist/new-storefront/rewrite.d.ts.map +1 -0
  273. package/dist/new-storefront/rewrite.js +701 -0
  274. package/dist/new-storefront/rewrite.js.map +1 -0
  275. package/dist/release-index.json +284 -0
  276. package/dist/rules/action-route-permissions.d.ts +141 -0
  277. package/dist/rules/action-route-permissions.d.ts.map +1 -0
  278. package/dist/rules/action-route-permissions.js +556 -0
  279. package/dist/rules/action-route-permissions.js.map +1 -0
  280. package/dist/rules/bundle-pairing.d.ts +74 -0
  281. package/dist/rules/bundle-pairing.d.ts.map +1 -0
  282. package/dist/rules/bundle-pairing.js +281 -0
  283. package/dist/rules/bundle-pairing.js.map +1 -0
  284. package/dist/rules/channel-resolution.d.ts +17 -0
  285. package/dist/rules/channel-resolution.d.ts.map +1 -0
  286. package/dist/rules/channel-resolution.js +382 -0
  287. package/dist/rules/channel-resolution.js.map +1 -0
  288. package/dist/rules/command-coverage.d.ts +210 -0
  289. package/dist/rules/command-coverage.d.ts.map +1 -0
  290. package/dist/rules/command-coverage.js +714 -0
  291. package/dist/rules/command-coverage.js.map +1 -0
  292. package/dist/rules/container-imports.d.ts +60 -0
  293. package/dist/rules/container-imports.d.ts.map +1 -0
  294. package/dist/rules/container-imports.js +158 -0
  295. package/dist/rules/container-imports.js.map +1 -0
  296. package/dist/rules/default-language-prose.d.ts +212 -0
  297. package/dist/rules/default-language-prose.d.ts.map +1 -0
  298. package/dist/rules/default-language-prose.js +710 -0
  299. package/dist/rules/default-language-prose.js.map +1 -0
  300. package/dist/rules/diacritic-folds.d.ts +238 -0
  301. package/dist/rules/diacritic-folds.d.ts.map +1 -0
  302. package/dist/rules/diacritic-folds.js +681 -0
  303. package/dist/rules/diacritic-folds.js.map +1 -0
  304. package/dist/rules/entity-tenant-classification.d.ts +171 -0
  305. package/dist/rules/entity-tenant-classification.d.ts.map +1 -0
  306. package/dist/rules/entity-tenant-classification.js +323 -0
  307. package/dist/rules/entity-tenant-classification.js.map +1 -0
  308. package/dist/rules/entry-presence.d.ts +142 -0
  309. package/dist/rules/entry-presence.d.ts.map +1 -0
  310. package/dist/rules/entry-presence.js +339 -0
  311. package/dist/rules/entry-presence.js.map +1 -0
  312. package/dist/rules/entry-scope.d.ts +91 -0
  313. package/dist/rules/entry-scope.d.ts.map +1 -0
  314. package/dist/rules/entry-scope.js +404 -0
  315. package/dist/rules/entry-scope.js.map +1 -0
  316. package/dist/rules/env-inputs.d.ts +222 -0
  317. package/dist/rules/env-inputs.d.ts.map +1 -0
  318. package/dist/rules/env-inputs.js +951 -0
  319. package/dist/rules/env-inputs.js.map +1 -0
  320. package/dist/rules/kernel-boundary.d.ts +37 -0
  321. package/dist/rules/kernel-boundary.d.ts.map +1 -0
  322. package/dist/rules/kernel-boundary.js +195 -0
  323. package/dist/rules/kernel-boundary.js.map +1 -0
  324. package/dist/rules/nul-bytes.d.ts +233 -0
  325. package/dist/rules/nul-bytes.d.ts.map +1 -0
  326. package/dist/rules/nul-bytes.js +332 -0
  327. package/dist/rules/nul-bytes.js.map +1 -0
  328. package/dist/rules/platform-surface.d.ts +479 -0
  329. package/dist/rules/platform-surface.d.ts.map +1 -0
  330. package/dist/rules/platform-surface.js +749 -0
  331. package/dist/rules/platform-surface.js.map +1 -0
  332. package/dist/rules/port-catches.d.ts +225 -0
  333. package/dist/rules/port-catches.d.ts.map +1 -0
  334. package/dist/rules/port-catches.js +1374 -0
  335. package/dist/rules/port-catches.js.map +1 -0
  336. package/dist/rules/port-shape.d.ts +213 -0
  337. package/dist/rules/port-shape.d.ts.map +1 -0
  338. package/dist/rules/port-shape.js +670 -0
  339. package/dist/rules/port-shape.js.map +1 -0
  340. package/dist/rules/queue-names.d.ts +108 -0
  341. package/dist/rules/queue-names.d.ts.map +1 -0
  342. package/dist/rules/queue-names.js +395 -0
  343. package/dist/rules/queue-names.js.map +1 -0
  344. package/dist/rules/singleton-identity.d.ts +205 -0
  345. package/dist/rules/singleton-identity.d.ts.map +1 -0
  346. package/dist/rules/singleton-identity.js +830 -0
  347. package/dist/rules/singleton-identity.js.map +1 -0
  348. package/dist/rules/subscribe-seam.d.ts +121 -0
  349. package/dist/rules/subscribe-seam.d.ts.map +1 -0
  350. package/dist/rules/subscribe-seam.js +594 -0
  351. package/dist/rules/subscribe-seam.js.map +1 -0
  352. package/dist/rules/transaction-context.d.ts +40 -0
  353. package/dist/rules/transaction-context.d.ts.map +1 -0
  354. package/dist/rules/transaction-context.js +294 -0
  355. package/dist/rules/transaction-context.js.map +1 -0
  356. package/package.json +59 -0
@@ -0,0 +1,1381 @@
1
+ /**
2
+ * The example deployment files an instance is scaffolded with
3
+ * (`specs/122-layer-deployment-independence/contracts/layer-independence.md` §3,
4
+ * under owner ruling **D-230**).
5
+ *
6
+ * ## Why this file exists
7
+ *
8
+ * `instance-repository.md` R2.1 lists a compose file, an nginx configuration
9
+ * and an `.env` as *"always in the instance"*. Measured on 2026-09-13, an
10
+ * instance was handed **none of them**, and no Dockerfile either — so a client
11
+ * asked for the owner's three-host topology wrote three deployment files from
12
+ * scratch, against a compose example that lives in *our* repository and assumes
13
+ * one host.
14
+ *
15
+ * ## They are examples, and they are derived from two axes and nothing else
16
+ *
17
+ * D-215: an instance holds *examples*, never a client's concrete files. So no
18
+ * real registry, no real domain, no real secret and none of this repository's
19
+ * container names appear below. What decides their content is the **member
20
+ * set** — an admin-less instance has no admin service, no admin image and no
21
+ * admin origin in its nginx example — and the **topology**, which selects which
22
+ * files are written and records nothing (R3.2). A topology written into the
23
+ * root manifest would be a third home for what an instance is, beside the
24
+ * `dependencies` that are the module set.
25
+ *
26
+ * ## The three-host split is derived, not written
27
+ *
28
+ * Every service carries the host it belongs to, and {@link composeDocument}
29
+ * drops an edge whose target is not in the same file. That is what makes R3.6
30
+ * checkable rather than asserted: the single-host graph partitions with exactly
31
+ * one edge crossing — `storefront -> backend`, `service_healthy`, a readiness
32
+ * convenience — and every `service_completed_successfully` edge in the file,
33
+ * `backend-install -> backend-migrate` and `backend -> backend-install`, lives
34
+ * entirely inside the backend host. The lost edge is replaced by **nothing**: each layer starts, answers its own
35
+ * health check and tolerates an absent peer, and a wait-for-it script is the
36
+ * temptation this rule exists to refuse. An edge added across a boundary
37
+ * changes what the renderer drops and reds `test/new-instance/deploy-examples.test.ts`.
38
+ *
39
+ * ## Nothing here spells a build input twice
40
+ *
41
+ * Every `--build-arg` the Dockerfile examples pass, and every `ARG` they
42
+ * declare, is emitted from `../lib/instance-build-inputs.js` (§2 R2.3). A
43
+ * fourth spelling of a build input cannot arrive here, because there is no
44
+ * place to write one.
45
+ *
46
+ * ## The development compose is the same catalogue in a second mode
47
+ *
48
+ * `specs/125-first-mile-install/spec.md` §4.1 (FR-100…FR-112). A scaffolded
49
+ * instance also carries `compose.dev.yml`, at its **root** and not under
50
+ * `deploy/`, and that file is **runnable as written** where every file under
51
+ * `deploy/` is an example the client has to build and push images for first. It
52
+ * is rendered by {@link developmentComposeFile} from the **same**
53
+ * {@link services} records, in `development` mode, and FR-103 is what that mode
54
+ * exists for: *"no second statement of what Endora needs to run may enter the
55
+ * tree"*. Two things differ between the modes and both are derived — where a
56
+ * value comes from (an operator-filled `${NAME}` against an inline-defaulted
57
+ * `${NAME:-…}`) and whether the service publishes a host port. The image, the
58
+ * healthcheck and the volume are one record's.
59
+ *
60
+ * Defect F-2 is what that requirement is measured against: this repository's own
61
+ * `docker-compose.yml` and the catalogue below name the same three stateful
62
+ * images, and they agreed by coincidence until
63
+ * `test/new-instance/dev-compose.test.ts` made them agree by instrument — that
64
+ * case reads both files and names both paths, and it is also why no image tag
65
+ * is written twice in this file, not even in this sentence. Writing a **third**
66
+ * statement of them into every client's tree is what
67
+ * this file would have done if the development compose had been authored rather
68
+ * than derived.
69
+ */
70
+ import { scopeToMembers } from '@endora-commerce/contracts';
71
+ import { buildArgFlags, buildInputsFor, } from '../lib/instance-build-inputs.js';
72
+ import { InstanceInputError } from './host.js';
73
+ /**
74
+ * The vocabulary, in the order the flag's refusal prints it.
75
+ *
76
+ * `single-host` is first because it is the default, on the owner's own *"the
77
+ * most common scenario is probably all three layers on one machine"* (D-215).
78
+ */
79
+ export const TOPOLOGIES = ['single-host', 'three-host'];
80
+ /** What a run that named no topology gets (D-230). */
81
+ export const DEFAULT_TOPOLOGY = 'single-host';
82
+ /**
83
+ * `--topology`'s value, or an operator-fixable refusal naming the vocabulary.
84
+ *
85
+ * F3's class: a flag the operator wrote and can rewrite, which
86
+ * `instance-tree.md` §4 puts at exit `1`. It is refused rather than defaulted —
87
+ * a run that silently ignored `--topology three-hosts` would write a
88
+ * single-host example to a client who asked for three, and the first they would
89
+ * hear of it is a compose file with a database in it.
90
+ */
91
+ export function assertTopology(value) {
92
+ if (TOPOLOGIES.includes(value))
93
+ return value;
94
+ throw new InstanceInputError('F3', `\`--topology ${value}\` is not a topology this command writes an example for. It is ` +
95
+ `one of ${TOPOLOGIES.join(' | ')}, and it selects which example deployment files go ` +
96
+ `into \`deploy/\` — it is written into no manifest and read back by nothing, so a ` +
97
+ `machine layout stays a fact about your machines. Nothing is written.`);
98
+ }
99
+ const IMAGE = (member) => ` image: \${REGISTRY_IMAGE}/${member}:\${IMAGE_TAG:-latest}`;
100
+ /**
101
+ * The backend's environment, declared once and read by both backend services.
102
+ *
103
+ * `ADMIN_BASE_URL` and `CORS_ALLOWED_ORIGINS` are here whatever the member set
104
+ * is, and that is R3.7 rather than an oversight: both are read by the
105
+ * **backend** — `mfa` composes mailed links from the first and the platform's
106
+ * HTTP server reads the second — so a rule that dropped them with the admin
107
+ * member would be keyed on the variable's name, which is wrong on two of the
108
+ * three inputs that mention the admin. They are passed through from the `.env`
109
+ * rather than composed from a domain, because under three hosts the backend
110
+ * cannot guess an origin that is not its own.
111
+ */
112
+ const BACKEND_ENVIRONMENT = [
113
+ 'x-backend-env: &backend-env',
114
+ ' NODE_ENV: production',
115
+ ' LOG_LEVEL: ${LOG_LEVEL:-info}',
116
+ ' PORT: "3001"',
117
+ ' DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}',
118
+ ' REDIS_URL: redis://redis:6379',
119
+ ' MEILISEARCH_URL: http://meilisearch:7700',
120
+ ' MEILISEARCH_API_KEY: ${MEILI_MASTER_KEY}',
121
+ ' # The origin every payment-gateway callback, public product feed and',
122
+ ' # newsletter confirmation link is built on. The backend refuses to boot in',
123
+ ' # production when it resolves to nothing at all.',
124
+ ' BACKEND_PUBLIC_URL: https://${API_DOMAIN}',
125
+ ' PUBLIC_API_BASE_URL: https://${API_DOMAIN}',
126
+ ' STOREFRONT_BASE_URL: https://${STOREFRONT_DOMAIN}',
127
+ ' # Both of these are the BACKEND\'s inputs, whether or not this instance',
128
+ ' # ships an admin: the first is what mailed links are composed from and the',
129
+ ' # second is what the browser is allowed to call the API from. Get the',
130
+ ' # second wrong and every call from the admin and the storefront fails with',
131
+ ' # a message that names none of this.',
132
+ ' ADMIN_BASE_URL: ${ADMIN_BASE_URL}',
133
+ ' CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}',
134
+ ' # The other half of the revalidation seam. It must be the SAME value the',
135
+ ' # storefront has; unset, the backend\'s revalidator is a silent no-op and a',
136
+ ' # catalogue change never reaches the rendered storefront.',
137
+ ' REVALIDATE_SECRET: ${REVALIDATE_SECRET}',
138
+ ' SESSION_COOKIE_SECRET: ${SESSION_COOKIE_SECRET}',
139
+ ' # Signs newsletter confirmation and unsubscribe links. Unset, the backend',
140
+ ' # signs them with the session key, and rotating that key invalidates every',
141
+ ' # link still waiting in an inbox.',
142
+ ' NEWSLETTER_TOKEN_SECRET: ${NEWSLETTER_TOKEN_SECRET}',
143
+ ' SETTINGS_SECRET_ENCRYPTION_KEY: ${SETTINGS_SECRET_ENCRYPTION_KEY}',
144
+ ' MFA_SECRET_ENCRYPTION_KEY: ${MFA_SECRET_ENCRYPTION_KEY}',
145
+ ' ASSETS_LIBRARY_HMAC_KEY: ${ASSETS_LIBRARY_HMAC_KEY}',
146
+ ' # Which upstream proxy may be believed about the client address. Unset, the',
147
+ ' # backend trusts none and every request looks like it came from your proxy.',
148
+ ' TRUSTED_PROXY_HOPS: ${TRUSTED_PROXY_HOPS}',
149
+ ' TRUSTED_PROXY_ADDRESSES: ${TRUSTED_PROXY_ADDRESSES}',
150
+ ' DEFAULT_SALES_CHANNEL_CODE: ${DEFAULT_SALES_CHANNEL_CODE}',
151
+ ' SALES_CHANNEL_HOST_MAP: ${SALES_CHANNEL_HOST_MAP}',
152
+ ' SMTP_URL: ${SMTP_URL}',
153
+ ' SMTP_FROM: ${SMTP_FROM}',
154
+ ];
155
+ /**
156
+ * {@link BACKEND_ENVIRONMENT}, completed from the declaration this run read.
157
+ *
158
+ * The static block is the fallback a run with no readable declaration still
159
+ * renders, and CLI 0.15.0's copy of it left `NEWSLETTER_TOKEN_SECRET` out — the
160
+ * only one of the platform's generable secrets it omitted, so every deployment
161
+ * signed newsletter links with the session key. A list can only be kept complete
162
+ * by somebody remembering to; so every **generable secret** the declaration
163
+ * scopes to the backend and the block does not name is appended here. Those are
164
+ * the inputs `new instance` writes a value for into a `.env` it generates, and a
165
+ * value the container is never handed is a value generated for nothing.
166
+ */
167
+ function backendEnvironment(declared) {
168
+ const named = new Set(BACKEND_ENVIRONMENT.flatMap((line) => /^ {2}([A-Z][A-Z0-9_]*):/.exec(line)?.[1] ?? []));
169
+ const owed = scopeToMembers(declared, ['backend']).filter((input) => input.generable && input.secret && !named.has(input.name));
170
+ if (owed.length === 0)
171
+ return BACKEND_ENVIRONMENT;
172
+ return [
173
+ ...BACKEND_ENVIRONMENT,
174
+ ' # Generable secrets the platform declares that the lines above do not name.',
175
+ ...owed.map((input) => ` ${input.name}: \${${input.name}}`),
176
+ ];
177
+ }
178
+ /**
179
+ * Every service the examples can hold, with the host it belongs to (R1.4), in
180
+ * the rendering the caller asked for (FR-103).
181
+ *
182
+ * `value` and `published` are the **only** two things the mode decides, and
183
+ * every service below reads them rather than branching: a record that tested
184
+ * the mode itself would be two records sharing a name, which is what
185
+ * *"one catalogue, two renderings"* refuses.
186
+ */
187
+ function services(input, mode = 'production') {
188
+ /**
189
+ * One value, from the mode's own source.
190
+ *
191
+ * In `production` it is a `${NAME}` the operator fills in `deploy/.env` — a
192
+ * default there would be this file choosing a client's database password. In
193
+ * `development` it is the same name carrying that default inline, which is
194
+ * FR-101: `docker compose -f compose.dev.yml up -d --wait` has to succeed in
195
+ * a tree whose `.env` has never been opened, and one un-defaulted expansion
196
+ * anywhere defeats that whatever the rest carry.
197
+ */
198
+ const value = (name, development) => mode === 'production' ? `\${${name}}` : `\${${name}:-${development}}`;
199
+ /**
200
+ * The host ports a backing service publishes, in `development` only.
201
+ *
202
+ * Under `deploy/` these are not published: the application is a container in
203
+ * the same project and reaches them by service name. On a development machine
204
+ * the backend, the admin and the storefront run **natively** from the
205
+ * workspace the same run wrote (FR-104), so the only way they reach these is
206
+ * a published port. Each one is overridable, because two checkouts on one
207
+ * machine is the ordinary case and a fixed 5432 makes the second one fail.
208
+ */
209
+ const published = (ports) => mode === 'production'
210
+ ? []
211
+ : [
212
+ ' ports:',
213
+ ...ports.map(([variable, host, container]) => ` - '\${${variable}:-${String(host)}}:${String(container)}'`),
214
+ ];
215
+ const all = [
216
+ {
217
+ name: 'postgres',
218
+ host: 'backend',
219
+ dependsOn: [],
220
+ body: [
221
+ ' image: postgres:16-alpine',
222
+ ' restart: unless-stopped',
223
+ ' environment:',
224
+ ` POSTGRES_USER: ${value('POSTGRES_USER', 'endora')}`,
225
+ ` POSTGRES_PASSWORD: ${value('POSTGRES_PASSWORD', 'endora')}`,
226
+ ` POSTGRES_DB: ${value('POSTGRES_DB', 'endora')}`,
227
+ ' volumes:',
228
+ ' - postgres-data:/var/lib/postgresql/data',
229
+ ...published([['POSTGRES_PORT', 5432, 5432]]),
230
+ ' healthcheck:',
231
+ ` test: ['CMD-SHELL', 'pg_isready -U ${value('POSTGRES_USER', 'endora')} -d ${value('POSTGRES_DB', 'endora')}']`,
232
+ ' interval: 5s',
233
+ ' timeout: 5s',
234
+ ' retries: 12',
235
+ ],
236
+ },
237
+ {
238
+ name: 'redis',
239
+ host: 'backend',
240
+ dependsOn: [],
241
+ body: [
242
+ ' image: redis:7-alpine',
243
+ ' restart: unless-stopped',
244
+ " command: ['redis-server', '--appendonly', 'yes']",
245
+ ' volumes:',
246
+ ' - redis-data:/data',
247
+ ...published([['REDIS_PORT', 6379, 6379]]),
248
+ ' healthcheck:',
249
+ " test: ['CMD', 'redis-cli', 'ping']",
250
+ ' interval: 5s',
251
+ ' timeout: 3s',
252
+ ' retries: 12',
253
+ ],
254
+ },
255
+ {
256
+ name: 'meilisearch',
257
+ host: 'backend',
258
+ dependsOn: [],
259
+ body: [
260
+ ' image: getmeili/meilisearch:v1.11',
261
+ ' restart: unless-stopped',
262
+ ' environment:',
263
+ // The development default is a real key rather than a blank, and it has
264
+ // to be: `MEILI_ENV: production` is the same record's line in both
265
+ // modes, and that image refuses to start on a master key shorter than
266
+ // 16 bytes. A development machine with no search engine is a health
267
+ // route reporting the instance degraded, which is the state FR-100 is
268
+ // about.
269
+ ` MEILI_MASTER_KEY: ${value('MEILI_MASTER_KEY', 'endora-development-master-key')}`,
270
+ ' MEILI_ENV: production',
271
+ " MEILI_NO_ANALYTICS: 'true'",
272
+ ' volumes:',
273
+ ' - meilisearch-data:/meili_data',
274
+ ...published([['MEILISEARCH_PORT', 7700, 7700]]),
275
+ ' healthcheck:',
276
+ ' # 127.0.0.1 and not localhost: the image resolves localhost to ::1',
277
+ ' # as well and meilisearch binds IPv4 only, so busybox wget gives up',
278
+ ' # on the first refusal and the check never reports healthy.',
279
+ " test: ['CMD', 'wget', '--quiet', '--spider', 'http://127.0.0.1:7700/health']",
280
+ ' interval: 5s',
281
+ ' timeout: 3s',
282
+ ' retries: 12',
283
+ ],
284
+ },
285
+ // The mail catcher exists in the development rendering only, and that is
286
+ // the honest shape rather than an omission: production mail goes to a real
287
+ // SMTP relay, and a catcher there would swallow every order confirmation a
288
+ // client's customers are waiting for (FR-112). `axllent/mailpit` and not
289
+ // `mailhog/MailHog` — measured 2026-09-14 through the GitHub API, MailHog's
290
+ // last commit is 2024-02-13 and Mailpit's is 2026-09-06.
291
+ ...(mode === 'development'
292
+ ? [
293
+ {
294
+ name: 'mailpit',
295
+ host: 'backend',
296
+ dependsOn: [],
297
+ body: [
298
+ ' image: axllent/mailpit:v1.31',
299
+ ' restart: unless-stopped',
300
+ ' environment:',
301
+ // A development SMTP_URL that carries credentials is the ordinary
302
+ // case — the client is pointing the same configuration at a real
303
+ // relay tomorrow — and this catcher accepts them rather than
304
+ // refusing mail nobody will read anyway. It keeps no volume: a
305
+ // caught message is worth exactly one session.
306
+ " MP_SMTP_AUTH_ACCEPT_ANY: '1'",
307
+ " MP_SMTP_AUTH_ALLOW_INSECURE: '1'",
308
+ ...published([
309
+ ['MAILPIT_SMTP_PORT', 1025, 1025],
310
+ ['MAILPIT_UI_PORT', 8025, 8025],
311
+ ]),
312
+ ' healthcheck:',
313
+ ' # The same 127.0.0.1 rule the search engine\'s check carries, for',
314
+ ' # the same reason: busybox wget gives up on the first refusal.',
315
+ " test: ['CMD', 'wget', '--quiet', '--spider', 'http://127.0.0.1:8025/readyz']",
316
+ ' interval: 5s',
317
+ ' timeout: 3s',
318
+ ' retries: 12',
319
+ ],
320
+ },
321
+ ]
322
+ : []),
323
+ {
324
+ name: 'backend-migrate',
325
+ host: 'backend',
326
+ dependsOn: [['postgres', 'service_healthy']],
327
+ body: [
328
+ ' # One-shot: apply the migrations before the API starts, from the SAME',
329
+ ' # image the API is about to run. A migrate job running a different',
330
+ ' # build of the application is the half-built state that ordering',
331
+ ' # exists to close.',
332
+ IMAGE('backend'),
333
+ ' # `dist/migrate.js` is what the backend this instance was scaffolded',
334
+ ' # with compiles `src/migrate.ts` to.',
335
+ " command: ['node', 'dist/migrate.js']",
336
+ ' environment: *backend-env',
337
+ " restart: 'no'",
338
+ ],
339
+ },
340
+ {
341
+ name: 'backend-install',
342
+ host: 'backend',
343
+ dependsOn: [
344
+ ['postgres', 'service_healthy'],
345
+ ['redis', 'service_healthy'],
346
+ ['meilisearch', 'service_healthy'],
347
+ ['backend-migrate', 'service_completed_successfully'],
348
+ ],
349
+ body: [
350
+ ' # One-shot: run every installed module\'s install hooks once the schema',
351
+ ' # exists and before the API starts — `pnpm run module:install --all`,',
352
+ ' # compiled. A database whose first act after the migrations is a boot',
353
+ ' # never runs them. Idempotent: an installed module reports itself done,',
354
+ ' # so it runs on every start, like the migrations.',
355
+ IMAGE('backend'),
356
+ " command: ['node', 'dist/module-commands/install.js', '--all']",
357
+ ' environment: *backend-env',
358
+ " restart: 'no'",
359
+ ],
360
+ },
361
+ {
362
+ name: 'backend',
363
+ host: 'backend',
364
+ dependsOn: [
365
+ ['postgres', 'service_healthy'],
366
+ ['redis', 'service_healthy'],
367
+ ['meilisearch', 'service_healthy'],
368
+ ['backend-install', 'service_completed_successfully'],
369
+ ],
370
+ body: [
371
+ IMAGE('backend'),
372
+ ' restart: unless-stopped',
373
+ ' environment: *backend-env',
374
+ ' # Published on loopback only — your own nginx proxies the public',
375
+ ' # name here and terminates TLS. See nginx.example.conf.',
376
+ ' ports:',
377
+ " - '${API_HOST_PORT}:3001'",
378
+ ' volumes:',
379
+ ' # The local-filesystem assets adapter writes under backend/var.',
380
+ ' - backend-assets:/app/backend/var',
381
+ ' healthcheck:',
382
+ " test: ['CMD', 'node', '-e', \"fetch('http://127.0.0.1:3001/api/v1/_health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"]",
383
+ ' interval: 10s',
384
+ ' timeout: 5s',
385
+ ' retries: 12',
386
+ ' start_period: 30s',
387
+ ],
388
+ },
389
+ {
390
+ name: 'storefront',
391
+ host: 'storefront',
392
+ // A readiness convenience and not a correctness guarantee: the storefront
393
+ // starts without the backend and its first page fetch fails. That is why
394
+ // the three-host example replaces it with nothing (R3.6).
395
+ dependsOn: [['backend', 'service_healthy']],
396
+ body: [
397
+ ' # Built from the storefront repository `endora new storefront` wrote,',
398
+ ' # which is its own tree and declares no module package (D-195).',
399
+ IMAGE('storefront'),
400
+ ' restart: unless-stopped',
401
+ ' environment:',
402
+ ' NODE_ENV: production',
403
+ " PORT: '3000'",
404
+ ' HOSTNAME: 0.0.0.0',
405
+ ...storefrontBackendUrl(input.topology),
406
+ ...revalidateSecret(input.topology),
407
+ ' #',
408
+ ' # The public origin is NOT settable here. The origin this shop puts',
409
+ ' # in every canonical link, in its sitemap and in its robots.txt is',
410
+ ' # inlined by `next build` from NEXT_PUBLIC_SITE_URL, and the repair',
411
+ ' # for a wrong one is a rebuild rather than a value in this block.',
412
+ ' ports:',
413
+ " - '${STOREFRONT_HOST_PORT}:3000'",
414
+ ' healthcheck:',
415
+ " test: ['CMD', 'node', '-e', \"fetch('http://127.0.0.1:3000/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"]",
416
+ ' interval: 10s',
417
+ ' timeout: 5s',
418
+ ' retries: 12',
419
+ ' start_period: 20s',
420
+ ],
421
+ },
422
+ ];
423
+ if (input.admin) {
424
+ all.push({
425
+ name: 'admin',
426
+ host: 'admin',
427
+ // Nothing. The admin is static files behind a web server and talks to the
428
+ // API from the browser, so it has no peer to wait for — which is why a
429
+ // host of its own costs it nothing at all.
430
+ dependsOn: [],
431
+ body: [
432
+ ' # Static files behind nginx. No database, no cache, no search, and no',
433
+ ' # `depends_on`: it reaches the API from the browser, so it starts,',
434
+ ' # restarts and rolls back on a schedule of its own.',
435
+ IMAGE('admin'),
436
+ ' restart: unless-stopped',
437
+ ' ports:',
438
+ " - '${ADMIN_HOST_PORT}:80'",
439
+ ' healthcheck:',
440
+ " test: ['CMD', 'wget', '--quiet', '--spider', 'http://127.0.0.1:80/']",
441
+ ' interval: 10s',
442
+ ' timeout: 3s',
443
+ ' retries: 6',
444
+ ],
445
+ });
446
+ }
447
+ return all;
448
+ }
449
+ /** R3.4 — a cross-host URL is a real origin, never a container name. */
450
+ function storefrontBackendUrl(topology) {
451
+ if (topology === 'single-host') {
452
+ return [
453
+ ' # The server-side fetcher, over this project\'s own network. The',
454
+ ' # container name resolves because both services are in one project;',
455
+ ' # split them across hosts and this becomes a public origin.',
456
+ ' BACKEND_BASE_URL: http://backend:3001',
457
+ ];
458
+ }
459
+ return [
460
+ ' # The backend is on another machine, so this is its PUBLIC origin. A',
461
+ ' # container name resolves only on a shared Docker network and would',
462
+ ' # fail here with a name lookup that says nothing about why.',
463
+ ' # Where you have a private link between the two hosts, put that',
464
+ ' # address here instead — it is the same value, over a cheaper path.',
465
+ ' #',
466
+ ' # WHAT THE SPLIT COSTS, AND IT IS NOT IN THE DIFF: every',
467
+ ' # server-rendered page now makes a network round trip to a different',
468
+ ' # machine. On one host that fetch was a bridge hop; here it is a TLS',
469
+ ' # request over whatever is between the two, on every render.',
470
+ ' BACKEND_BASE_URL: https://${API_DOMAIN}',
471
+ ];
472
+ }
473
+ /** R3.5's second cost, stated in the file the operator edits. */
474
+ function revalidateSecret(topology) {
475
+ if (topology === 'single-host') {
476
+ return [
477
+ ' # The secret `/api/revalidate` compares the backend\'s header',
478
+ ' # against. Unset here, the endpoint answers 401 to a backend that is',
479
+ ' # configured correctly.',
480
+ ' REVALIDATE_SECRET: ${REVALIDATE_SECRET}',
481
+ ];
482
+ }
483
+ return [
484
+ ' # The secret `/api/revalidate` compares the backend\'s header against,',
485
+ ' # and it must be the SAME value the backend has.',
486
+ ' #',
487
+ ' # THE SECOND COST OF THE SPLIT: on one host this value never left a',
488
+ ' # private bridge network. Here it is a bearer secret on a public',
489
+ ' # endpoint, presented over the internet on every catalogue change.',
490
+ ' # Generate it freshly per environment and rotate it like a password.',
491
+ ' REVALIDATE_SECRET: ${REVALIDATE_SECRET}',
492
+ ];
493
+ }
494
+ /** The named volumes a set of services needs, derived from what they mount. */
495
+ function volumesFor(chosen) {
496
+ const named = new Set();
497
+ for (const service of chosen) {
498
+ for (const line of service.body) {
499
+ const match = /^ {6}- ([a-z-]+):\//.exec(line);
500
+ if (match !== null)
501
+ named.add(match[1]);
502
+ }
503
+ }
504
+ return [...named].sort();
505
+ }
506
+ /**
507
+ * One compose document over a set of services.
508
+ *
509
+ * An edge whose target is not in the same document is **dropped**, never
510
+ * translated: that is R3.6, and it is what makes the three-host files a
511
+ * derivation of the single-host one rather than a second authoring of it.
512
+ */
513
+ function composeDocument(header, chosen, declared) {
514
+ const present = new Set(chosen.map((service) => service.name));
515
+ const lines = [...header, ''];
516
+ if (chosen.some((service) => service.name === 'backend')) {
517
+ lines.push(...backendEnvironment(declared), '');
518
+ }
519
+ lines.push('services:');
520
+ for (const service of chosen) {
521
+ lines.push(` ${service.name}:`, ...service.body);
522
+ const edges = service.dependsOn.filter(([target]) => present.has(target));
523
+ if (edges.length > 0) {
524
+ lines.push(' depends_on:');
525
+ for (const [target, condition] of edges) {
526
+ lines.push(` ${target}:`, ` condition: ${condition}`);
527
+ }
528
+ }
529
+ lines.push('');
530
+ }
531
+ const volumes = volumesFor(chosen);
532
+ if (volumes.length > 0) {
533
+ lines.push('volumes:', ...volumes.map((name) => ` ${name}:`), '');
534
+ }
535
+ return `${lines.join('\n').replace(/\n+$/, '')}\n`;
536
+ }
537
+ // ── the development compose (`specs/125-first-mile-install/` §4.1) ──────────
538
+ /** Where the development compose is written, relative to the instance root. */
539
+ export const DEV_COMPOSE_PATH = 'compose.dev.yml';
540
+ /**
541
+ * The names a document expands **without** an inline default (FR-111).
542
+ *
543
+ * The guard `envExampleFor` does not give you, and the reason it is a second
544
+ * pattern rather than that function's constant is one character:
545
+ * `/\$\{([A-Z0-9_]+)(?::-[^}]*)?\}/g`'s default clause is **non-capturing**, so
546
+ * a match there says nothing about which of the two forms it was. Reusing it
547
+ * here would report every expansion as defaulted — a guard that is green on
548
+ * precisely the document it was written to refuse (spec §5.3.3).
549
+ *
550
+ * Sorted and de-duplicated, because the sentence this feeds is read by whoever
551
+ * added the record, and three occurrences of one name is one repair.
552
+ */
553
+ export function undefaultedExpansions(document) {
554
+ const names = new Set();
555
+ for (const match of document.matchAll(/\$\{([A-Z0-9_]+)(:-[^}]*)?\}/g)) {
556
+ if (match[2] === undefined)
557
+ names.add(match[1]);
558
+ }
559
+ return [...names].sort();
560
+ }
561
+ /**
562
+ * The header of the one file in a scaffolded tree that is meant to be **run**.
563
+ *
564
+ * It says the command, it says what the file is not, and it says where the
565
+ * other compose files are — because a tree holding both a production example
566
+ * and a development stack is a tree in which somebody will start the wrong one.
567
+ */
568
+ const DEVELOPMENT_HEADER = [
569
+ '# The backing services this instance needs on a DEVELOPMENT machine.',
570
+ '#',
571
+ '# docker compose -f compose.dev.yml up -d --wait',
572
+ '# docker compose -f compose.dev.yml down',
573
+ '#',
574
+ '# `--wait` blocks until every health check below passes, which is what stops',
575
+ '# `pnpm run migrate` racing a Postgres that is still initialising. Both lines',
576
+ '# are `pnpm run dev:services` and `pnpm run dev:services:down` in this',
577
+ '# repository, and this file is what they run.',
578
+ '#',
579
+ '# EVERY value below carries an inline default, so this works in a tree whose',
580
+ '# `.env` you have never opened. Set any of them in `.env` beside this file to',
581
+ '# override one — two checkouts on one machine want different host ports.',
582
+ '#',
583
+ '# IT IS NOT A DEPLOYMENT, and it is deliberately not at one of Compose\'s four',
584
+ '# default filenames: a bare `docker compose up` in this tree finds nothing.',
585
+ '# The examples for a machine you own are in `deploy/` — they pull images you',
586
+ '# have built and pushed, and they publish nothing on loopback by accident.',
587
+ '#',
588
+ '# There is no application service here. The backend, the admin and the',
589
+ '# storefront run natively from this workspace (`pnpm run start`,',
590
+ '# `pnpm run preview:admin`), against the ports published below.',
591
+ ];
592
+ /**
593
+ * `compose.dev.yml`, rendered from the catalogue in `development` mode.
594
+ *
595
+ * **Which services reach it is derived rather than listed** (FR-104): every
596
+ * service whose image is this repository's own build — `${REGISTRY_IMAGE}/…` —
597
+ * is an application service and is dropped, and everything else is a backing
598
+ * service and is kept. So a backing service the catalogue gains appears here in
599
+ * the same merge request with nothing edited, and an application service it
600
+ * gains stays out, which is the direction both rules want. A list would have
601
+ * been a second statement of the partition.
602
+ *
603
+ * `extra` exists for the guard's own proof and for nothing else: it is the only
604
+ * way to put an expansion into this document that the catalogue cannot produce,
605
+ * and a test that could not do that would be asserting the throw over a
606
+ * document that never reaches it.
607
+ */
608
+ export function developmentComposeFile(input, extra = []) {
609
+ const backing = services(input, 'development').filter((service) => !service.body.some((line) => line.includes('${REGISTRY_IMAGE}')));
610
+ const content = composeDocument([...DEVELOPMENT_HEADER, ...extra], backing, input.declared);
611
+ const blank = undefaultedExpansions(content);
612
+ if (blank.length > 0) {
613
+ throw new Error(`deploy: ${DEV_COMPOSE_PATH} expands ${blank.join(', ')} with no inline default. This ` +
614
+ 'file is the one a client starts without editing anything, so an expansion with no ' +
615
+ '`:-` default is a container that comes up wrong — or not at all — in a tree whose ' +
616
+ '`.env` has never been opened. Give the record a default, or keep the value out of ' +
617
+ 'the development rendering.');
618
+ }
619
+ return { path: DEV_COMPOSE_PATH, kind: 'derived', member: 'root', content };
620
+ }
621
+ /**
622
+ * The defaults a rendered document carries, by name.
623
+ *
624
+ * Read off the document rather than off the catalogue that wrote it, which is
625
+ * the same discipline `envExampleFor` states for the production examples:
626
+ * *"derived from the rendered document rather than listed, so the two cannot
627
+ * come apart"*. A caller therefore cannot be handed a port the file does not
628
+ * publish.
629
+ */
630
+ function inlineDefaults(document) {
631
+ const defaults = new Map();
632
+ for (const match of document.matchAll(/\$\{([A-Z0-9_]+):-([^}]*)\}/g)) {
633
+ if (!defaults.has(match[1]))
634
+ defaults.set(match[1], match[2]);
635
+ }
636
+ return defaults;
637
+ }
638
+ /**
639
+ * How a **natively running** process reaches the development stack (FR-105).
640
+ *
641
+ * One entry per input the rendered document actually answers, composed from the
642
+ * defaults that document carries — so a changed port or a changed credential
643
+ * moves the address in the same run, with no second list to edit. An entry
644
+ * whose composition names something the document does not carry is **not
645
+ * produced**: a document with no mail catcher yields no `SMTP_URL`, and nothing
646
+ * here has to know that `SMTP_URL` is the `email` module's input.
647
+ *
648
+ * That is the compose half of FR-105's intersection. The other half is the
649
+ * instance's own declaration and belongs to the caller: *"an input is derived
650
+ * only when the development compose provides a service for it **and** this
651
+ * instance declares it"*, and a caller that skipped the second test would write
652
+ * a value for a module nobody installed.
653
+ *
654
+ * `localhost` and not a container name, for the reason FR-104 gives: nothing in
655
+ * this document is an application, so every reader of these values is a process
656
+ * on the host reaching a published port.
657
+ */
658
+ export function developmentAddresses(document) {
659
+ const defaults = inlineDefaults(document);
660
+ const addresses = new Map();
661
+ /** One input, composed only if the document answers every name it needs. */
662
+ const compose = (name, needs, build) => {
663
+ if (!needs.every((key) => defaults.has(key)))
664
+ return;
665
+ addresses.set(name, build((key) => defaults.get(key)));
666
+ };
667
+ compose('DATABASE_URL', ['POSTGRES_USER', 'POSTGRES_PASSWORD', 'POSTGRES_PORT', 'POSTGRES_DB'], (of) => `postgresql://${of('POSTGRES_USER')}:${of('POSTGRES_PASSWORD')}@localhost:${of('POSTGRES_PORT')}/${of('POSTGRES_DB')}`);
668
+ compose('REDIS_URL', ['REDIS_PORT'], (of) => `redis://localhost:${of('REDIS_PORT')}`);
669
+ compose('MEILISEARCH_URL', ['MEILISEARCH_PORT'], (of) => `http://localhost:${of('MEILISEARCH_PORT')}`);
670
+ compose('MEILISEARCH_API_KEY', ['MEILI_MASTER_KEY'], (of) => of('MEILI_MASTER_KEY'));
671
+ compose('SMTP_URL', ['MAILPIT_SMTP_PORT'], (of) => `smtp://localhost:${of('MAILPIT_SMTP_PORT')}`);
672
+ return addresses;
673
+ }
674
+ /**
675
+ * Where the mail catcher's own interface is, if this document runs one.
676
+ *
677
+ * Not an environment input — nothing reads it — and therefore not in
678
+ * {@link developmentAddresses}. It is printed, because a client whose instance
679
+ * sends mail to a port has no way to learn where that mail went.
680
+ */
681
+ export function developmentMailUrl(document) {
682
+ const port = inlineDefaults(document).get('MAILPIT_UI_PORT');
683
+ return port === undefined ? undefined : `http://localhost:${port}`;
684
+ }
685
+ /**
686
+ * What a *running* stack needs, as opposed to what a *build* needs.
687
+ *
688
+ * What this list is **for**, since G3: an **example value**. The sentence
689
+ * saying what a variable decides comes from the instance's own environment
690
+ * declaration wherever one covers the name, and an entry that carries a
691
+ * `meaning` is one no declaration does — an image path, a host port, the
692
+ * database container's own credentials. That split is the whole of it: a
693
+ * declaration deliberately carries no default (`environment-inputs.md` R1.3),
694
+ * and this file's whole subject is a stack an operator can paste and start.
695
+ *
696
+ * Every entry here is an **example**, per D-215: no real domain, no real
697
+ * registry and no real secret. Which of them reach a given host's
698
+ * `.env.example` is not decided here — {@link envExampleFor} takes the ones
699
+ * that host's compose file actually expands, so a variable nothing reads is
700
+ * never handed to an operator to fill in.
701
+ */
702
+ const RUNTIME_INPUTS = [
703
+ {
704
+ name: 'REGISTRY_IMAGE',
705
+ meaning: 'The image path without the per-layer suffix. Each service appends its own name, so ' +
706
+ 'one value serves the backend, the storefront and the admin.',
707
+ example: 'registry.example.com/your-group/your-project',
708
+ },
709
+ {
710
+ name: 'IMAGE_TAG',
711
+ meaning: 'Which build to run. A deploy job normally supplies the commit it built; set a value ' +
712
+ 'here only for a manual `up`.',
713
+ example: 'latest',
714
+ },
715
+ {
716
+ name: 'API_DOMAIN',
717
+ meaning: 'The public host the backend answers on. DNS must already point at it.',
718
+ example: 'api.example.com',
719
+ },
720
+ {
721
+ name: 'STOREFRONT_DOMAIN',
722
+ meaning: 'The public host the storefront answers on.',
723
+ example: 'example.com',
724
+ },
725
+ {
726
+ name: 'ADMIN_BASE_URL',
727
+ meaning: "The admin's public origin, as the BACKEND needs to know it: `mfa` composes the links " +
728
+ 'it mails from this value. It is the backend\'s input and not the admin\'s, so it is ' +
729
+ 'here whether or not this instance ships an admin — an operator running one elsewhere ' +
730
+ 'still owes the backend its origin.',
731
+ example: 'https://admin.example.com',
732
+ },
733
+ {
734
+ name: 'CORS_ALLOWED_ORIGINS',
735
+ meaning: 'Which browser origins may call the API. Comma-separated, scheme and host, no trailing ' +
736
+ 'slash — the storefront and the admin. Wrong here, every call from both fails with a ' +
737
+ 'message that names none of this.',
738
+ example: 'https://example.com,https://admin.example.com',
739
+ },
740
+ {
741
+ name: 'API_HOST_PORT',
742
+ meaning: 'Where the backend is published on this machine. Keep the 127.0.0.1 prefix: your own ' +
743
+ 'nginx proxies the public name here, and nothing outside the machine should reach it.',
744
+ example: '127.0.0.1:3001',
745
+ },
746
+ {
747
+ name: 'STOREFRONT_HOST_PORT',
748
+ meaning: 'Where the storefront is published on this machine. Keep the 127.0.0.1 prefix, for the ' +
749
+ 'same reason the API port has one.',
750
+ example: '127.0.0.1:3000',
751
+ },
752
+ {
753
+ name: 'ADMIN_HOST_PORT',
754
+ meaning: 'Where the admin is published on this machine. Keep the 127.0.0.1 prefix: your own ' +
755
+ 'nginx proxies the public name here, and nothing outside the machine should reach it.',
756
+ example: '127.0.0.1:8080',
757
+ },
758
+ {
759
+ name: 'POSTGRES_USER',
760
+ meaning: 'The database role the backend connects as.',
761
+ example: 'endora',
762
+ },
763
+ {
764
+ name: 'POSTGRES_PASSWORD',
765
+ meaning: 'Its password. Generate one: `openssl rand -hex 32`.',
766
+ example: 'change-me-generate-one',
767
+ },
768
+ { name: 'POSTGRES_DB', meaning: 'The database name.', example: 'endora' },
769
+ {
770
+ name: 'MEILI_MASTER_KEY',
771
+ meaning: 'The search engine\'s master key. `openssl rand -base64 32`.',
772
+ example: 'change-me-generate-one',
773
+ },
774
+ {
775
+ name: 'SESSION_COOKIE_SECRET',
776
+ meaning: 'Signs the session cookie. `openssl rand -hex 32`, freshly per environment.',
777
+ example: 'change-me-generate-one',
778
+ },
779
+ {
780
+ name: 'NEWSLETTER_TOKEN_SECRET',
781
+ meaning: 'Signs newsletter confirmation and unsubscribe links. `openssl rand -base64 32`. ' +
782
+ 'Empty, the backend signs them with the session key instead.',
783
+ // Empty rather than a placeholder: empty is a working state, and a
784
+ // placeholder is a signing key every copy of this file shares.
785
+ example: '',
786
+ },
787
+ {
788
+ name: 'SETTINGS_SECRET_ENCRYPTION_KEY',
789
+ meaning: 'Encrypts the secret Settings modules store. Base64, 32 bytes.',
790
+ // Empty rather than a placeholder: `change-me-generate-one` decodes to 16
791
+ // bytes, which the backend boots on silently and then refuses at the first
792
+ // save of a secret setting. Empty at least logs a warning at boot.
793
+ example: '',
794
+ whenEmpty: 'Generate one with `openssl rand -base64 32`. Left empty, the backend boots with a ' +
795
+ 'warning and secret settings stay unavailable until it is set.',
796
+ },
797
+ {
798
+ name: 'MFA_SECRET_ENCRYPTION_KEY',
799
+ meaning: 'Encrypts stored MFA secrets. `openssl rand -base64 32`. Empty, no second factor ' +
800
+ 'can be enrolled.',
801
+ // Empty rather than a placeholder: `change-me-generate-one` decodes to 16
802
+ // bytes, and `mfa` refuses to boot on a key that is not 32.
803
+ example: '',
804
+ },
805
+ {
806
+ name: 'ASSETS_LIBRARY_HMAC_KEY',
807
+ meaning: 'Signs asset URLs. `openssl rand -hex 32`.',
808
+ example: 'change-me-generate-one',
809
+ },
810
+ {
811
+ name: 'REVALIDATE_SECRET',
812
+ meaning: 'The shared secret the backend presents to the storefront after a content write, and ' +
813
+ 'the one the storefront compares it against. The SAME value on both. Empty, and the ' +
814
+ 'revalidator is a silent no-op: a catalogue change does not appear until the fetch ' +
815
+ 'cache expires on its own.',
816
+ example: 'change-me-generate-one',
817
+ },
818
+ {
819
+ name: 'TRUSTED_PROXY_HOPS',
820
+ meaning: 'How many proxies sit in front of the backend. One nginx is 1; a CDN in front of it ' +
821
+ 'makes it 2. Unset, the backend believes no forwarded address, which collapses the ' +
822
+ 'per-IP rate limit into one bucket for the whole internet.',
823
+ example: '1',
824
+ },
825
+ {
826
+ name: 'TRUSTED_PROXY_ADDRESSES',
827
+ meaning: 'The alternative to the hop count, for a proxy whose address is fixed: IPs, CIDR ' +
828
+ 'ranges, or loopback / linklocal / uniquelocal. Set ONE of the two — the backend ' +
829
+ 'refuses to boot with both, and there is deliberately no "trust everything" value.',
830
+ example: '',
831
+ },
832
+ {
833
+ name: 'DEFAULT_SALES_CHANNEL_CODE',
834
+ meaning: 'The channel content resolves against when the request names none.',
835
+ example: 'default',
836
+ },
837
+ {
838
+ name: 'SALES_CHANNEL_HOST_MAP',
839
+ meaning: 'host=channelCode pairs, comma-separated. Empty disables host resolution.',
840
+ example: '',
841
+ },
842
+ {
843
+ name: 'SMTP_URL',
844
+ meaning: 'Where mail goes. Empty falls back to a console mailer that delivers nothing.',
845
+ example: '',
846
+ },
847
+ { name: 'SMTP_FROM', meaning: 'The From address on that mail.', example: 'no-reply@example.com' },
848
+ { name: 'LOG_LEVEL', meaning: 'How much the backend says.', example: 'info' },
849
+ ];
850
+ /**
851
+ * The `.env.example` for one compose file — exactly the variables it expands.
852
+ *
853
+ * Derived from the rendered document rather than listed, so the two cannot come
854
+ * apart: a variable the compose file expands and this file does not declare is
855
+ * an operator finding a blank at run time, and a variable declared here that
856
+ * nothing reads is a value nobody can act on. A `${NAME}` with no entry in
857
+ * {@link RUNTIME_INPUTS} is a programming error and says so rather than being
858
+ * written out with no explanation.
859
+ */
860
+ function envExampleFor(header, compose, declared) {
861
+ const referenced = new Set([...compose.matchAll(/\$\{([A-Z0-9_]+)(?::-[^}]*)?\}/g)].map((match) => match[1]));
862
+ const describes = new Map(declared.map((input) => [input.name, input]));
863
+ const lines = [...header];
864
+ for (const input of RUNTIME_INPUTS) {
865
+ if (!referenced.has(input.name))
866
+ continue;
867
+ referenced.delete(input.name);
868
+ // The declaration wins wherever one covers the name — see
869
+ // {@link RuntimeInput.meaning} for why the fallback below still exists.
870
+ const declaration = describes.get(input.name);
871
+ const sentence = declaration === undefined ? input.meaning : declarationSentence(declaration);
872
+ lines.push('', ...wrapComment(sentence), ...(input.whenEmpty === undefined ? [] : wrapComment(input.whenEmpty)), `${input.name}=${input.example}`);
873
+ }
874
+ // A variable only the declaration covers. It is rendered rather than refused
875
+ // because the declaration is the more authoritative of the two sources: an
876
+ // input a module started reading arrives here with its own sentence and needs
877
+ // no entry written beside it. It gets no example, because a declaration
878
+ // deliberately carries no default (`environment-inputs.md` R1.3) and one
879
+ // invented here would be the seventieth home of a value nobody reviewed.
880
+ for (const input of declared) {
881
+ if (!referenced.has(input.name))
882
+ continue;
883
+ referenced.delete(input.name);
884
+ lines.push('', ...wrapComment(declarationSentence(input)), `${input.name}=`);
885
+ }
886
+ if (referenced.size > 0) {
887
+ throw new Error(`deploy: the compose example expands ${[...referenced].sort().join(', ')}, which ` +
888
+ 'neither RUNTIME_INPUTS nor this instance\'s environment declaration describes. An ' +
889
+ 'operator would be handed a stack with a blank where a value belongs and no sentence ' +
890
+ 'saying what it decides.');
891
+ }
892
+ return `${lines.join('\n')}\n`;
893
+ }
894
+ /**
895
+ * One declared input's sentence, as the operator reads it in a `.env.example`.
896
+ *
897
+ * The declaration's own words in both halves — what it decides, and what leaving
898
+ * it unset costs — because a rewrite here would be a second statement of the
899
+ * author's fact with nothing reconciling the two.
900
+ */
901
+ function declarationSentence(input) {
902
+ if (input.requirement.kind === 'optional') {
903
+ return `${input.describes.en} Without it, ${input.requirement.without.en}`;
904
+ }
905
+ if (input.requirement.kind === 'requiredWhen') {
906
+ return (`${input.describes.en} Required when ` +
907
+ `${input.requirement.input}=${input.requirement.equals}.`);
908
+ }
909
+ return `${input.describes.en} Required.`;
910
+ }
911
+ /** One sentence, wrapped to a width a terminal shows whole. */
912
+ function wrapComment(text, prefix = '# ') {
913
+ const out = [];
914
+ let line = '';
915
+ for (const word of text.split(' ')) {
916
+ if (line.length > 0 && `${line} ${word}`.length > 84) {
917
+ out.push(`${prefix}${line}`);
918
+ line = word;
919
+ continue;
920
+ }
921
+ line = line.length === 0 ? word : `${line} ${word}`;
922
+ }
923
+ if (line.length > 0)
924
+ out.push(`${prefix}${line}`);
925
+ return out;
926
+ }
927
+ // ── the nginx example ──────────────────────────────────────────────────────
928
+ /** One `server` block, per public origin this instance actually has. */
929
+ function nginxBlock(title, domain, variable, port, extra) {
930
+ return [
931
+ `# --- ${title} ${'-'.repeat(Math.max(0, 60 - title.length))}`,
932
+ 'server {',
933
+ ' listen 80;',
934
+ ' listen [::]:80;',
935
+ ` server_name ${domain};${' '.repeat(Math.max(1, 28 - domain.length))}# <- ${variable}`,
936
+ '',
937
+ ...extra,
938
+ ' location / {',
939
+ ` proxy_pass http://${port};`,
940
+ ' proxy_http_version 1.1;',
941
+ ' proxy_set_header Host $host;',
942
+ ' proxy_set_header X-Real-IP $remote_addr;',
943
+ ' proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;',
944
+ ' proxy_set_header X-Forwarded-Proto $scheme;',
945
+ ' proxy_set_header X-Forwarded-Host $host;',
946
+ ' proxy_set_header Upgrade $http_upgrade;',
947
+ ' proxy_set_header Connection $connection_upgrade;',
948
+ ' proxy_read_timeout 120s;',
949
+ ' }',
950
+ '}',
951
+ '',
952
+ ];
953
+ }
954
+ function nginxExample(input) {
955
+ return `${[
956
+ '# An EXAMPLE reverse-proxy configuration for the nginx already on this host.',
957
+ '#',
958
+ '# The compose stack publishes each layer on a loopback port; this file',
959
+ '# terminates TLS and proxies each public name to the matching local port. It',
960
+ '# is not applied by anything: copy it, put your own names in, and let certbot',
961
+ '# rewrite each block in place.',
962
+ '#',
963
+ '# sudo cp nginx.example.conf /etc/nginx/sites-available/endora',
964
+ '# sudo ln -s /etc/nginx/sites-available/endora /etc/nginx/sites-enabled/endora',
965
+ '# sudo nginx -t && sudo systemctl reload nginx',
966
+ '# sudo certbot --nginx -d example.com -d api.example.com',
967
+ '#',
968
+ '# The blocks start as plain :80 so certbot has something to attach to. If you',
969
+ '# changed the host ports in .env, change the proxy_pass targets to match.',
970
+ '',
971
+ '# Next.js and the API\'s event streams both need the Connection/Upgrade dance.',
972
+ 'map $http_upgrade $connection_upgrade {',
973
+ ' default upgrade;',
974
+ " '' close;",
975
+ '}',
976
+ '',
977
+ ...nginxBlock('Storefront', 'example.com', 'STOREFRONT_DOMAIN', '127.0.0.1:3000', [
978
+ ' # Room for the SSR responses of a large catalogue page.',
979
+ ' client_max_body_size 25m;',
980
+ '',
981
+ ]),
982
+ ...(input.admin
983
+ ? nginxBlock('Admin', 'admin.example.com', 'ADMIN_DOMAIN', '127.0.0.1:8080', [])
984
+ : [
985
+ '# There is deliberately no admin block: this instance was scaffolded',
986
+ '# without the admin member, so there is no admin artefact to proxy and no',
987
+ '# third public name. The backend still needs to be told an admin origin if',
988
+ '# you run one elsewhere — see ADMIN_BASE_URL in .env.example.',
989
+ '',
990
+ ]),
991
+ ...nginxBlock('Backend API', 'api.example.com', 'API_DOMAIN', '127.0.0.1:3001', [
992
+ ' # Asset uploads and bulk imports go straight to the API.',
993
+ ' client_max_body_size 100m;',
994
+ '',
995
+ ]),
996
+ ]
997
+ .join('\n')
998
+ .replace(/\n+$/, '')}\n`;
999
+ }
1000
+ // ── the image examples ─────────────────────────────────────────────────────
1001
+ /**
1002
+ * The base image tag, derived from the `engines.node` the instance declares.
1003
+ *
1004
+ * R2.5a: a version this command chose would be a value nobody reviewed. The
1005
+ * major is what the range actually constrains, and a range with no digit in it
1006
+ * is not something a resolved manifest produces — the running interpreter's own
1007
+ * major is the fallback rather than a literal written here.
1008
+ */
1009
+ export function nodeImage(enginesNode) {
1010
+ const major = /(\d+)/.exec(enginesNode)?.[1] ?? process.versions.node.split('.')[0];
1011
+ return `node:${major}-slim`;
1012
+ }
1013
+ /** `corepack`, activating the package manager the root manifest pins. */
1014
+ export function corepack(packageManager) {
1015
+ return packageManager === undefined
1016
+ ? [
1017
+ '# Your root manifest pins no `packageManager`, so corepack takes its own',
1018
+ '# default. Pin one and this line names it instead.',
1019
+ 'RUN corepack enable',
1020
+ ]
1021
+ : [`RUN corepack enable && corepack prepare ${packageManager} --activate`];
1022
+ }
1023
+ /** The workspace manifests an install needs, which is one per written member. */
1024
+ function memberManifests(input) {
1025
+ return [
1026
+ '# The lockfile is one of these on purpose: `--frozen-lockfile` below refuses',
1027
+ '# to resolve a range, so an image is built from the versions you committed and',
1028
+ '# never from whatever the registry had that morning. Commit it.',
1029
+ 'COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./',
1030
+ ...(input.npmrc ? ['COPY .npmrc ./'] : []),
1031
+ 'COPY backend/package.json backend/',
1032
+ ...(input.admin ? ['COPY admin/package.json admin/'] : []),
1033
+ ...(input.docs ? ['COPY docs/package.json docs/'] : []),
1034
+ ];
1035
+ }
1036
+ /** The `docker build` line, with every `--build-arg` the declaration emits. */
1037
+ export function buildInvocation(target, path) {
1038
+ const flags = buildArgFlags(target);
1039
+ return [
1040
+ `# docker build -f ${path} \\`,
1041
+ ...flags.map((flag) => `# ${flag} \\`),
1042
+ `# -t \${REGISTRY_IMAGE}/${target}:\${IMAGE_TAG} .`,
1043
+ ];
1044
+ }
1045
+ /** The `ARG`/`ENV` pair per input this target's build reads. */
1046
+ export function argDeclarations(target) {
1047
+ return buildInputsFor(target).flatMap(({ input, consumer }) => [
1048
+ ...wrapComment(input.meaning),
1049
+ `ARG ${consumer.buildArg}`,
1050
+ `ENV ${consumer.buildArg}=$${consumer.buildArg}`,
1051
+ ]);
1052
+ }
1053
+ function backendDockerfile(input) {
1054
+ return `${[
1055
+ '# syntax=docker/dockerfile:1.7',
1056
+ '# An EXAMPLE image for this instance\'s backend: the API and, by default, the',
1057
+ '# queue consumers beside it. Copy it, change what you need, own it.',
1058
+ '#',
1059
+ '# Build from the root of this repository, so the workspace is visible:',
1060
+ ...buildInvocation('backend', 'deploy/Dockerfile.backend'),
1061
+ '#',
1062
+ '# The backend runs BUILT output. `start`, `migrate` and every `module:*`',
1063
+ '# command name `dist/`, so the compile below is not an optimisation.',
1064
+ '',
1065
+ `FROM ${nodeImage(input.enginesNode)} AS base`,
1066
+ 'ENV PNPM_HOME=/pnpm',
1067
+ 'ENV PATH="$PNPM_HOME:$PATH"',
1068
+ '# argon2 ships a native addon; these cover a target with no prebuilt binary.',
1069
+ 'RUN apt-get update \\',
1070
+ ' && apt-get install -y --no-install-recommends python3 make g++ ca-certificates \\',
1071
+ ' && rm -rf /var/lib/apt/lists/*',
1072
+ ...corepack(input.packageManager),
1073
+ 'WORKDIR /app',
1074
+ '',
1075
+ '# --- dependencies: the manifests first, so a source edit does not reinstall ---',
1076
+ ...memberManifests(input),
1077
+ 'RUN pnpm install --frozen-lockfile',
1078
+ '',
1079
+ '# --- the application ---',
1080
+ 'COPY . .',
1081
+ '# The layer\'s own build command, and not a second spelling of it. Change what',
1082
+ '# `build:backend` runs and this image follows with no edit here.',
1083
+ 'RUN pnpm run build:backend',
1084
+ '',
1085
+ ...argDeclarations('backend'),
1086
+ '',
1087
+ 'ENV NODE_ENV=production',
1088
+ 'ENV PORT=3001',
1089
+ 'WORKDIR /app/backend',
1090
+ 'EXPOSE 3001',
1091
+ '',
1092
+ '# The compose example overrides this for the one-shot migrate and install jobs.',
1093
+ 'CMD ["node", "dist/index.js"]',
1094
+ ].join('\n')}\n`;
1095
+ }
1096
+ function adminDockerfile(input) {
1097
+ return `${[
1098
+ '# syntax=docker/dockerfile:1.7',
1099
+ '# An EXAMPLE image for this instance\'s admin: a Vite build, served as static',
1100
+ '# files by nginx. It reaches the API from the browser and has no runtime',
1101
+ '# dependency on this tree, on Node, on the database, on Redis or on search —',
1102
+ '# which is what lets it live on a host of its own.',
1103
+ '#',
1104
+ '# Build from the root of this repository:',
1105
+ ...buildInvocation('admin', 'deploy/Dockerfile.admin'),
1106
+ '#',
1107
+ '# THE BUNDLE IS BOUND TO ONE API ORIGIN AT BUILD TIME. Vite inlines the value',
1108
+ '# below into the JavaScript, so one bundle serves one backend: promoting this',
1109
+ '# image from staging to production is a REBUILD, not a redeploy.',
1110
+ '#',
1111
+ '# The build needs this installed workspace and cannot be done without one: the',
1112
+ '# admin\'s own build runs `endora generate` first, which renders its screen',
1113
+ '# registry over the module packages THIS instance installed. That dependency',
1114
+ '# is what keeps one module list governing all three deployables.',
1115
+ '',
1116
+ `FROM ${nodeImage(input.enginesNode)} AS build`,
1117
+ 'ENV PNPM_HOME=/pnpm',
1118
+ 'ENV PATH="$PNPM_HOME:$PATH"',
1119
+ ...corepack(input.packageManager),
1120
+ 'WORKDIR /app',
1121
+ '',
1122
+ ...memberManifests(input),
1123
+ 'RUN pnpm install --frozen-lockfile',
1124
+ '',
1125
+ 'COPY . .',
1126
+ '',
1127
+ ...argDeclarations('admin'),
1128
+ '# The layer\'s own build command. It runs `endora generate` first, so the',
1129
+ '# registry the bundle is built from is this install\'s.',
1130
+ 'RUN pnpm run build:admin',
1131
+ '',
1132
+ '# --- runtime: static files, and nothing else ---',
1133
+ 'FROM nginx:1.27-alpine AS run',
1134
+ '# A single-page application needs every unknown path to reach index.html, or a',
1135
+ '# reload of any screen but the first answers 404.',
1136
+ "RUN printf 'server {\\n listen 80;\\n root /usr/share/nginx/html;\\n location / { try_files $uri $uri/ /index.html; }\\n}\\n' \\",
1137
+ ' > /etc/nginx/conf.d/default.conf',
1138
+ 'COPY --from=build /app/admin/dist /usr/share/nginx/html',
1139
+ 'EXPOSE 80',
1140
+ ].join('\n')}\n`;
1141
+ }
1142
+ // ── the README ─────────────────────────────────────────────────────────────
1143
+ function deployReadme(input, written) {
1144
+ const files = written.filter((path) => path !== 'README.md');
1145
+ const singleHost = input.topology === 'single-host';
1146
+ return `${[
1147
+ '# Deploying this instance',
1148
+ '',
1149
+ 'Everything in this directory is an **example**. None of it is applied by anything, none',
1150
+ 'of it is read back by any command, and no value in it was chosen by anybody but you: the',
1151
+ 'domains are `example.com`, the secrets say `change-me`, and the registry is nowhere. Copy',
1152
+ 'what you need onto the machines you run, change it, and own it from then on.',
1153
+ '',
1154
+ `These files were written for the **${input.topology}** topology, which was a flag on the`,
1155
+ 'command that scaffolded this tree. The choice is recorded in no file and read by nothing —',
1156
+ 'a machine layout is a fact about your machines, and the one thing this repository states',
1157
+ 'about itself is the module list in the root `package.json`. Scaffold again with the other',
1158
+ 'topology if you want to see its examples; nothing here has to be undone first.',
1159
+ '',
1160
+ '## What is here',
1161
+ '',
1162
+ '| File | What it is |',
1163
+ '| --- | --- |',
1164
+ ...files.map((path) => `| \`${path}\` | ${describeFile(path)} |`),
1165
+ '',
1166
+ '## Bringing it up',
1167
+ '',
1168
+ ...(singleHost
1169
+ ? [
1170
+ 'One machine, one command:',
1171
+ '',
1172
+ '```',
1173
+ 'docker compose --env-file .env -f compose.prod.yml up -d',
1174
+ '```',
1175
+ '',
1176
+ 'The migration job runs first, then the install job (`module:install --all`, a no-op',
1177
+ 'for a module already installed), and the API waits for both to exit 0, so a schema',
1178
+ 'change and a new module\'s install hooks are applied before anything serves a request.',
1179
+ 'Point your host nginx at the loopback ports (see `nginx.example.conf`) and let certbot',
1180
+ 'handle TLS.',
1181
+ ]
1182
+ : [
1183
+ 'Three machines, in this order. The order matters once, on a first bring-up: the',
1184
+ 'storefront\'s first page fetch and the admin\'s first API call both need a backend',
1185
+ 'that has migrated.',
1186
+ '',
1187
+ '1. **The backend host** — it owns every stateful service, the migration and install',
1188
+ ' jobs and the API:',
1189
+ '',
1190
+ ' ```',
1191
+ ' docker compose --env-file .env -f three-host/compose.backend.yml up -d',
1192
+ ' ```',
1193
+ '',
1194
+ '2. **The storefront host**:',
1195
+ '',
1196
+ ' ```',
1197
+ ' docker compose --env-file .env -f three-host/compose.storefront.yml up -d',
1198
+ ' ```',
1199
+ '',
1200
+ '3. **The admin host** — static files; it waits for nothing and can go up at any',
1201
+ ' point:',
1202
+ '',
1203
+ ' ```',
1204
+ ' docker compose --env-file .env -f three-host/compose.admin.yml up -d',
1205
+ ' ```',
1206
+ '',
1207
+ '**The three `.env` files are not interchangeable.** Each carries exactly the values',
1208
+ 'its own compose file reads and nothing else — the backend\'s holds the database',
1209
+ 'password and every encryption key, the storefront\'s holds one shared secret and two',
1210
+ 'origins, the admin\'s holds an image tag and a port. Copying one onto another host',
1211
+ 'either hands that host secrets it has no use for or leaves it starting with blanks.',
1212
+ '',
1213
+ '## What the split costs, and neither is in the diff',
1214
+ '',
1215
+ 'Both are written into the files themselves, so an operator editing one meets them:',
1216
+ '',
1217
+ '1. **Every server-rendered storefront page gains a network round trip.** On one host',
1218
+ ' the SSR fetch was a bridge hop; here it is a TLS request to another machine, on',
1219
+ ' every render.',
1220
+ '2. **`REVALIDATE_SECRET` becomes a bearer secret on a public endpoint.** It never',
1221
+ ' left a private network before; now it crosses whatever is between the two hosts',
1222
+ ' on every catalogue change.',
1223
+ '',
1224
+ '## What replaces `depends_on` across hosts: nothing',
1225
+ '',
1226
+ 'On one host the storefront waits for the backend to report healthy. That was a',
1227
+ 'readiness convenience and never a correctness guarantee — the storefront starts fine',
1228
+ 'without the backend and its first page fetch fails. Across hosts it is dropped and',
1229
+ '**not** replaced by a wait-for-it script, an init container or an orchestration',
1230
+ 'dependency. Each layer starts, answers its own health check and tolerates an absent',
1231
+ 'peer. The one edge whose loss would be a correctness bug — the API waiting for the',
1232
+ 'migration job — is inside the backend host and is untouched.',
1233
+ ]),
1234
+ '',
1235
+ '## Building the images',
1236
+ '',
1237
+ 'The Dockerfiles here build from the **root of this repository**, because the admin bundle',
1238
+ 'is rendered over the module packages this instance installed and cannot be built without',
1239
+ 'the install. That is not a limitation to work around: it is what keeps one module list',
1240
+ 'governing every artefact you deploy.',
1241
+ '',
1242
+ ...(input.admin
1243
+ ? [
1244
+ 'One more consequence of it, stated because it surprises people: the admin bundle has',
1245
+ 'its API origin inlined at build time. One bundle serves one backend, and moving an',
1246
+ 'admin image from staging to production is a rebuild rather than a redeploy.',
1247
+ '',
1248
+ ]
1249
+ : []),
1250
+ 'The commands are in each file\'s own header, with every `--build-arg` the build reads.',
1251
+ ]
1252
+ .join('\n')
1253
+ .replace(/\n+$/, '')}\n`;
1254
+ }
1255
+ function describeFile(path) {
1256
+ if (path === 'compose.prod.yml') {
1257
+ return 'every service on one machine: the database, the cache, the search engine, the migration job, the API, the storefront and the admin';
1258
+ }
1259
+ if (path === '.env.example')
1260
+ return 'what that stack reads on every start. Copy to `.env` beside it; `.env` is git-ignored';
1261
+ if (path === 'nginx.example.conf')
1262
+ return 'a reverse-proxy block per public name, for the nginx already on the host';
1263
+ if (path === 'three-host/compose.backend.yml') {
1264
+ return 'the backend machine: the database, the cache, the search engine, the migration job and the API. Every stateful service is here';
1265
+ }
1266
+ if (path === 'three-host/compose.storefront.yml')
1267
+ return 'the storefront machine: one service, reaching the API over its public origin';
1268
+ if (path === 'three-host/compose.admin.yml')
1269
+ return 'the admin machine: static files behind nginx, with no peer to wait for';
1270
+ if (path.startsWith('three-host/.env.')) {
1271
+ const host = path.slice('three-host/.env.'.length).replace('.example', '');
1272
+ return `what the ${host} machine reads. Copy to \`.env\` on **that** machine and no other`;
1273
+ }
1274
+ if (path === 'Dockerfile.backend')
1275
+ return 'an example image for the API and its queue consumers';
1276
+ if (path === 'Dockerfile.admin')
1277
+ return 'an example image for the admin bundle, served by nginx';
1278
+ return 'an example';
1279
+ }
1280
+ // ── the plan ───────────────────────────────────────────────────────────────
1281
+ const SINGLE_HOST_HEADER = [
1282
+ '# An EXAMPLE production stack for ONE machine.',
1283
+ '#',
1284
+ '# It pulls images by tag and wires them together; it builds nothing and it',
1285
+ '# terminates no TLS. Your own nginx does that — see nginx.example.conf — and',
1286
+ '# every service below is published on a loopback port for it to reach.',
1287
+ '#',
1288
+ '# docker compose --env-file .env -f compose.prod.yml up -d',
1289
+ '#',
1290
+ '# `.env` sits beside this file, is git-ignored, and is yours. Nothing here was',
1291
+ '# chosen by anybody else: see .env.example for what each value decides.',
1292
+ ];
1293
+ function threeHostHeader(host, extra) {
1294
+ return [
1295
+ `# An EXAMPLE production stack for the ${host.toUpperCase()} machine, one of three.`,
1296
+ '#',
1297
+ `# docker compose --env-file .env -f compose.${host}.yml up -d`,
1298
+ '#',
1299
+ `# The \`.env\` this reads is \`.env.${host}.example\`, copied onto THAT machine.`,
1300
+ '# The three are not interchangeable: each carries exactly what its own file',
1301
+ '# reads, so copying one onto another host either hands it secrets it has no',
1302
+ '# use for or starts it with blanks. See README.md for the order.',
1303
+ ...extra,
1304
+ ];
1305
+ }
1306
+ /**
1307
+ * Every `deploy/` file this run writes.
1308
+ *
1309
+ * They are the **client's** kind: rendered once, edited by them, and read back
1310
+ * by nothing here. A file this command would read again would be a second home
1311
+ * for a fact the manifest already holds.
1312
+ */
1313
+ export function deployFiles(input) {
1314
+ const all = services(input);
1315
+ const files = [];
1316
+ const write = (path, content) => {
1317
+ files.push({ path: `deploy/${path}`, kind: 'client', member: 'root', content });
1318
+ };
1319
+ if (input.topology === 'single-host') {
1320
+ const compose = composeDocument(SINGLE_HOST_HEADER, all, input.declared);
1321
+ write('compose.prod.yml', compose);
1322
+ write('.env.example', envExampleFor([
1323
+ '# What this stack reads on every start. Copy to `.env` beside this file and fill it',
1324
+ '# in; `.env` is git-ignored and nothing here is a value anybody but you chose.',
1325
+ '#',
1326
+ '# These are the values THIS compose file expands, for a stack on a host. The',
1327
+ '# `.env.example` at the root of this repository is the one a development machine',
1328
+ '# reads and it is a different set: it names the database and the cache directly,',
1329
+ '# where the services below are named by the compose network instead.',
1330
+ ], compose, input.declared));
1331
+ write('nginx.example.conf', nginxExample(input));
1332
+ }
1333
+ else {
1334
+ const hosts = input.admin
1335
+ ? ['backend', 'storefront', 'admin']
1336
+ : ['backend', 'storefront'];
1337
+ for (const host of hosts) {
1338
+ const chosen = all.filter((service) => service.host === host);
1339
+ const compose = composeDocument(threeHostHeader(host, extraHeaderFor(host)), chosen, input.declared);
1340
+ write(`three-host/compose.${host}.yml`, compose);
1341
+ write(`three-host/.env.${host}.example`, envExampleFor([
1342
+ `# What the ${host.toUpperCase()} machine reads on every start. Copy to \`.env\` on`,
1343
+ '# THAT machine and on no other — this file holds exactly the values',
1344
+ `# \`compose.${host}.yml\` expands, and the other two hosts hold theirs.`,
1345
+ ], compose, input.declared));
1346
+ }
1347
+ }
1348
+ write('Dockerfile.backend', backendDockerfile(input));
1349
+ if (input.admin)
1350
+ write('Dockerfile.admin', adminDockerfile(input));
1351
+ write('README.md', deployReadme(input, files.map((file) => file.path.slice('deploy/'.length))));
1352
+ return files;
1353
+ }
1354
+ /** The one sentence each host's header owes beyond the shared four. */
1355
+ function extraHeaderFor(host) {
1356
+ if (host === 'backend') {
1357
+ return [
1358
+ '#',
1359
+ '# Every stateful service is here — the database, the cache and the search',
1360
+ '# engine — along with the migration job. Putting one of them on another',
1361
+ '# machine is a layout these examples have not measured.',
1362
+ ];
1363
+ }
1364
+ if (host === 'storefront') {
1365
+ return [
1366
+ '#',
1367
+ '# One service, reaching the backend over its PUBLIC origin. It waits for',
1368
+ '# nothing: it starts without the backend and its first page fetch fails,',
1369
+ '# which is what the single-host `depends_on` bought and what is deliberately',
1370
+ '# not replaced here by a wait-for-it script or an init container.',
1371
+ ];
1372
+ }
1373
+ return [
1374
+ '#',
1375
+ '# Static files behind nginx. No database, no cache, no search, no Node and no',
1376
+ '# `depends_on` — it talks to the API from the browser. It is the cheapest of',
1377
+ '# the three to move, and the one whose bundle is bound to one API origin at',
1378
+ '# build time: promoting it between environments is a rebuild.',
1379
+ ];
1380
+ }
1381
+ //# sourceMappingURL=deploy.js.map