@starci/hfs 4.3.0 → 4.4.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 (515) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/db/migrate.mjs +361 -0
  3. package/db/post-cutover.mjs +136 -0
  4. package/emit/db-types.mjs +10 -1
  5. package/lint/run.mjs +1 -1
  6. package/package.json +4 -1
  7. package/release/gate.mjs +298 -0
  8. package/release/index.mjs +103 -0
  9. package/release/push.mjs +110 -0
  10. package/runtime/knowledge/hfs/canon-pins.yaml +11 -11
  11. package/runtime/knowledge/hfs/fe-stack.yaml +22 -1
  12. package/runtime/knowledge/hfs/rules.yaml +3401 -152
  13. package/runtime/knowledge/hfs/slots.yaml +344 -62
  14. package/runtime/knowledge/patterns/be/api.yaml +32 -5
  15. package/runtime/knowledge/patterns/be/cli.yaml +14 -4
  16. package/runtime/knowledge/patterns/be/event-bus.yaml +3 -3
  17. package/runtime/knowledge/patterns/be/jobs.yaml +20 -2
  18. package/runtime/knowledge/patterns/be/projections.yaml +4 -2
  19. package/runtime/knowledge/patterns/be/queues.yaml +11 -3
  20. package/runtime/knowledge/patterns/be/reactors.yaml +20 -1
  21. package/runtime/knowledge/patterns/be/realtime.yaml +25 -3
  22. package/runtime/knowledge/patterns/be/saga.yaml +20 -3
  23. package/runtime/knowledge/patterns/be/webhooks.yaml +23 -4
  24. package/runtime/knowledge/patterns/fe/folder.yaml +1 -0
  25. package/runtime/knowledge/sonar-gate.yaml +1 -1
  26. package/runtime/modules/kernel/failure-codes.yaml +3113 -43
  27. package/runtime/modules/models/runtimes.yaml +1 -1
  28. package/runtime/modules/schemas/application-stacks.schema.yaml +663 -0
  29. package/runtime/packages/cli/src/catalog.generated.mjs +14104 -0
  30. package/runtime/packages/cli/src/validate-args.mjs +213 -0
  31. package/runtime/scripts/api/git/add.mjs +5 -0
  32. package/runtime/scripts/api/git/init.mjs +7 -0
  33. package/runtime/scripts/api/git/lib.mjs +3 -0
  34. package/runtime/scripts/api/git/tag.mjs +5 -0
  35. package/runtime/scripts/api/node/lib.mjs +20 -0
  36. package/runtime/scripts/api/node/run-node.mjs +6 -0
  37. package/runtime/scripts/api/sops/lib.mjs +41 -0
  38. package/runtime/scripts/api/sops/selected-age-recipient.mjs +7 -0
  39. package/runtime/scripts/api/supabase/database-dump.mjs +5 -0
  40. package/runtime/scripts/api/supabase/database-query.mjs +5 -0
  41. package/runtime/scripts/api/supabase/lib.mjs +44 -0
  42. package/runtime/scripts/api/supabase/migration-repair.mjs +5 -0
  43. package/runtime/scripts/hfs/app-wiring.mjs +201 -0
  44. package/runtime/scripts/hfs/architecture/backend-app-files.mjs +1 -8
  45. package/runtime/scripts/hfs/architecture/backend.mjs +2 -7
  46. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +6 -3
  47. package/runtime/scripts/hfs/architecture/dead-exports-bindings.mjs +149 -0
  48. package/runtime/scripts/hfs/architecture/dead-exports.mjs +9 -41
  49. package/runtime/scripts/hfs/architecture/default-deny.mjs +8 -6
  50. package/runtime/scripts/hfs/architecture/frontend-world.mjs +1 -1
  51. package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +153 -0
  52. package/runtime/scripts/hfs/architecture/hfs.mjs +3 -3
  53. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +28 -13
  54. package/runtime/scripts/hfs/architecture/next-data-inference.mjs +176 -0
  55. package/runtime/scripts/hfs/architecture/next-data-symbols.mjs +26 -0
  56. package/runtime/scripts/hfs/architecture/next-data.mjs +31 -23
  57. package/runtime/scripts/hfs/architecture/reachability.mjs +14 -0
  58. package/runtime/scripts/hfs/architecture/register-once.mjs +2 -2
  59. package/runtime/scripts/hfs/architecture/source-names.mjs +48 -3
  60. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +211 -11
  61. package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -1
  62. package/runtime/scripts/hfs/architecture/supabase.mjs +59 -21
  63. package/runtime/scripts/hfs/architecture/surface.mjs +2 -1
  64. package/runtime/scripts/hfs/architecture/tiers.mjs +3 -3
  65. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +8 -2
  66. package/runtime/scripts/hfs/check-results.mjs +28 -0
  67. package/runtime/scripts/hfs/check.mjs +46 -47
  68. package/runtime/scripts/hfs/coverage-from.mjs +70 -0
  69. package/runtime/scripts/hfs/db-baseline-verb.mjs +276 -0
  70. package/runtime/scripts/hfs/db-plan-verb.mjs +254 -0
  71. package/runtime/scripts/hfs/db-restamp-verb.mjs +60 -0
  72. package/runtime/scripts/hfs/declaration-shape.mjs +39 -3
  73. package/runtime/scripts/hfs/doc-commands.mjs +367 -0
  74. package/runtime/scripts/hfs/edition-slots.mjs +2 -2
  75. package/runtime/scripts/hfs/example-secrets.mjs +158 -0
  76. package/runtime/scripts/hfs/manifest-shape.mjs +67 -7
  77. package/runtime/scripts/hfs/refusal-codes.mjs +6 -0
  78. package/runtime/scripts/hfs/rule-catalog.mjs +156 -11
  79. package/runtime/scripts/hfs/rule-params-shape.mjs +22 -10
  80. package/runtime/scripts/hfs/rules/a15-findings.mjs +88 -0
  81. package/runtime/scripts/hfs/rules/app-env-required.mjs +20 -0
  82. package/runtime/scripts/hfs/rules/app-root.mjs +2 -2
  83. package/runtime/scripts/hfs/rules/assets.mjs +79 -0
  84. package/runtime/scripts/hfs/rules/boot-proof.mjs +16 -0
  85. package/runtime/scripts/hfs/rules/canon-copy-drift.mjs +59 -0
  86. package/runtime/scripts/hfs/rules/contract.mjs +66 -5
  87. package/runtime/scripts/hfs/rules/coverage-below.mjs +230 -0
  88. package/runtime/scripts/hfs/rules/database-access.mjs +343 -0
  89. package/runtime/scripts/hfs/rules/database-baseline.mjs +142 -0
  90. package/runtime/scripts/hfs/rules/database-config.mjs +250 -2
  91. package/runtime/scripts/hfs/rules/database-constants.mjs +9 -0
  92. package/runtime/scripts/hfs/rules/database-migrations.mjs +286 -7
  93. package/runtime/scripts/hfs/rules/database-plpgsql.mjs +119 -9
  94. package/runtime/scripts/hfs/rules/database-queue-sources.mjs +176 -0
  95. package/runtime/scripts/hfs/rules/database-queues.mjs +361 -0
  96. package/runtime/scripts/hfs/rules/database-sql.mjs +91 -9
  97. package/runtime/scripts/hfs/rules/database.mjs +9 -0
  98. package/runtime/scripts/hfs/rules/deploy-scan.mjs +192 -0
  99. package/runtime/scripts/hfs/rules/deploy-workflow.mjs +219 -0
  100. package/runtime/scripts/hfs/rules/doc-fresh.mjs +286 -0
  101. package/runtime/scripts/hfs/rules/docker-health.mjs +249 -0
  102. package/runtime/scripts/hfs/rules/docker.mjs +24 -7
  103. package/runtime/scripts/hfs/rules/edition.mjs +9 -8
  104. package/runtime/scripts/hfs/rules/env-parity.mjs +373 -0
  105. package/runtime/scripts/hfs/rules/env-value-shape.mjs +113 -0
  106. package/runtime/scripts/hfs/rules/event-bus.mjs +3 -2
  107. package/runtime/scripts/hfs/rules/event-tables.mjs +100 -0
  108. package/runtime/scripts/hfs/rules/fe-contract-documents.mjs +115 -15
  109. package/runtime/scripts/hfs/rules/fe-stack.mjs +57 -0
  110. package/runtime/scripts/hfs/rules/feature-files.mjs +28 -0
  111. package/runtime/scripts/hfs/rules/frontend-tree.mjs +54 -35
  112. package/runtime/scripts/hfs/rules/integration-specs.mjs +3 -2
  113. package/runtime/scripts/hfs/rules/lexicon.mjs +62 -0
  114. package/runtime/scripts/hfs/rules/lite-proof-lane.mjs +89 -0
  115. package/runtime/scripts/hfs/rules/monorepo.mjs +14 -1
  116. package/runtime/scripts/hfs/rules/pipeline.mjs +8 -1
  117. package/runtime/scripts/hfs/rules/release-provenance.mjs +211 -0
  118. package/runtime/scripts/hfs/rules/saga-delivery.mjs +66 -0
  119. package/runtime/scripts/hfs/rules/saga.mjs +8 -16
  120. package/runtime/scripts/hfs/rules/script-hygiene.mjs +151 -0
  121. package/runtime/scripts/hfs/rules/secret-custody-only.mjs +65 -0
  122. package/runtime/scripts/hfs/rules/secrets.mjs +13 -0
  123. package/runtime/scripts/hfs/rules/services.mjs +4 -4
  124. package/runtime/scripts/hfs/rules/stack-tooling.mjs +93 -0
  125. package/runtime/scripts/hfs/rules/stacks-deploy.mjs +76 -0
  126. package/runtime/scripts/hfs/rules/stacks-ingress.mjs +87 -0
  127. package/runtime/scripts/hfs/rules/stacks-lite-backing.mjs +111 -0
  128. package/runtime/scripts/hfs/rules/stacks-release.mjs +366 -0
  129. package/runtime/scripts/hfs/rules/stacks-runtime.mjs +41 -1
  130. package/runtime/scripts/hfs/rules/stacks-swarm.mjs +417 -0
  131. package/runtime/scripts/hfs/rules/supabase-secrets.mjs +1 -1
  132. package/runtime/scripts/hfs/rules/supply-chain.mjs +167 -0
  133. package/runtime/scripts/hfs/rules/unused-symbol-flow.mjs +101 -0
  134. package/runtime/scripts/hfs/rules/unused-symbol-ledger.mjs +77 -0
  135. package/runtime/scripts/hfs/rules/unused-symbol-translators.mjs +165 -0
  136. package/runtime/scripts/hfs/rules/unused-symbol-values.mjs +103 -0
  137. package/runtime/scripts/hfs/rules/unused-symbol.mjs +320 -0
  138. package/runtime/scripts/hfs/runtime-rules/source-ast.mjs +185 -0
  139. package/runtime/scripts/hfs/runtime-rules/workflow-source.mjs +19 -0
  140. package/runtime/scripts/hfs/sealed-members.mjs +101 -0
  141. package/runtime/scripts/hfs/secret-bootstrap-crypto.mjs +200 -0
  142. package/runtime/scripts/hfs/secret-edit.mjs +170 -0
  143. package/runtime/scripts/hfs/secret-init.mjs +127 -19
  144. package/runtime/scripts/hfs/secret-sops.mjs +38 -6
  145. package/runtime/scripts/hfs/secret-store.mjs +56 -54
  146. package/runtime/scripts/hfs/secret.mjs +34 -5
  147. package/runtime/scripts/hfs/slot-imports.mjs +18 -5
  148. package/runtime/scripts/hfs/slots.mjs +1 -0
  149. package/runtime/scripts/hfs/stack-config-kit.mjs +34 -0
  150. package/runtime/scripts/hfs/stack-deploy-config.mjs +313 -0
  151. package/runtime/scripts/hfs/stack-ingress-config.mjs +237 -0
  152. package/runtime/scripts/hfs/stacks-gate.mjs +414 -0
  153. package/runtime/scripts/hfs/starcistacks-runtime.mjs +61 -0
  154. package/runtime/scripts/lib/command-tokens.mjs +126 -0
  155. package/runtime/scripts/lib/compose-values.mjs +11 -0
  156. package/runtime/scripts/lib/deployment-findings.mjs +7 -0
  157. package/runtime/scripts/lib/json-clone.mjs +61 -0
  158. package/runtime/scripts/lib/list.mjs +4 -0
  159. package/runtime/scripts/lib/runtime-example.mjs +13 -0
  160. package/runtime/scripts/lib/secret-patterns.mjs +60 -5
  161. package/runtime/scripts/lib/sql-ast.mjs +32 -0
  162. package/runtime/scripts/lib/stack-services.mjs +1 -1
  163. package/runtime/scripts/lib/swarm-stack.mjs +5 -18
  164. package/runtime/scripts/lib/trim-end.mjs +8 -0
  165. package/runtime/scripts/lib/ts-analysis.mjs +5 -2
  166. package/runtime/scripts/lib/ts-ast.mjs +27 -2
  167. package/runtime/scripts/lib/workflow-events.mjs +8 -0
  168. package/scaffold/add-app.mjs +77 -59
  169. package/scaffold/add-cli-lite.mjs +14 -6
  170. package/scaffold/add-compose.mjs +79 -0
  171. package/scaffold/add-database-lite.mjs +14 -0
  172. package/scaffold/add-fe-overlay.mjs +122 -0
  173. package/scaffold/add-fe-page.mjs +269 -0
  174. package/scaffold/add-inbox-lite.mjs +74 -42
  175. package/scaffold/add-table.mjs +110 -87
  176. package/scaffold/add.mjs +159 -44
  177. package/scaffold/app.mjs +124 -39
  178. package/scaffold/edition-gate.mjs +3 -3
  179. package/scaffold/lite-exports.mjs +2 -1
  180. package/scaffold/service.mjs +2 -2
  181. package/src/main.mjs +229 -116
  182. package/stack/age-install.mjs +37 -0
  183. package/stack/backup.mjs +100 -0
  184. package/stack/build-args.mjs +42 -0
  185. package/stack/build.mjs +173 -0
  186. package/stack/cli.mjs +130 -0
  187. package/stack/config.mjs +57 -0
  188. package/stack/context.mjs +57 -0
  189. package/stack/converge.mjs +107 -0
  190. package/stack/deploy.mjs +301 -0
  191. package/stack/digest.mjs +16 -0
  192. package/stack/docker-transport.mjs +53 -0
  193. package/stack/edge-alias.mjs +118 -0
  194. package/stack/errors.mjs +21 -0
  195. package/stack/image-scan.mjs +152 -0
  196. package/stack/ingress-create.mjs +114 -0
  197. package/stack/ingress-publish.mjs +273 -0
  198. package/stack/ingress.mjs +706 -0
  199. package/stack/jobs.mjs +51 -0
  200. package/stack/live-restore.mjs +177 -0
  201. package/stack/prepare.mjs +266 -0
  202. package/stack/private-file.mjs +22 -0
  203. package/stack/provision.mjs +90 -0
  204. package/stack/prune.mjs +46 -0
  205. package/stack/release-store.mjs +87 -0
  206. package/stack/remote.mjs +205 -0
  207. package/stack/render.mjs +149 -0
  208. package/stack/retire.mjs +47 -0
  209. package/stack/sealed.mjs +149 -0
  210. package/stack/task.mjs +42 -0
  211. package/stack/verify.mjs +99 -0
  212. package/sync/ci-commands.mjs +13 -0
  213. package/sync/index.mjs +108 -52
  214. package/sync/stack-images.mjs +127 -0
  215. package/templates/app/ci-workflows/github/workflows/ci.yml +5 -1
  216. package/templates/app/ci-workflows/github/workflows/images.yml +15 -3
  217. package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +148 -0
  218. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +7 -3
  219. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +38 -6
  220. package/templates/app/ci-workflows-lite/github/workflows/images.yml +14 -2
  221. package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +148 -0
  222. package/templates/app/codegen-graphql.mjs.partial +13 -2
  223. package/templates/app/codegen-lite/scripts/codegen.mjs +6 -1
  224. package/templates/app/codegen-prelude.mjs.partial +8 -0
  225. package/templates/app/gitignore +3 -0
  226. package/templates/app/gitignore-lite +3 -0
  227. package/templates/app/package-scripts/package.json +1 -0
  228. package/templates/app/package-scripts/supabase.json +7 -0
  229. package/templates/app/package-scripts-lite/package.json +1 -7
  230. package/templates/app/skeleton/.editorconfig +1 -0
  231. package/templates/app/skeleton/.gitattributes +7 -1
  232. package/templates/app/skeleton/.github/dependabot.yml +17 -0
  233. package/templates/app/skeleton/.nvmrc +1 -1
  234. package/templates/app/skeleton/.starcistacks/application-stacks.yaml +134 -1
  235. package/templates/app/skeleton/.starcistacks/dev/README.md +16 -0
  236. package/templates/app/skeleton/.starcistacks/dev/environment.json +5 -0
  237. package/templates/app/skeleton/.starcistacks/dev/infra/compose/stack.yaml +71 -0
  238. package/templates/app/skeleton/.starcistacks/dev/runtime/env/KEYS.md +14 -0
  239. package/templates/app/skeleton/.starcistacks/production/README.md +37 -0
  240. package/templates/app/skeleton/.starcistacks/production/environment.json +5 -0
  241. package/templates/app/skeleton/.starcistacks/production/infra/swarm/Caddyfile +15 -0
  242. package/templates/app/skeleton/.starcistacks/production/infra/swarm/owner-actions.md +11 -0
  243. package/templates/app/skeleton/.starcistacks/production/infra/swarm/recovery/restore-proof.md +7 -0
  244. package/templates/app/skeleton/.starcistacks/production/infra/swarm/releases/current.yaml +6 -0
  245. package/templates/app/skeleton/.starcistacks/production/infra/swarm/releases/images.lock.yaml +9 -0
  246. package/templates/app/skeleton/.starcistacks/production/infra/swarm/releases/previous.yaml +6 -0
  247. package/templates/app/skeleton/.starcistacks/production/infra/swarm/stack.yaml +142 -0
  248. package/templates/app/skeleton/.starcistacks/production/runtime/env/KEYS.md +14 -0
  249. package/templates/app/skeleton/README.md +6 -6
  250. package/templates/app/skeleton/scripts/codegen.mjs +1 -2
  251. package/templates/app/skeleton-lite/.editorconfig +16 -0
  252. package/templates/app/skeleton-lite/.gitattributes +8 -0
  253. package/templates/app/skeleton-lite/.github/dependabot.yml +17 -0
  254. package/templates/app/skeleton-lite/.nvmrc +1 -0
  255. package/templates/app/skeleton-lite/.sops.yaml +3 -2
  256. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +178 -1
  257. package/templates/app/skeleton-lite/.starcistacks/dev/README.md +20 -0
  258. package/templates/app/skeleton-lite/.starcistacks/dev/environment.json +2 -1
  259. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/Caddyfile +16 -0
  260. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/owner-actions.md +11 -0
  261. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/recovery/restore-proof.md +7 -0
  262. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/releases/current.yaml +6 -0
  263. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/releases/images.lock.yaml +8 -0
  264. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/releases/previous.yaml +6 -0
  265. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +120 -0
  266. package/templates/app/skeleton-lite/.starcistacks/dev/runtime/env/KEYS.md +18 -0
  267. package/templates/app/skeleton-lite/.starcistacks/production/README.md +38 -0
  268. package/templates/app/skeleton-lite/.starcistacks/production/environment.json +6 -0
  269. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/Caddyfile +11 -0
  270. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/owner-actions.md +11 -0
  271. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/recovery/restore-proof.md +7 -0
  272. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/releases/current.yaml +6 -0
  273. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/releases/images.lock.yaml +8 -0
  274. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/releases/previous.yaml +6 -0
  275. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +121 -0
  276. package/templates/app/skeleton-lite/.starcistacks/production/runtime/env/KEYS.md +21 -0
  277. package/templates/app/skeleton-lite/.starciwork/brand/index.yaml +12 -0
  278. package/templates/app/skeleton-lite/README.md +5 -3
  279. package/templates/app/skeleton-lite/supabase/config.toml +8 -1
  280. package/templates/app/skeleton-lite/supabase/migrations/__timestamp___baseline.sql +15 -6
  281. package/templates/app/upgrade-full/.starcistacks/dev/README.md +13 -7
  282. package/templates/be/image/worker/Dockerfile +1 -1
  283. package/templates/be/inbox/database-client.spec.ts.tpl +17 -18
  284. package/templates/be/inbox/database-client.ts.tpl +12 -11
  285. package/templates/be/inbox/delivery-recorded.event.ts.tpl +36 -0
  286. package/templates/be/inbox/event-index.ts.tpl +1 -0
  287. package/templates/be/inbox/events.json.tpl +10 -0
  288. package/templates/be/inbox/migration.sql.tpl +1 -0
  289. package/templates/be/inbox/platform/event-bus/event-bus.contracts.ts.tpl +42 -0
  290. package/templates/be/inbox/platform/event-bus/event-bus.decorators.ts.tpl +9 -0
  291. package/templates/be/inbox/platform/event-bus/event-bus.module-definition.ts.tpl +6 -0
  292. package/templates/be/inbox/platform/event-bus/event-bus.module.ts.tpl +19 -0
  293. package/templates/be/inbox/platform/event-bus/event-bus.options.ts.tpl +2 -0
  294. package/templates/be/inbox/platform/event-bus/event-bus.port.ts.tpl +16 -0
  295. package/templates/be/inbox/platform/event-bus/event-bus.service.spec.ts.tpl +45 -0
  296. package/templates/be/inbox/platform/event-bus/event-bus.service.ts.tpl +15 -0
  297. package/templates/be/inbox/platform/event-bus/index.ts.tpl +5 -0
  298. package/templates/be/inbox/platform/event-bus/persistence/event-bus.sql.ts.tpl +5 -0
  299. package/templates/be/inbox/platform/inbox/inbox.decorators.ts.tpl +9 -0
  300. package/templates/be/inbox/platform/inbox/inbox.module-definition.ts.tpl +6 -0
  301. package/templates/be/inbox/platform/inbox/inbox.module.ts.tpl +19 -0
  302. package/templates/be/inbox/platform/inbox/inbox.options.ts.tpl +2 -0
  303. package/templates/be/inbox/platform/inbox/inbox.port.ts.tpl +10 -0
  304. package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +41 -0
  305. package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +19 -0
  306. package/templates/be/inbox/platform/inbox/index.ts.tpl +3 -0
  307. package/templates/be/inbox/platform/inbox/persistence/inbox.sql.ts.tpl +7 -0
  308. package/templates/be/inbox/platform/migration.sql.tpl +48 -0
  309. package/templates/be/inbox/service.spec.ts.tpl +42 -9
  310. package/templates/be/inbox/service.ts.tpl +23 -8
  311. package/templates/be/inbox/webhook-door.spec.ts.tpl +1 -1
  312. package/templates/be/inbox/webhook-door.ts.tpl +2 -2
  313. package/templates/be/patterns/api/feature.module-definition.ts.tpl +6 -0
  314. package/templates/be/patterns/api/feature.module.ts.tpl +2 -1
  315. package/templates/be/patterns/api/feature.options.ts.tpl +2 -0
  316. package/templates/be/patterns/api/graphql.module-definition.ts.tpl +6 -0
  317. package/templates/be/patterns/api/graphql.module.ts.tpl +3 -2
  318. package/templates/be/patterns/api/graphql.options.ts.tpl +2 -0
  319. package/templates/be/patterns/cli/group.module-definition.ts.tpl +6 -0
  320. package/templates/be/patterns/cli/group.module.ts.tpl +2 -1
  321. package/templates/be/patterns/cli/group.options.ts.tpl +2 -0
  322. package/templates/be/patterns/event-bus/platform/errors/event-bus.error.ts.tpl +0 -3
  323. package/templates/be/patterns/event-bus/platform/messages/event-bus.messages.ts.tpl +0 -2
  324. package/templates/be/patterns/jobs/job.module-definition.ts.tpl +6 -0
  325. package/templates/be/patterns/jobs/job.module.ts.tpl +2 -1
  326. package/templates/be/patterns/jobs/job.options.ts.tpl +2 -0
  327. package/templates/be/patterns/jobs/job.queue.module-definition.ts.tpl +6 -0
  328. package/templates/be/patterns/jobs/job.queue.module.ts.tpl +6 -3
  329. package/templates/be/patterns/jobs/job.queue.options.ts.tpl +2 -0
  330. package/templates/be/patterns/jobs/platform/fenced.processor.spec.ts.tpl +3 -1
  331. package/templates/be/patterns/outbox/platform/outbox-relay.policy.spec.ts.tpl +25 -9
  332. package/templates/be/patterns/queues/platform/queue.config.ts.tpl +1 -1
  333. package/templates/be/patterns/queues/platform/queue.index.ts.tpl +1 -1
  334. package/templates/be/patterns/queues/platform/queue.module-definition.ts.tpl +2 -2
  335. package/templates/be/patterns/queues/platform/queue.module.ts.tpl +11 -0
  336. package/templates/be/patterns/queues/platform/queue.options.ts.tpl +10 -0
  337. package/templates/be/patterns/queues/queue.module-definition.ts.tpl +6 -0
  338. package/templates/be/patterns/queues/queue.module.ts.tpl +2 -1
  339. package/templates/be/patterns/queues/queue.options.ts.tpl +2 -0
  340. package/templates/be/patterns/reactors/message.module-definition.ts.tpl +6 -0
  341. package/templates/be/patterns/reactors/message.module.ts.tpl +6 -3
  342. package/templates/be/patterns/reactors/message.options.ts.tpl +2 -0
  343. package/templates/be/patterns/reactors/reactor.module-definition.ts.tpl +6 -0
  344. package/templates/be/patterns/reactors/reactor.module.ts.tpl +2 -1
  345. package/templates/be/patterns/reactors/reactor.options.ts.tpl +2 -0
  346. package/templates/be/patterns/realtime/channel.gateway.spec.ts.tpl +188 -0
  347. package/templates/be/patterns/realtime/channel.gateway.ts.tpl +22 -0
  348. package/templates/be/patterns/realtime/channel.graphql.module-definition.ts.tpl +6 -0
  349. package/templates/be/patterns/realtime/channel.graphql.module.ts.tpl +2 -1
  350. package/templates/be/patterns/realtime/channel.graphql.options.ts.tpl +2 -0
  351. package/templates/be/patterns/realtime/channel.websocket.module-definition.ts.tpl +6 -0
  352. package/templates/be/patterns/realtime/channel.websocket.module.ts.tpl +110 -0
  353. package/templates/be/patterns/realtime/channel.websocket.options.ts.tpl +7 -0
  354. package/templates/be/patterns/saga/saga.message.module-definition.ts.tpl +6 -0
  355. package/templates/be/patterns/saga/saga.message.module.ts.tpl +6 -3
  356. package/templates/be/patterns/saga/saga.message.options.ts.tpl +2 -0
  357. package/templates/be/patterns/saga/saga.module-definition.ts.tpl +6 -0
  358. package/templates/be/patterns/saga/saga.module.ts.tpl +2 -1
  359. package/templates/be/patterns/saga/saga.options.ts.tpl +2 -0
  360. package/templates/be/patterns/webhooks/webhook.http.module-definition.ts.tpl +6 -0
  361. package/templates/be/patterns/webhooks/webhook.http.module.ts.tpl +2 -1
  362. package/templates/be/patterns/webhooks/webhook.http.options.ts.tpl +2 -0
  363. package/templates/be/platform/graphql/localize-errors.mapper.spec.ts +80 -26
  364. package/templates/be/platform/graphql/localize-errors.mapper.ts +2 -5
  365. package/templates/be/skeleton/apps/__app__/src/app.module.ts +11 -9
  366. package/templates/be/skeleton/apps/__app__/src/compose/api.ts +4 -0
  367. package/templates/be/skeleton/apps/__app__/src/compose/domain.ts +11 -0
  368. package/templates/be/skeleton/apps/__app__/src/compose/platform.ts +2 -0
  369. package/templates/be/skeleton/apps/__app__/src/compose/system.ts +5 -0
  370. package/templates/be/skeleton/apps/__app__/src/compose/webhooks.ts +4 -0
  371. package/templates/be/skeleton/apps/__app__/src/main.ts +15 -1
  372. package/templates/be/skeleton/apps/cli/src/app.module.ts +3 -13
  373. package/templates/be/skeleton/apps/cli/src/cli.options.ts +6 -14
  374. package/templates/be/skeleton/apps/cli/src/compose/cli.ts +5 -0
  375. package/templates/be/skeleton/apps/cli/src/compose/platform.ts +24 -0
  376. package/templates/be/skeleton/src/features/api/system-health/application/check-readiness.contracts.ts +2 -0
  377. package/templates/be/skeleton/src/features/api/system-health/application/check-readiness.handler.ts +22 -0
  378. package/templates/be/skeleton/src/features/api/system-health/application/check-readiness.query.ts +11 -0
  379. package/templates/be/skeleton/src/features/api/system-health/system-health.module-definition.ts +6 -0
  380. package/templates/be/skeleton/src/features/api/system-health/system-health.module.ts +5 -3
  381. package/templates/be/skeleton/src/features/api/system-health/system-health.options.ts +2 -0
  382. package/templates/be/skeleton/src/features/api/system-health/transport/http/ready.controller.ts +19 -0
  383. package/templates/be/skeleton/src/features/api/system-health/transport/http/system-health-http.module-definition.ts +6 -0
  384. package/templates/be/skeleton/src/features/api/system-health/transport/http/system-health-http.module.ts +8 -3
  385. package/templates/be/skeleton/src/features/api/system-health/transport/http/system-health-http.options.ts +2 -0
  386. package/templates/be/skeleton/src/features/cli/cli.module-definition.ts +6 -0
  387. package/templates/be/skeleton/src/features/cli/cli.module.ts +3 -2
  388. package/templates/be/skeleton/src/features/cli/cli.options.ts +2 -0
  389. package/templates/be/skeleton/src/features/cli/migrate/migrate.module-definition.ts +6 -0
  390. package/templates/be/skeleton/src/features/cli/migrate/migrate.module.ts +2 -1
  391. package/templates/be/skeleton/src/features/cli/migrate/migrate.options.ts +2 -0
  392. package/templates/be/skeleton/src/features/cli/seed/seed.module-definition.ts +6 -0
  393. package/templates/be/skeleton/src/features/cli/seed/seed.module.ts +2 -1
  394. package/templates/be/skeleton/src/features/cli/seed/seed.options.ts +2 -0
  395. package/templates/be/skeleton/src/features/cli/seed/subs/run.cli.spec.ts +5 -6
  396. package/templates/be/skeleton/src/modules/domain/identity/admission.policy.spec.ts +56 -4
  397. package/templates/be/skeleton/src/modules/domain/identity/admission.policy.ts +36 -5
  398. package/templates/be/skeleton/src/modules/domain/identity/auth.guard.spec.ts +39 -8
  399. package/templates/be/skeleton/src/modules/domain/identity/auth.guard.ts +14 -8
  400. package/templates/be/skeleton/src/modules/domain/identity/credential.contracts.ts +26 -0
  401. package/templates/be/skeleton/src/modules/domain/identity/credential.guard.spec.ts +119 -0
  402. package/templates/be/skeleton/src/modules/domain/identity/credential.guard.ts +43 -0
  403. package/templates/be/skeleton/src/modules/domain/identity/identity.contracts.ts +16 -0
  404. package/templates/be/skeleton/src/modules/domain/identity/identity.decorators.ts +18 -1
  405. package/templates/be/skeleton/src/modules/domain/identity/identity.module.ts +22 -4
  406. package/templates/be/skeleton/src/modules/domain/identity/identity.options.ts +8 -2
  407. package/templates/be/skeleton/src/modules/domain/identity/index.ts +1 -0
  408. package/templates/be/skeleton/src/modules/platform/database/connection-source.client.spec.ts +11 -4
  409. package/templates/be/skeleton/src/modules/platform/database/database-probe.service.spec.ts +32 -0
  410. package/templates/be/skeleton/src/modules/platform/database/database-probe.service.ts +14 -0
  411. package/templates/be/skeleton/src/modules/platform/database/database.module.ts +7 -1
  412. package/templates/be/skeleton/src/modules/platform/database/index.ts +2 -0
  413. package/templates/be/skeleton/src/modules/platform/database/primary.decorators.ts +8 -0
  414. package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.spec.ts +25 -6
  415. package/templates/be/skeleton/src/modules/platform/errors/errors.filter.spec.ts +16 -6
  416. package/templates/be/skeleton/src/modules/platform/http-security/edge-security.service.spec.ts +130 -0
  417. package/templates/be/skeleton/src/modules/platform/http-security/edge-security.service.ts +58 -0
  418. package/templates/be/skeleton/src/modules/platform/http-security/errors/http-security.error.ts +3 -0
  419. package/templates/be/skeleton/src/modules/platform/http-security/execution-request.mapper.spec.ts +16 -4
  420. package/templates/be/skeleton/src/modules/platform/http-security/http-security.config.ts +16 -10
  421. package/templates/be/skeleton/src/modules/platform/http-security/http-security.module.ts +3 -1
  422. package/templates/be/skeleton/src/modules/platform/http-security/http-security.options.ts +2 -0
  423. package/templates/be/skeleton/src/modules/platform/http-security/index.ts +5 -0
  424. package/templates/be/skeleton/src/modules/platform/http-security/messages/http-security.messages.ts +12 -5
  425. package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.spec.ts +13 -8
  426. package/templates/be/skeleton/src/modules/platform/http-security/readiness.contracts.ts +5 -0
  427. package/templates/be/skeleton/src/modules/platform/http-security/readiness.decorators.ts +7 -0
  428. package/templates/be/skeleton/src/modules/platform/http-security/readiness.service.spec.ts +86 -0
  429. package/templates/be/skeleton/src/modules/platform/http-security/readiness.service.ts +39 -0
  430. package/templates/be/skeleton/src/modules/platform/http-security/webhook-signature.service.spec.ts +139 -131
  431. package/templates/be/skeleton/src/modules/platform/i18n/index.ts +1 -1
  432. package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.spec.ts +91 -67
  433. package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.ts +5 -4
  434. package/templates/be/skeleton/src/modules/platform/logging/logging.log-events.ts +0 -2
  435. package/templates/be/skeleton/src/modules/platform/primitives/outcome.mapper.spec.ts +20 -6
  436. package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.spec.ts +16 -4
  437. package/templates/be/skeleton/src/modules/platform/retry/index.ts +3 -0
  438. package/templates/be/skeleton/src/modules/platform/retry/retry.decorators.ts +9 -0
  439. package/templates/be/skeleton/src/modules/platform/retry/retry.module-definition.ts +7 -0
  440. package/templates/be/skeleton/src/modules/platform/retry/retry.module.ts +19 -0
  441. package/templates/be/skeleton/src/modules/platform/retry/retry.options.ts +2 -0
  442. package/templates/be/skeleton/src/modules/platform/retry/retry.port.ts +11 -0
  443. package/templates/be/skeleton/src/modules/platform/retry/system-timer.service.spec.ts +36 -0
  444. package/templates/be/skeleton/src/modules/platform/retry/system-timer.service.ts +10 -0
  445. package/templates/be/skeleton/src/tests/contract/http-security/http-security.contract-spec.ts +54 -0
  446. package/templates/be/skeleton-lite/apps/api/src/app.module.ts +14 -18
  447. package/templates/be/skeleton-lite/apps/api/src/compose/api.ts +4 -0
  448. package/templates/be/skeleton-lite/apps/api/src/compose/domain.ts +10 -0
  449. package/templates/be/skeleton-lite/apps/api/src/compose/integrations.ts +16 -0
  450. package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +2 -0
  451. package/templates/be/skeleton-lite/apps/api/src/compose/system.ts +5 -0
  452. package/templates/be/skeleton-lite/apps/api/src/compose/webhooks.ts +4 -0
  453. package/templates/be/skeleton-lite/apps/api/src/main.ts +2 -3
  454. package/templates/be/skeleton-lite/apps/cli/src/compose/platform.ts +12 -0
  455. package/templates/be/skeleton-lite/src/modules/domain/identity/admission.policy.spec.ts +71 -5
  456. package/templates/be/skeleton-lite/src/modules/domain/identity/admission.policy.ts +29 -2
  457. package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.spec.ts +68 -15
  458. package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.ts +14 -9
  459. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.contracts.ts +9 -2
  460. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +16 -1
  461. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.module.ts +19 -4
  462. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.options.ts +5 -1
  463. package/templates/be/skeleton-lite/src/modules/domain/identity/index.ts +2 -1
  464. package/templates/be/skeleton-lite/src/modules/integrations/supabase/errors/supabase.error.ts +0 -3
  465. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.client.spec.ts +17 -5
  466. package/templates/be/skeleton-lite/src/modules/platform/database/database.module.ts +24 -13
  467. package/templates/be/skeleton-lite/src/modules/platform/database/database.port.ts +5 -0
  468. package/templates/be/skeleton-lite/src/modules/platform/database/errors/database.error.ts +16 -0
  469. package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +3 -0
  470. package/templates/be/skeleton-lite/src/modules/platform/http-security/http-security.module.ts +5 -2
  471. package/templates/be/skeleton-lite/src/modules/platform/http-security/index.ts +4 -1
  472. package/templates/be/skeleton-lite/src/modules/platform/i18n/index.ts +1 -1
  473. package/templates/be/skeleton-lite/src/modules/platform/logging/logging.log-events.ts +7 -0
  474. package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +29 -0
  475. package/templates/be/skeleton-lite/src/tests/fixtures/builders/cli.builder.ts +16 -0
  476. package/templates/be/table/api/feature.module-definition.ts.tpl +6 -0
  477. package/templates/be/table/api/feature.module.ts.tpl +2 -1
  478. package/templates/be/table/api/feature.options.ts.tpl +2 -0
  479. package/templates/be/table/api/graphql.module-definition.ts.tpl +6 -0
  480. package/templates/be/table/api/graphql.module.ts.tpl +3 -2
  481. package/templates/be/table/api/graphql.options.ts.tpl +2 -0
  482. package/templates/be/table/migration.sql.tpl +1 -0
  483. package/templates/be/upgrade-full/apps/cli/src/app.module.ts +4 -6
  484. package/templates/be/upgrade-full/apps/cli/src/cli.env.ts +7 -0
  485. package/templates/be/upgrade-full/apps/cli/src/compose/cli.ts +5 -0
  486. package/templates/be/upgrade-full/apps/cli/src/compose/platform.ts +9 -0
  487. package/templates/be/upgrade-full/apps/cli/src/main.ts +3 -0
  488. package/templates/be/upgrade-full/src/features/cli/cli.module.ts +3 -2
  489. package/templates/fe/auth/supabase-client.ts.tpl +34 -0
  490. package/templates/fe/skeleton/apps/__app__/src/app/auth/callback/route.ts +11 -0
  491. package/templates/fe/skeleton/apps/__app__/src/components/blocks/SignInForm/index.tsx +6 -1
  492. package/templates/fe/skeleton/apps/__app__/src/components/leaves/Icon/index.tsx +4 -0
  493. package/templates/fe/skeleton/apps/__app__/src/features/overlays/AppGuideOverlay/component.tsx +31 -0
  494. package/templates/fe/skeleton/apps/__app__/src/features/overlays/AppGuideOverlay/index.tsx +24 -0
  495. package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSession.ts +6 -1
  496. package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSignOut.ts +3 -4
  497. package/templates/fe/skeleton/apps/__app__/src/hooks/zustand/appGuide/index.ts +1 -0
  498. package/templates/fe/skeleton/apps/__app__/src/hooks/zustand/appGuide/store.ts +15 -0
  499. package/templates/fe/skeleton/apps/__app__/src/modules/auth/pkce-client.ts +18 -0
  500. package/templates/fe/skeleton/apps/__app__/src/modules/auth/server.ts +19 -0
  501. package/templates/fe/skeleton/apps/app/src/features/pages/AppHomePage/index.tsx +7 -1
  502. package/templates/fe/skeleton/apps/app/src/modules/i18n/messages/en.json +57 -0
  503. package/templates/fe/skeleton/apps/app/src/modules/i18n/messages/vi.json +6 -0
  504. package/templates/fe/skeleton/apps/landing/src/modules/i18n/messages/en.json +31 -0
  505. package/templates/fe/skeleton/packages/__project__-i18n/src/routing.ts +1 -1
  506. package/templates/fe/skeleton/packages/__project__-ui/src/index.ts +1 -0
  507. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/client.ts +1 -34
  508. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppHomePage/index.tsx +7 -1
  509. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/en.json +57 -0
  510. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/vi.json +30 -24
  511. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/routing.ts +1 -1
  512. package/templates/fe/transport/graphql/clients/clients/auth.ts +1 -1
  513. package/templates/fe/transport/graphql/clients/links/locale.ts +2 -2
  514. package/upgrade/check.mjs +3 -1
  515. package/upgrade/index.mjs +53 -40
@@ -0,0 +1,301 @@
1
+ // deploy.mjs - the whole release (deployRelease) and the redeploy of a stored release (rollbackRelease).
2
+ // deploy: render, provision, store the release record, pre-pull, `docker stack deploy`, converge, verify, accept, prune. A failure at or
3
+ // after the stack deploy redeploys the previous accepted release from its STORED bytes; a failure before it leaves the services untouched.
4
+ // Without `execute` a deploy renders locally and prints the plan: NO remote call at all. A rollback cannot undo data effects.
5
+ //
6
+ // `deps` are the collaborators, all factories or functions with the signature of their module, defaulting to lazy dynamic imports so this
7
+ // module never needs render.mjs / provision.mjs / remote.mjs at load time (the specs inject stubs):
8
+ // renderRelease, provisionRelease, convergeStack, verifyStack, pruneStack (ctx, config, options) -> { ok, lines, data }
9
+ // releaseStore (config, ssh) -> store ssh (ctx, config, { sshCmd }) -> runner(command, { input })
10
+ import { createHash } from 'node:crypto';
11
+ import fs from 'node:fs';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+ import { findInOrder } from '../runtime/scripts/lib/in-order.mjs';
15
+ import { convergeStack } from './converge.mjs';
16
+ import { describeClash, edgeCollisions, reconcileEdgeAliases } from './edge-alias.mjs';
17
+ import { firstLine, tool } from './context.mjs';
18
+ import { hasPlaceholderDigest, isReleaseId } from './digest.mjs';
19
+ import { failure, refusal } from './errors.mjs';
20
+ import { pruneStack } from './prune.mjs';
21
+ import { parseYaml } from '../runtime/engine/yaml.mjs';
22
+ import { createReleaseStore } from './release-store.mjs';
23
+ import { verifyStack } from './verify.mjs';
24
+
25
+ /** Loads `file` (relative to this module). Split out so a spec can load a real sibling module through `lazy`. */
26
+ export const importModule = (file) => import(file);
27
+
28
+ /** A function that imports `file` on first call and forwards its arguments to the export `name`. */
29
+ export const lazy = (file, name) => async (...args) => (await importModule(file))[name](...args);
30
+
31
+ export const DEFAULT_DEPS = Object.freeze({
32
+ renderRelease: lazy('./render.mjs', 'renderRelease'),
33
+ provisionRelease: lazy('./provision.mjs', 'provisionRelease'),
34
+ convergeStack,
35
+ reconcileEdge: reconcileEdgeAliases,
36
+ verifyStack,
37
+ pruneStack,
38
+ releaseStore: createReleaseStore,
39
+ ssh: lazy('./remote.mjs', 'ownerSshRunner'),
40
+ });
41
+
42
+ const IMAGE_LINE = /^[ \t]+image:[ \t]*(\S+)[ \t]*$/gmu;
43
+ const QUOTE = /^["']|["']$/gu;
44
+ const IMAGE_REF = /^[A-Za-z0-9][A-Za-z0-9./_:@-]*$/u;
45
+
46
+ /** Every network alias the services of a rendered stack claim: [{ service, alias }]. */
47
+ function stackAliases(bytes) {
48
+ const services = parseYaml(bytes)?.services ?? {};
49
+ return Object.entries(services).flatMap(([service, entry]) => Object.values(entry?.networks ?? {}).flatMap((network) => (network?.aliases ?? []).map((alias) => ({ service, alias }))));
50
+ }
51
+
52
+ /**
53
+ * A swarm service must never claim an alias that a live (compose) upstream of the declared ingress still answers to: on the shared overlay both would
54
+ * share the name and the old route would round-robin into the new service BEFORE the routes are published. Refused before anything is changed.
55
+ */
56
+ function refuseSharedAliases(config, bytes) {
57
+ const live = new Map((config.ingress?.upstreams ?? []).filter((upstream) => upstream.container !== null).flatMap((upstream) => upstream.aliases.map((alias) => [alias, upstream.name])));
58
+ const clash = stackAliases(bytes).find(({ alias }) => live.has(alias));
59
+ if (clash) throw refusal('STACK_DEPLOY_ALIAS_SHARED', `service ${clash.service} claims the network alias ${clash.alias}, which the live upstream ${live.get(clash.alias)} of deploy.ingress.upstreams still answers to: give the swarm service its own alias (for example ${clash.alias}-swarm) and point the published routes at it; nothing was changed`);
60
+ }
61
+
62
+ /**
63
+ * Read-only preflight on the live edge networks: the implicit short name of a service (`api` for shop_api) must not equal a name another container or
64
+ * service of the same external network resolves (docker stack deploy adds it as an alias, and the other tenant's `http://api` would reach this stack).
65
+ */
66
+ async function refuseEdgeCollisions(run, config, bytes) {
67
+ const { clashes } = await edgeCollisions(run, config, bytes);
68
+ if (clashes.length > 0) throw refusal('STACK_DEPLOY_ALIAS_SHARED', describeClash(clashes[0]));
69
+ }
70
+
71
+ /** The release id (first 16 hex of the sha256 of the rendered stack bytes) and the distinct images of a rendered release. */
72
+ function inspectRendered(out, config) {
73
+ const bytes = fs.readFileSync(path.join(out, 'stack.yaml'), 'utf8');
74
+ refuseSharedAliases(config, bytes);
75
+ if (hasPlaceholderDigest(bytes)) throw refusal('STACK_DEPLOY_PLACEHOLDER', 'the rendered stack still carries a placeholder digest; nothing was changed');
76
+ const images = [...new Set([...bytes.matchAll(IMAGE_LINE)].map((match) => match[1].replaceAll(QUOTE, '')))];
77
+ const unsafe = images.find((image) => !IMAGE_REF.test(image));
78
+ if (unsafe) throw refusal('STACK_DEPLOY_IMAGE', `the rendered stack names an image that is not a plain reference (${unsafe}); nothing was changed`);
79
+ return { releaseId: createHash('sha256').update(bytes).digest('hex').slice(0, 16), images, bytes };
80
+ }
81
+
82
+ function renderOptions(options, out, previous) {
83
+ return { images: options.images ?? {}, sourceSha: options.sourceSha, previous, out, allowUnpinned: false, extraCompose: [] };
84
+ }
85
+
86
+ /** A step that returns { ok, lines } whatever happens: an exception becomes a failed step (used once the services may have changed). */
87
+ async function attempt(action) {
88
+ try {
89
+ return await action();
90
+ } catch (error) {
91
+ return { ok: false, lines: [String(error.message)] };
92
+ }
93
+ }
94
+
95
+ /** `docker stack deploy` of a stored or rendered file. */
96
+ function stackDeploy(ctx, config, file) {
97
+ const run = tool(ctx, 'docker', ['stack', 'deploy', '--prune', '--with-registry-auth', '--resolve-image', 'always', '--detach=true', '-c', file, config.stack]);
98
+ if (run.status !== 0) return { ok: false, lines: [`docker stack deploy failed: ${firstLine(run.stderr)}`] };
99
+ return { ok: true, lines: [`deploy: requested ${config.stack} from ${path.basename(path.dirname(file))}/${path.basename(file)}`] };
100
+ }
101
+
102
+ /** Runs [name, action] steps in order until one fails; records { step, ok } and collects the lines. Returns the failed step name or ''. */
103
+ async function runSteps(plan, lines, steps) {
104
+ const failed = await findInOrder(plan, async ([step, action]) => {
105
+ const result = await attempt(action);
106
+ lines.push(...result.lines);
107
+ steps.push({ step, ok: result.ok });
108
+ return !result.ok;
109
+ });
110
+ return failed ? failed[0] : '';
111
+ }
112
+
113
+ const isoBefore = (ctx, seconds) => new Date(ctx.now() - seconds * 1000).toISOString();
114
+
115
+ /** Opens the release store over the ssh runner of this invocation; the runner's cleanup is queued on `cleanups` (sealed session files). */
116
+ async function openRunner(ctx, config, options, d, cleanups) {
117
+ const run = await d.ssh(ctx, config, { sshCmd: options.sshCmd ?? null });
118
+ cleanups.push(() => run.cleanup?.());
119
+ return run;
120
+ }
121
+
122
+ const openStore = async (ctx, config, options, d, cleanups) => d.releaseStore(config, await openRunner(ctx, config, options, d, cleanups));
123
+
124
+ /** Redeploys a stored release after a failed deploy: fetch, stack deploy, converge. Returns the lines to print. */
125
+ async function redeployStored(ctx, config, d, store, previous, out, steps) {
126
+ const dir = path.join(out, 'previous');
127
+ const lines = [`ROLLING BACK to the previous accepted release ${previous}`];
128
+ const failed = await runSteps([
129
+ ['rollback-fetch', async () => { await store.fetch(previous, dir); return { ok: true, lines: [] }; }],
130
+ ['rollback-deploy', () => stackDeploy(ctx, config, path.join(dir, 'stack.yaml'))],
131
+ ['rollback-edge', async () => d.reconcileEdge(ctx, config, path.join(dir, 'stack.yaml'))],
132
+ ['rollback-converge', () => d.convergeStack(ctx, config, { since: isoBefore(ctx, 30), timeoutSeconds: 600 })],
133
+ ], lines, steps);
134
+ lines.push(failed ? 'ROLLBACK DID NOT CONVERGE: follow the stack runbook, Rollback' : `rolled back to ${previous}`);
135
+ return { lines, ok: !failed };
136
+ }
137
+
138
+ /** After the accept: prune with both release records (non-fatal) and drop dangling images (failure ignored). */
139
+ async function housekeeping(ctx, config, d, store, { previous, out }, lines) {
140
+ const outcome = await attempt(async () => {
141
+ const keepFiles = [path.join(out, 'release.json')];
142
+ if (previous) {
143
+ await store.fetch(previous, path.join(out, 'previous'));
144
+ keepFiles.push(path.join(out, 'previous', 'release.json'));
145
+ }
146
+ return d.pruneStack(ctx, config, { keepFiles, execute: true });
147
+ });
148
+ lines.push(...outcome.lines);
149
+ if (!outcome.ok) lines.push('prune failed (non-fatal; superseded objects stay until the next release)');
150
+ tool(ctx, 'docker', ['image', 'prune', '-f']);
151
+ }
152
+
153
+ function prePull(ctx, images) {
154
+ const lines = [];
155
+ for (const image of images) {
156
+ const run = tool(ctx, 'docker', ['pull', '--quiet', image]);
157
+ if (run.status !== 0) throw failure('STACK_DEPLOY_PULL', `docker pull ${image} failed: ${firstLine(run.stderr)}`);
158
+ lines.push(`pulled ${image}`);
159
+ }
160
+ return lines;
161
+ }
162
+
163
+ /** Everything before the stack deploy: any failure here leaves the services untouched. Returns { ok, lines, releaseId, images, previous }. */
164
+ async function prepareRelease(ctx, config, options, d, out, cleanups) {
165
+ const run = await openRunner(ctx, config, options, d, cleanups);
166
+ const store = d.releaseStore(config, run);
167
+ const previous = await store.current();
168
+ const rendered = await d.renderRelease(ctx, config, renderOptions(options, out, previous || null));
169
+ const { releaseId, images, bytes } = inspectRendered(out, config);
170
+ await refuseEdgeCollisions(run, config, bytes);
171
+ const lines = [...(rendered.lines ?? [])];
172
+ const provisioned = await d.provisionRelease(ctx, config, { execute: true, out, releaseFile: path.join(out, 'release.json') });
173
+ lines.push(...(provisioned.lines ?? []));
174
+ if (provisioned.ok === false) return { ok: false, lines, releaseId, previous, store, stepsDone: [{ step: 'render', ok: true }, { step: 'provision', ok: false }] };
175
+ await store.store(out, releaseId);
176
+ lines.push(...prePull(ctx, images));
177
+ return { ok: true, lines, releaseId, previous, store, stepsDone: [{ step: 'render', ok: true }, { step: 'provision', ok: true }, { step: 'store', ok: true }, { step: 'pre-pull', ok: true }] };
178
+ }
179
+
180
+ async function deployExecute(ctx, config, options, d, out, cleanups) {
181
+ const prepared = await prepareRelease(ctx, config, options, d, out, cleanups);
182
+ const { releaseId, previous, store } = prepared;
183
+ const lines = [...prepared.lines];
184
+ const steps = [...prepared.stepsDone];
185
+ const data = { releaseId, previous: previous || null, steps, rolledBack: false };
186
+ if (!prepared.ok) return { ok: false, lines: [...lines, 'deploy not started; the services are untouched'], data };
187
+ const since = isoBefore(ctx, 60);
188
+ const { skipPublic } = options;
189
+ const failed = await runSteps([
190
+ ['deploy', () => stackDeploy(ctx, config, path.join(out, 'stack.yaml'))],
191
+ ['edge', async () => d.reconcileEdge(ctx, config, path.join(out, 'stack.yaml'))],
192
+ ['converge', () => d.convergeStack(ctx, config, { since, timeoutSeconds: options.timeoutSeconds })],
193
+ ['verify', () => d.verifyStack(ctx, config, { retrySeconds: options.retrySeconds, skipPublic: skipPublic === true, stackFile: path.join(out, 'stack.yaml') })],
194
+ ...(skipPublic ? [] : [['accept', async () => { await store.accept(releaseId); return { ok: true, lines: [`accepted release ${releaseId}`] }; }]]),
195
+ ], lines, steps);
196
+ if (!failed && skipPublic) {
197
+ data.pendingAccept = true;
198
+ return { ok: true, lines: [...lines, `deployed release ${releaseId}: converged and verified inside the stack; the public hosts were NOT checked and the release is NOT accepted yet`, `next: starci app stack ingress publish, then starci app stack accept --release ${releaseId} --execute`], data };
199
+ }
200
+ if (!failed) {
201
+ await housekeeping(ctx, config, d, store, { previous, out }, lines);
202
+ return { ok: true, lines: [...lines, `deployed release ${releaseId}`], data };
203
+ }
204
+ lines.push(`deploy failed at ${failed}`);
205
+ if (!previous) return { ok: false, lines: [...lines, 'no previous accepted release; services were rolled back by update_config where they had a previous spec'], data };
206
+ const back = await redeployStored(ctx, config, d, store, previous, out, steps);
207
+ data.rolledBack = back.ok;
208
+ return { ok: false, lines: [...lines, ...back.lines], data };
209
+ }
210
+
211
+ /**
212
+ * Deploys a release: `options` = { images, sourceSha, execute, out, timeoutSeconds, retrySeconds, skipPublic, sshCmd }.
213
+ * skipPublic (the first deploy into a shared ingress, whose public routes exist only after `ingress publish`): the verification checks only what runs
214
+ * inside the stack, and the release stays PENDING (stored and deployed, pointer not moved) until `acceptRelease` has verified it in full.
215
+ * Returns { ok, lines, data: { releaseId, previous, steps: [{ step, ok }], rolledBack } }.
216
+ */
217
+ export async function deployRelease(ctx, config, options = {}, deps = {}) {
218
+ const d = { ...DEFAULT_DEPS, ...deps };
219
+ if (!options.sourceSha) throw refusal('STACK_DEPLOY_SOURCE_SHA', '--source-sha is required (the git commit being deployed)');
220
+ const made = options.out ? null : fs.mkdtempSync(path.join(os.tmpdir(), 'stack-release-'));
221
+ const out = options.out ?? made;
222
+ const cleanups = [];
223
+ try {
224
+ const rendered = await d.renderRelease(ctx, config, renderOptions(options, out, null));
225
+ const { releaseId } = inspectRendered(out, config);
226
+ if (options.execute !== true) {
227
+ const plan = options.skipPublic
228
+ ? `dry run: release ${releaseId} would be provisioned, stored, pre-pulled, deployed, converged and verified inside the stack (public hosts skipped); it would stay pending until ingress publish and stack accept`
229
+ : `dry run: release ${releaseId} would be provisioned, stored, pre-pulled, deployed, converged, verified, accepted`;
230
+ return { ok: true, lines: [...(rendered.lines ?? []), plan], data: { releaseId, steps: [{ step: 'render', ok: true }], executed: false } };
231
+ }
232
+ return await deployExecute(ctx, config, options, d, out, cleanups);
233
+ } finally {
234
+ cleanups.forEach((close) => close());
235
+ if (made) fs.rmSync(made, { recursive: true, force: true });
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Redeploys a stored release (default: the `previous` pointer): `options` = { release, execute, timeoutSeconds, sshCmd }.
241
+ * `release` is read from `options` only and must be null or 16 lowercase hex.
242
+ */
243
+ export async function rollbackRelease(ctx, config, options = {}, deps = {}) {
244
+ const d = { ...DEFAULT_DEPS, ...deps };
245
+ const release = options.release ?? null;
246
+ if (release !== null && !isReleaseId(release)) throw refusal('STACK_RELEASE_ID', 'the release must be 16 lowercase hex characters');
247
+ const cleanups = [];
248
+ const store = await openStore(ctx, config, options, d, cleanups);
249
+ let dir = null;
250
+ try {
251
+ const target = release ?? await store.previous();
252
+ if (!target) throw refusal('STACK_ROLLBACK_NO_TARGET', 'no previous accepted release is recorded; name one with --release');
253
+ if (options.execute !== true) return { ok: true, lines: [`dry run: would redeploy release ${target}`], data: { release: target, executed: false } };
254
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stack-rollback-'));
255
+ const lines = [];
256
+ const steps = [];
257
+ const since = isoBefore(ctx, 30);
258
+ const failed = await runSteps([
259
+ ['fetch', async () => { await store.fetch(target, dir); return { ok: true, lines: [] }; }],
260
+ ['deploy', () => stackDeploy(ctx, config, path.join(dir, 'stack.yaml'))],
261
+ ['edge', async () => d.reconcileEdge(ctx, config, path.join(dir, 'stack.yaml'))],
262
+ ['converge', () => d.convergeStack(ctx, config, { since, timeoutSeconds: options.timeoutSeconds ?? 600 })],
263
+ ['verify', () => d.verifyStack(ctx, config, { retrySeconds: 180, stackFile: path.join(dir, 'stack.yaml') })],
264
+ ['accept', async () => { await store.accept(target); return { ok: true, lines: [] }; }],
265
+ ], lines, steps);
266
+ lines.push(failed ? `rollback to ${target} failed at ${failed}` : `rolled back to release ${target}; a rollback cannot undo data effects`);
267
+ return { ok: !failed, lines, data: { release: target, executed: true, steps } };
268
+ } finally {
269
+ if (dir) fs.rmSync(dir, { recursive: true, force: true });
270
+ cleanups.forEach((close) => close());
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Accepts a release that a `skipPublic` deploy left pending: the FULL verification (public hosts and checks inside the stack) must pass first, then the
276
+ * pointer moves and the superseded secrets and configs are pruned. `options` = { release, execute, retrySeconds, sshCmd }.
277
+ */
278
+ export async function acceptRelease(ctx, config, options = {}, deps = {}) {
279
+ const d = { ...DEFAULT_DEPS, ...deps };
280
+ const release = options.release ?? null;
281
+ if (!isReleaseId(release)) throw refusal('STACK_RELEASE_ID', 'accept needs --release <16 lowercase hex characters> (the id the deploy printed)');
282
+ const cleanups = [];
283
+ const store = await openStore(ctx, config, options, d, cleanups);
284
+ const out = fs.mkdtempSync(path.join(os.tmpdir(), 'stack-accept-'));
285
+ try {
286
+ try { await store.fetch(release, out); } catch { throw refusal('STACK_ACCEPT_UNKNOWN', `release ${release} has no stored record on the manager`); }
287
+ const previous = await store.current();
288
+ if (previous === release) return { ok: true, lines: [`release ${release} is already the accepted release`], data: { release, accepted: true, executed: options.execute === true } };
289
+ if (options.execute !== true) return { ok: true, lines: [`dry run: would run the full verification (public hosts and checks inside the stack) and then accept release ${release}`], data: { release, accepted: false, executed: false } };
290
+ const verified = await d.verifyStack(ctx, config, { retrySeconds: options.retrySeconds, stackFile: path.join(out, 'stack.yaml') });
291
+ const lines = [...(verified.lines ?? [])];
292
+ if (!verified.ok) return { ok: false, lines: [...lines, `release ${release} is NOT accepted: the full verification failed`], data: { release, accepted: false, executed: true } };
293
+ await store.accept(release);
294
+ lines.push(`accepted release ${release}`);
295
+ await housekeeping(ctx, config, d, store, { previous: previous || null, out }, lines);
296
+ return { ok: true, lines, data: { release, accepted: true, executed: true } };
297
+ } finally {
298
+ fs.rmSync(out, { recursive: true, force: true });
299
+ cleanups.forEach((close) => close());
300
+ }
301
+ }
@@ -0,0 +1,16 @@
1
+ // digest.mjs - image reference and release identifier guards (the all-zero placeholder digest is never a real image).
2
+ /** A registry image reference pinned by a real sha256 digest; the all-zero placeholder digest is refused. */
3
+ export const DIGEST_REF = /^[a-z0-9./_-]+(?::[A-Za-z0-9._-]+)?@sha256:(?!0{64}$)[0-9a-f]{64}$/u;
4
+
5
+ /** A release identifier: the first 16 hex characters of the release digest. */
6
+ export const RELEASE_ID = /^[0-9a-f]{16}$/u;
7
+
8
+ /** Detects an unreplaced image digest in an image reference or in rendered stack bytes. */
9
+ export const hasPlaceholderDigest = (bytes) => /@sha256:0{64}(?![0-9a-f])/u.test(String(bytes));
10
+
11
+ /** True for a pinned image reference; `allowUnpinned` (a local throwaway swarm) still refuses the placeholder digest. */
12
+ export const isImageReference = (value, allowUnpinned = false) =>
13
+ typeof value === 'string' && value.length > 0 && !hasPlaceholderDigest(value) && (allowUnpinned || DIGEST_REF.test(value));
14
+
15
+ /** True for 16 lowercase hex characters. */
16
+ export const isReleaseId = (value) => typeof value === 'string' && RELEASE_ID.test(value);
@@ -0,0 +1,53 @@
1
+ // docker-transport.mjs - lets the verbs that drive the docker CLI (deploy, converge, verify, rollback, prune) reach the manager from an OPERATOR
2
+ // machine. In CI `connect` writes the ssh config and DOCKER_HOST; on an operator machine the key lives in the sealed members, so the docker CLI
3
+ // is run with a private HOME that holds an ssh config for the sealed session and DOCKER_HOST=ssh://<alias>. Only `docker` children that did not get
4
+ // an explicit environment are affected (the minimal environment of `docker stack config` stays exactly as the render built it). The key lives in
5
+ // a private temp directory that is overwritten and removed when the verb ends; it is never in argv, a line or an error.
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { writePrivate } from './private-file.mjs';
9
+ import { openSealedSession } from './remote.mjs';
10
+
11
+ const ALIAS = 'stack-vps';
12
+ const forward = (file) => file.replaceAll('\\', '/');
13
+
14
+ /** True when the operator's docker CLI has to be pointed at the manager by this module. */
15
+ function needed(ctx, config, sshCmd) {
16
+ if (sshCmd || ctx.env.DOCKER_HOST || ctx.env.GITHUB_ACTIONS === 'true' || ctx.env.STARCI_STACK_SEALED_SSH === '0') return false;
17
+ const { sealed } = config.remote;
18
+ if (!sealed) return false;
19
+ return [sealed.ops, sealed.sshKey, sealed.knownHosts].every((slug) => fs.existsSync(path.join(config.secretsDir, `${slug}.enc`)));
20
+ }
21
+
22
+ function sshConfig(session) {
23
+ const at = session.account.lastIndexOf('@');
24
+ return [
25
+ `Host ${ALIAS}`, ` HostName ${session.account.slice(at + 1)}`, ` User ${session.account.slice(0, at)}`,
26
+ ` IdentityFile ${forward(session.keyFile)}`, ' IdentitiesOnly yes', ` UserKnownHostsFile ${forward(session.knownFile)}`,
27
+ ` StrictHostKeyChecking ${session.strict}`, ' BatchMode yes', ' ConnectTimeout 20', '',
28
+ ].join('\n');
29
+ }
30
+
31
+ /** The environment of a docker child: a private HOME with the ssh config, DOCKER_HOST, and the operator's docker config (registry logins) kept. */
32
+ function dockerEnvironment(ctx, session) {
33
+ const home = path.join(session.dir, 'home');
34
+ fs.mkdirSync(path.join(home, '.ssh'), { recursive: true });
35
+ writePrivate(ctx, path.join(home, '.ssh', 'config'), sshConfig(session));
36
+ const operatorHome = ctx.env.HOME ?? ctx.env.USERPROFILE ?? '';
37
+ const dockerConfig = ctx.env.DOCKER_CONFIG ?? (operatorHome ? path.join(operatorHome, '.docker') : undefined);
38
+ return { ...ctx.env, HOME: home, USERPROFILE: home, DOCKER_HOST: `ssh://${ALIAS}`, ...(dockerConfig ? { DOCKER_CONFIG: dockerConfig } : {}) };
39
+ }
40
+
41
+ /** Runs `body(ctx')` where ctx'.spawn points the docker CLI at the manager when this is an operator machine; otherwise `body(ctx)`. */
42
+ export async function withDockerTransport(ctx, config, { sshCmd = null } = {}, body) {
43
+ if (!needed(ctx, config, sshCmd)) return body(ctx);
44
+ const session = openSealedSession(ctx, config);
45
+ const env = dockerEnvironment(ctx, session);
46
+ const spawn = (command, args, options = {}) => (command === 'docker' && options.env === undefined ? ctx.spawn(command, args, { ...options, env }) : ctx.spawn(command, args, options));
47
+ for (const note of session.notes) ctx.stderr(`${note}\n`);
48
+ try {
49
+ return await body(Object.create(ctx, { spawn: { value: spawn } }));
50
+ } finally {
51
+ session.cleanup();
52
+ }
53
+ }
@@ -0,0 +1,118 @@
1
+ // edge-alias.mjs - the network aliases of a service on a SHARED EXTERNAL (edge) overlay network.
2
+ // `docker stack deploy` adds the compose service name (`api`) as an implicit alias on EVERY network a service joins, whatever `aliases:` the compose
3
+ // file lists. On a network that other tenants share, `api` then resolves to this stack's service too (the 2026-10-15 incident: tayson-web proxied
4
+ // http://api:3074 and got shop_api for 17 hours). So an edge attachment must carry ONLY its declared aliases:
5
+ // edgeAttachments the services of a rendered (or source) stack attached to an external network, with their declared aliases
6
+ // edgeCollisions read-only preflight: does a short service name equal a name another container or service of that network resolves?
7
+ // reconcileEdgeAliases after `docker stack deploy`: `service update --network-rm <edge>` then `--network-add name=<edge>,alias=<declared>`
8
+ // verifyEdgeAttachment the service spec carries exactly the declared aliases (used by `stack verify`)
9
+ // Docker is reached through the same seams as the neighbouring steps: ctx.spawn (the docker transport) for the deploy-time steps, and the ssh runner
10
+ // with shell-quoted arguments for the read-only preflight.
11
+ import fs from 'node:fs';
12
+ import { parseYaml } from '../runtime/engine/yaml.mjs';
13
+ import { toolOutput } from './context.mjs';
14
+ import { refusal } from './errors.mjs';
15
+ import { remoteDocker } from './remote.mjs';
16
+
17
+ const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/u;
18
+ const NETWORK_FORMAT = '{{json .}}';
19
+
20
+ const networkMap = (value) => (Array.isArray(value) ? Object.fromEntries(value.map((name) => [name, null])) : value ?? {});
21
+ const sameSet = (left, right) => [...new Set(left)].sort().join('\n') === [...new Set(right)].sort().join('\n');
22
+ const show = (aliases) => `[${aliases.join(', ')}]`;
23
+
24
+ /** [{ service, network, aliases }]: every service attached to an `external: true` network of the stack, with the aliases it declares. */
25
+ export function edgeAttachments(bytes) {
26
+ const model = parseYaml(bytes) ?? {};
27
+ const external = new Map(Object.entries(model.networks ?? {}).filter(([, definition]) => definition?.external === true).map(([key, definition]) => [key, String(definition.name ?? key)]));
28
+ return Object.entries(model.services ?? {}).flatMap(([service, entry]) => Object.entries(networkMap(entry?.networks))
29
+ .filter(([key]) => external.has(key))
30
+ .map(([key, attachment]) => ({ service, network: external.get(key), aliases: (attachment?.aliases ?? []).map(String) })));
31
+ }
32
+
33
+ /** The attachments of the stack file `file`. */
34
+ export const edgeAttachmentsOf = (file) => edgeAttachments(fs.readFileSync(file, 'utf8'));
35
+
36
+ function safe(attachment) {
37
+ for (const name of [attachment.service, attachment.network, ...attachment.aliases]) {
38
+ if (!SAFE_NAME.test(name)) throw refusal('STACK_DEPLOY_EDGE_NAME', `the name ${JSON.stringify(name)} (service ${attachment.service}, network ${attachment.network}) is not a plain docker name`);
39
+ }
40
+ }
41
+
42
+ function parseJson(text) {
43
+ try { return JSON.parse(text); } catch { return null; }
44
+ }
45
+
46
+ /** The names of the containers and services attached to the external network, read-only through the ssh runner (`docker network inspect`). */
47
+ async function networkNames(run, network) {
48
+ const result = await remoteDocker(run, ['network', 'inspect', network, '--verbose', '--format', NETWORK_FORMAT]);
49
+ const model = result.status === 0 ? parseJson(result.stdout) : null;
50
+ if (model === null || typeof model !== 'object') throw refusal('STACK_DEPLOY_EDGE_UNREADABLE', `cannot read the external network ${network} (docker network inspect failed): the edge network must exist before a deploy; nothing was changed`);
51
+ return [...Object.values(model.Containers ?? {}).map((container) => String(container.Name)), ...Object.keys(model.Services ?? {})];
52
+ }
53
+
54
+ /**
55
+ * Read-only: for each external network of `bytes`, which implicit short service names (`api` for shop_api) another container or service of that
56
+ * network already resolves. Returns { networks, clashes: [{ service, alias, network, holder }] }; throws STACK_DEPLOY_EDGE_UNREADABLE when a
57
+ * network cannot be inspected. This stack's own services (`<stack>_*`) are not "others".
58
+ */
59
+ export async function edgeCollisions(run, config, bytes) {
60
+ const attachments = edgeAttachments(bytes);
61
+ const networks = [...new Set(attachments.map((item) => item.network))];
62
+ const taken = await Promise.all(networks.map(async (network) => [network, (await networkNames(run, network)).filter((name) => !name.startsWith(`${config.stack}_`))]));
63
+ const clashes = taken.flatMap(([network, others]) => attachments
64
+ .filter((item) => item.network === network && others.includes(item.service))
65
+ .map((item) => ({ service: `${config.stack}_${item.service}`, alias: item.service, network, holder: item.service })));
66
+ return { networks, clashes };
67
+ }
68
+
69
+ /** The refusal text of one clash. */
70
+ export const describeClash = (clash) => `service ${clash.service} would carry the implicit short-name alias ${clash.alias} on the external network ${clash.network}, which ${clash.holder} (another tenant) already resolves: rename the service or the other name; nothing was changed`;
71
+
72
+ const docker = (ctx, args) => toolOutput(ctx, 'docker', args);
73
+
74
+ const networkId = (ctx, network) => docker(ctx, ['network', 'inspect', network, '--format', '{{.Id}}']).trim();
75
+
76
+ /** The edge entry of the service spec ({ Target, Aliases }) or null when the service is not attached. */
77
+ function edgeEntry(ctx, service, id) {
78
+ const inspect = JSON.parse(docker(ctx, ['service', 'inspect', service, '--format', '{{json .}}']));
79
+ return (inspect.Spec?.TaskTemplate?.Networks ?? []).find((entry) => entry.Target === id) ?? null;
80
+ }
81
+
82
+ const aliasesOf = (entry) => (entry === null ? [] : entry.Aliases ?? []);
83
+
84
+ /**
85
+ * After `docker stack deploy`: makes the edge attachment of every edge-attached service carry exactly its declared aliases. `docker service update`
86
+ * ignores a `--network-add` of an attached network, so the attachment is removed first and added again with the declared aliases. Idempotent (an exact
87
+ * attachment is left alone). Throws STACK_DEPLOY_EDGE_ALIAS when the aliases still differ afterwards. Returns { ok, lines }.
88
+ */
89
+ export function reconcileEdgeAliases(ctx, config, file) {
90
+ const lines = [];
91
+ const ids = new Map();
92
+ for (const item of edgeAttachmentsOf(file)) {
93
+ safe(item);
94
+ const service = `${config.stack}_${item.service}`;
95
+ if (!ids.has(item.network)) ids.set(item.network, networkId(ctx, item.network));
96
+ const id = ids.get(item.network);
97
+ const before = edgeEntry(ctx, service, id);
98
+ if (before !== null && sameSet(aliasesOf(before), item.aliases)) {
99
+ lines.push(`edge: ${service} on ${item.network}: aliases ${show(item.aliases)} (already exact)`);
100
+ continue;
101
+ }
102
+ if (before !== null) docker(ctx, ['service', 'update', '--detach=true', '--network-rm', item.network, service]);
103
+ docker(ctx, ['service', 'update', '--detach=true', '--network-add', ['name=' + item.network, ...item.aliases.map((alias) => `alias=${alias}`)].join(','), service]);
104
+ const after = aliasesOf(edgeEntry(ctx, service, id));
105
+ if (!sameSet(after, item.aliases)) throw refusal('STACK_DEPLOY_EDGE_ALIAS', `${service} carries the aliases ${show(after)} on ${item.network} after the reconcile, declared ${show(item.aliases)}`);
106
+ lines.push(`edge: ${service} on ${item.network}: aliases ${show(aliasesOf(before))} -> ${show(item.aliases)}`);
107
+ }
108
+ return { ok: true, lines };
109
+ }
110
+
111
+ /** `stack verify`: throws when the service is not attached to the edge network or carries any alias other than the declared ones. */
112
+ export function verifyEdgeAttachment(ctx, config, item) {
113
+ const service = `${config.stack}_${item.service}`;
114
+ const entry = edgeEntry(ctx, service, networkId(ctx, item.network));
115
+ if (entry === null) throw new Error(`${service} is not attached to ${item.network}`);
116
+ if (!sameSet(aliasesOf(entry), item.aliases)) throw new Error(`${service} carries the aliases ${show(aliasesOf(entry))} on ${item.network}, declared ${show(item.aliases)}`);
117
+ return `aliases ${show(item.aliases)}`;
118
+ }
@@ -0,0 +1,21 @@
1
+ // errors.mjs - the one error type of the stack tooling and its exit codes.
2
+ export const EXIT = Object.freeze({ ok: 0, failed: 1, refused: 2, approval: 3 });
3
+
4
+ /** A refusal or failure with a stable code; `exit` is the process exit code the CLI maps it to. */
5
+ export class StackError extends Error {
6
+ constructor(code, message, exit = EXIT.refused) {
7
+ super(`${code}: ${message}`);
8
+ this.name = 'StackError';
9
+ this.code = code;
10
+ this.exit = exit;
11
+ }
12
+ }
13
+
14
+ /** A failed step (a check or an effect did not succeed): exit 1. */
15
+ export const failure = (code, message) => new StackError(code, message, EXIT.failed);
16
+
17
+ /** A refusal (bad usage, unsafe or unknown input, missing configuration): exit 2. */
18
+ export const refusal = (code, message) => new StackError(code, message, EXIT.refused);
19
+
20
+ /** The owner has not approved a one-time effect: exit 3. */
21
+ export const approvalMissing = (code, message) => new StackError(code, message, EXIT.approval);