@starci/hfs 4.2.17 → 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 (564) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +7 -0
  3. package/db/migrate.mjs +361 -0
  4. package/db/post-cutover.mjs +136 -0
  5. package/emit/db-types.mjs +10 -1
  6. package/lint/run.mjs +1 -1
  7. package/package.json +4 -1
  8. package/release/gate.mjs +298 -0
  9. package/release/index.mjs +103 -0
  10. package/release/push.mjs +110 -0
  11. package/runtime/knowledge/hfs/canon-pins.yaml +11 -11
  12. package/runtime/knowledge/hfs/fe-stack.yaml +22 -1
  13. package/runtime/knowledge/hfs/rules.yaml +3437 -160
  14. package/runtime/knowledge/hfs/slots.yaml +430 -92
  15. package/runtime/knowledge/patterns/be/api.yaml +92 -2
  16. package/runtime/knowledge/patterns/be/cli.yaml +37 -9
  17. package/runtime/knowledge/patterns/be/event-bus.yaml +3 -3
  18. package/runtime/knowledge/patterns/be/jobs.yaml +20 -2
  19. package/runtime/knowledge/patterns/be/projections.yaml +4 -2
  20. package/runtime/knowledge/patterns/be/queues.yaml +11 -3
  21. package/runtime/knowledge/patterns/be/reactors.yaml +20 -1
  22. package/runtime/knowledge/patterns/be/realtime.yaml +25 -3
  23. package/runtime/knowledge/patterns/be/saga.yaml +20 -3
  24. package/runtime/knowledge/patterns/be/webhooks.yaml +210 -36
  25. package/runtime/knowledge/patterns/fe/folder.yaml +4 -0
  26. package/runtime/knowledge/sonar-gate.yaml +1 -1
  27. package/runtime/modules/kernel/failure-codes.yaml +3128 -47
  28. package/runtime/modules/models/runtimes.yaml +1 -1
  29. package/runtime/modules/schemas/application-stacks.schema.yaml +663 -0
  30. package/runtime/packages/cli/src/catalog.generated.mjs +14104 -0
  31. package/runtime/packages/cli/src/validate-args.mjs +213 -0
  32. package/runtime/scripts/api/git/add.mjs +5 -0
  33. package/runtime/scripts/api/git/cat-file.mjs +5 -0
  34. package/runtime/scripts/api/git/init.mjs +7 -0
  35. package/runtime/scripts/api/git/lib.mjs +3 -0
  36. package/runtime/scripts/api/git/tag.mjs +5 -0
  37. package/runtime/scripts/api/node/lib.mjs +20 -0
  38. package/runtime/scripts/api/node/run-node.mjs +6 -0
  39. package/runtime/scripts/api/sops/lib.mjs +41 -0
  40. package/runtime/scripts/api/sops/resolve-sops.mjs +7 -0
  41. package/runtime/scripts/api/sops/seal.mjs +1 -1
  42. package/runtime/scripts/api/sops/selected-age-recipient.mjs +7 -0
  43. package/runtime/scripts/api/supabase/database-dump.mjs +5 -0
  44. package/runtime/scripts/api/supabase/database-query.mjs +5 -0
  45. package/runtime/scripts/api/supabase/lib.mjs +44 -0
  46. package/runtime/scripts/api/supabase/migration-repair.mjs +5 -0
  47. package/runtime/scripts/hfs/app-wiring.mjs +201 -0
  48. package/runtime/scripts/hfs/architecture/backend-app-files.mjs +103 -0
  49. package/runtime/scripts/hfs/architecture/backend.mjs +22 -10
  50. package/runtime/scripts/hfs/architecture/background-unowned.mjs +157 -41
  51. package/runtime/scripts/hfs/architecture/canon-clones.mjs +240 -0
  52. package/runtime/scripts/hfs/architecture/canon-jsx-clones.mjs +143 -0
  53. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +6 -3
  54. package/runtime/scripts/hfs/architecture/clones.mjs +17 -8
  55. package/runtime/scripts/hfs/architecture/connection-map.mjs +14 -12
  56. package/runtime/scripts/hfs/architecture/context-map.mjs +25 -14
  57. package/runtime/scripts/hfs/architecture/context-owner.mjs +1 -2
  58. package/runtime/scripts/hfs/architecture/dead-exports-bindings.mjs +149 -0
  59. package/runtime/scripts/hfs/architecture/dead-exports.mjs +23 -50
  60. package/runtime/scripts/hfs/architecture/default-deny.mjs +8 -6
  61. package/runtime/scripts/hfs/architecture/frontend-block-render.mjs +104 -0
  62. package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +30 -11
  63. package/runtime/scripts/hfs/architecture/frontend-world.mjs +1 -1
  64. package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +153 -0
  65. package/runtime/scripts/hfs/architecture/hfs.mjs +3 -3
  66. package/runtime/scripts/hfs/architecture/i18n-keys.mjs +16 -3
  67. package/runtime/scripts/hfs/architecture/machine-ast.mjs +17 -0
  68. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +49 -25
  69. package/runtime/scripts/hfs/architecture/next-data-inference.mjs +176 -0
  70. package/runtime/scripts/hfs/architecture/next-data-symbols.mjs +26 -0
  71. package/runtime/scripts/hfs/architecture/next-data.mjs +31 -23
  72. package/runtime/scripts/hfs/architecture/reachability.mjs +14 -0
  73. package/runtime/scripts/hfs/architecture/register-once.mjs +15 -7
  74. package/runtime/scripts/hfs/architecture/required-files.mjs +2 -1
  75. package/runtime/scripts/hfs/architecture/source-names.mjs +48 -3
  76. package/runtime/scripts/hfs/architecture/staged-consumers.mjs +37 -0
  77. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +214 -17
  78. package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -6
  79. package/runtime/scripts/hfs/architecture/supabase.mjs +59 -21
  80. package/runtime/scripts/hfs/architecture/surface.mjs +2 -1
  81. package/runtime/scripts/hfs/architecture/tiers.mjs +11 -4
  82. package/runtime/scripts/hfs/architecture/typescript.mjs +1 -8
  83. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +9 -13
  84. package/runtime/scripts/hfs/check-results.mjs +28 -0
  85. package/runtime/scripts/hfs/check.mjs +63 -72
  86. package/runtime/scripts/hfs/coverage-from.mjs +70 -0
  87. package/runtime/scripts/hfs/db-baseline-verb.mjs +276 -0
  88. package/runtime/scripts/hfs/db-plan-verb.mjs +254 -0
  89. package/runtime/scripts/hfs/db-restamp-verb.mjs +60 -0
  90. package/runtime/scripts/hfs/declaration-shape.mjs +39 -3
  91. package/runtime/scripts/hfs/doc-commands.mjs +367 -0
  92. package/runtime/scripts/hfs/edition-slots.mjs +2 -2
  93. package/runtime/scripts/hfs/example-secrets.mjs +158 -0
  94. package/runtime/scripts/hfs/manifest-shape.mjs +91 -6
  95. package/runtime/scripts/hfs/path-findings.mjs +32 -2
  96. package/runtime/scripts/hfs/refusal-codes.mjs +6 -0
  97. package/runtime/scripts/hfs/rule-catalog.mjs +156 -11
  98. package/runtime/scripts/hfs/rule-params-shape.mjs +22 -10
  99. package/runtime/scripts/hfs/rules/a15-findings.mjs +88 -0
  100. package/runtime/scripts/hfs/rules/app-env-required.mjs +20 -0
  101. package/runtime/scripts/hfs/rules/app-root.mjs +2 -2
  102. package/runtime/scripts/hfs/rules/assets.mjs +79 -0
  103. package/runtime/scripts/hfs/rules/boot-proof.mjs +16 -0
  104. package/runtime/scripts/hfs/rules/canon-copy-drift.mjs +59 -0
  105. package/runtime/scripts/hfs/rules/contract.mjs +66 -5
  106. package/runtime/scripts/hfs/rules/coverage-below.mjs +230 -0
  107. package/runtime/scripts/hfs/rules/database-access.mjs +343 -0
  108. package/runtime/scripts/hfs/rules/database-baseline.mjs +142 -0
  109. package/runtime/scripts/hfs/rules/database-config.mjs +250 -2
  110. package/runtime/scripts/hfs/rules/database-constants.mjs +9 -0
  111. package/runtime/scripts/hfs/rules/database-migrations.mjs +317 -37
  112. package/runtime/scripts/hfs/rules/database-plpgsql.mjs +119 -9
  113. package/runtime/scripts/hfs/rules/database-queue-sources.mjs +176 -0
  114. package/runtime/scripts/hfs/rules/database-queues.mjs +361 -0
  115. package/runtime/scripts/hfs/rules/database-sql.mjs +91 -9
  116. package/runtime/scripts/hfs/rules/database.mjs +11 -2
  117. package/runtime/scripts/hfs/rules/deploy-scan.mjs +192 -0
  118. package/runtime/scripts/hfs/rules/deploy-workflow.mjs +219 -0
  119. package/runtime/scripts/hfs/rules/doc-fresh.mjs +286 -0
  120. package/runtime/scripts/hfs/rules/docker-health.mjs +249 -0
  121. package/runtime/scripts/hfs/rules/docker.mjs +24 -7
  122. package/runtime/scripts/hfs/rules/edition.mjs +11 -10
  123. package/runtime/scripts/hfs/rules/env-parity.mjs +373 -0
  124. package/runtime/scripts/hfs/rules/env-value-shape.mjs +113 -0
  125. package/runtime/scripts/hfs/rules/event-bus.mjs +3 -2
  126. package/runtime/scripts/hfs/rules/event-tables.mjs +100 -0
  127. package/runtime/scripts/hfs/rules/fe-contract-documents.mjs +115 -15
  128. package/runtime/scripts/hfs/rules/fe-stack.mjs +57 -0
  129. package/runtime/scripts/hfs/rules/feature-files.mjs +28 -0
  130. package/runtime/scripts/hfs/rules/frontend-tree.mjs +54 -35
  131. package/runtime/scripts/hfs/rules/integration-specs.mjs +3 -2
  132. package/runtime/scripts/hfs/rules/lexicon.mjs +62 -0
  133. package/runtime/scripts/hfs/rules/lite-proof-lane.mjs +89 -0
  134. package/runtime/scripts/hfs/rules/monorepo.mjs +14 -1
  135. package/runtime/scripts/hfs/rules/pipeline.mjs +8 -1
  136. package/runtime/scripts/hfs/rules/proof-commands.mjs +2 -0
  137. package/runtime/scripts/hfs/rules/release-provenance.mjs +211 -0
  138. package/runtime/scripts/hfs/rules/saga-delivery.mjs +66 -0
  139. package/runtime/scripts/hfs/rules/saga.mjs +8 -16
  140. package/runtime/scripts/hfs/rules/script-hygiene.mjs +151 -0
  141. package/runtime/scripts/hfs/rules/secret-custody-only.mjs +65 -0
  142. package/runtime/scripts/hfs/rules/secrets.mjs +26 -0
  143. package/runtime/scripts/hfs/rules/services.mjs +4 -4
  144. package/runtime/scripts/hfs/rules/stack-tooling.mjs +93 -0
  145. package/runtime/scripts/hfs/rules/stacks-deploy.mjs +76 -0
  146. package/runtime/scripts/hfs/rules/stacks-ingress.mjs +87 -0
  147. package/runtime/scripts/hfs/rules/stacks-lite-backing.mjs +111 -0
  148. package/runtime/scripts/hfs/rules/stacks-release.mjs +366 -0
  149. package/runtime/scripts/hfs/rules/stacks-runtime.mjs +168 -0
  150. package/runtime/scripts/hfs/rules/stacks-swarm.mjs +417 -0
  151. package/runtime/scripts/hfs/rules/stacks.mjs +13 -8
  152. package/runtime/scripts/hfs/rules/supabase-secrets.mjs +1 -1
  153. package/runtime/scripts/hfs/rules/supply-chain.mjs +167 -0
  154. package/runtime/scripts/hfs/rules/unused-symbol-flow.mjs +101 -0
  155. package/runtime/scripts/hfs/rules/unused-symbol-ledger.mjs +77 -0
  156. package/runtime/scripts/hfs/rules/unused-symbol-translators.mjs +165 -0
  157. package/runtime/scripts/hfs/rules/unused-symbol-values.mjs +103 -0
  158. package/runtime/scripts/hfs/rules/unused-symbol.mjs +320 -0
  159. package/runtime/scripts/hfs/runtime-rules/source-ast.mjs +185 -0
  160. package/runtime/scripts/hfs/runtime-rules/workflow-source.mjs +19 -0
  161. package/runtime/scripts/hfs/sealed-members.mjs +101 -0
  162. package/runtime/scripts/hfs/secret-bootstrap-crypto.mjs +200 -0
  163. package/runtime/scripts/hfs/secret-document.mjs +97 -0
  164. package/runtime/scripts/hfs/secret-edit.mjs +170 -0
  165. package/runtime/scripts/hfs/secret-errors.mjs +20 -0
  166. package/runtime/scripts/hfs/secret-files.mjs +124 -0
  167. package/runtime/scripts/hfs/secret-init.mjs +300 -0
  168. package/runtime/scripts/hfs/secret-output.mjs +100 -0
  169. package/runtime/scripts/hfs/secret-sops.mjs +130 -0
  170. package/runtime/scripts/hfs/secret-store.mjs +163 -0
  171. package/runtime/scripts/hfs/secret.mjs +162 -193
  172. package/runtime/scripts/hfs/slot-imports.mjs +18 -3
  173. package/runtime/scripts/hfs/slot-required.mjs +13 -0
  174. package/runtime/scripts/hfs/slot-semantic-problems.mjs +2 -1
  175. package/runtime/scripts/hfs/slots.mjs +1 -0
  176. package/runtime/scripts/hfs/stack-config-kit.mjs +34 -0
  177. package/runtime/scripts/hfs/stack-deploy-config.mjs +313 -0
  178. package/runtime/scripts/hfs/stack-ingress-config.mjs +237 -0
  179. package/runtime/scripts/hfs/stacks-gate.mjs +414 -0
  180. package/runtime/scripts/hfs/starcistacks-runtime.mjs +61 -0
  181. package/runtime/scripts/hfs/tree-findings.mjs +22 -0
  182. package/runtime/scripts/lib/command-tokens.mjs +126 -0
  183. package/runtime/scripts/lib/compose-values.mjs +11 -0
  184. package/runtime/scripts/lib/deployment-findings.mjs +7 -0
  185. package/runtime/scripts/lib/json-clone.mjs +61 -0
  186. package/runtime/scripts/lib/list.mjs +4 -0
  187. package/runtime/scripts/lib/roots.mjs +59 -0
  188. package/runtime/scripts/lib/runtime-example.mjs +13 -0
  189. package/runtime/scripts/lib/secret-patterns.mjs +60 -5
  190. package/runtime/scripts/lib/sql-ast.mjs +32 -0
  191. package/runtime/scripts/lib/stack-services.mjs +1 -1
  192. package/runtime/scripts/lib/swarm-stack.mjs +126 -0
  193. package/runtime/scripts/lib/trim-end.mjs +8 -0
  194. package/runtime/scripts/lib/ts-analysis.mjs +23 -0
  195. package/runtime/scripts/lib/ts-ast.mjs +27 -2
  196. package/runtime/scripts/lib/workflow-events.mjs +8 -0
  197. package/scaffold/add-app.mjs +77 -59
  198. package/scaffold/add-cli-lite.mjs +24 -17
  199. package/scaffold/add-compose.mjs +79 -0
  200. package/scaffold/add-database-lite.mjs +14 -0
  201. package/scaffold/add-fe-overlay.mjs +122 -0
  202. package/scaffold/add-fe-page.mjs +269 -0
  203. package/scaffold/add-inbox-lite.mjs +87 -124
  204. package/scaffold/add-plan.mjs +183 -0
  205. package/scaffold/add-table.mjs +132 -70
  206. package/scaffold/add.mjs +186 -204
  207. package/scaffold/app.mjs +128 -43
  208. package/scaffold/edition-gate.mjs +3 -3
  209. package/scaffold/image.mjs +2 -2
  210. package/scaffold/lite-exports.mjs +2 -1
  211. package/scaffold/service.mjs +2 -2
  212. package/src/main.mjs +238 -119
  213. package/stack/age-install.mjs +37 -0
  214. package/stack/backup.mjs +100 -0
  215. package/stack/build-args.mjs +42 -0
  216. package/stack/build.mjs +173 -0
  217. package/stack/cli.mjs +130 -0
  218. package/stack/config.mjs +57 -0
  219. package/stack/context.mjs +57 -0
  220. package/stack/converge.mjs +107 -0
  221. package/stack/deploy.mjs +301 -0
  222. package/stack/digest.mjs +16 -0
  223. package/stack/docker-transport.mjs +53 -0
  224. package/stack/edge-alias.mjs +118 -0
  225. package/stack/errors.mjs +21 -0
  226. package/stack/image-scan.mjs +152 -0
  227. package/stack/ingress-create.mjs +114 -0
  228. package/stack/ingress-publish.mjs +273 -0
  229. package/stack/ingress.mjs +706 -0
  230. package/stack/jobs.mjs +51 -0
  231. package/stack/live-restore.mjs +177 -0
  232. package/stack/prepare.mjs +266 -0
  233. package/stack/private-file.mjs +22 -0
  234. package/stack/provision.mjs +90 -0
  235. package/stack/prune.mjs +46 -0
  236. package/stack/release-store.mjs +87 -0
  237. package/stack/remote.mjs +205 -0
  238. package/stack/render.mjs +149 -0
  239. package/stack/retire.mjs +47 -0
  240. package/stack/sealed.mjs +149 -0
  241. package/stack/task.mjs +42 -0
  242. package/stack/verify.mjs +99 -0
  243. package/sync/ci-commands.mjs +13 -0
  244. package/sync/index.mjs +127 -64
  245. package/sync/stack-images.mjs +127 -0
  246. package/templates/app/ci-workflows/github/workflows/ci.yml +5 -1
  247. package/templates/app/ci-workflows/github/workflows/images.yml +15 -3
  248. package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +148 -0
  249. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +7 -3
  250. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +38 -6
  251. package/templates/app/ci-workflows-lite/github/workflows/images.yml +14 -2
  252. package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +148 -0
  253. package/templates/app/codegen-graphql.mjs.partial +13 -2
  254. package/templates/app/codegen-lite/scripts/codegen.mjs +6 -1
  255. package/templates/app/codegen-prelude.mjs.partial +8 -0
  256. package/templates/app/gitignore +4 -0
  257. package/templates/app/gitignore-lite +4 -0
  258. package/templates/app/hooks/husky/pre-commit +7 -12
  259. package/templates/app/package-scripts/package.json +1 -0
  260. package/templates/app/package-scripts/supabase.json +7 -0
  261. package/templates/app/package-scripts-lite/package.json +1 -7
  262. package/templates/app/skeleton/.editorconfig +1 -0
  263. package/templates/app/skeleton/.gitattributes +7 -1
  264. package/templates/app/skeleton/.github/dependabot.yml +17 -0
  265. package/templates/app/skeleton/.nvmrc +1 -1
  266. package/templates/app/skeleton/.sops.yaml +1 -1
  267. package/templates/app/skeleton/.starcistacks/application-stacks.yaml +134 -1
  268. package/templates/app/skeleton/.starcistacks/dev/README.md +16 -0
  269. package/templates/app/skeleton/.starcistacks/dev/environment.json +5 -0
  270. package/templates/app/skeleton/.starcistacks/dev/infra/compose/stack.yaml +71 -0
  271. package/templates/app/skeleton/.starcistacks/dev/runtime/env/KEYS.md +14 -0
  272. package/templates/app/skeleton/.starcistacks/production/README.md +37 -0
  273. package/templates/app/skeleton/.starcistacks/production/environment.json +5 -0
  274. package/templates/app/skeleton/.starcistacks/production/infra/swarm/Caddyfile +15 -0
  275. package/templates/app/skeleton/.starcistacks/production/infra/swarm/owner-actions.md +11 -0
  276. package/templates/app/skeleton/.starcistacks/production/infra/swarm/recovery/restore-proof.md +7 -0
  277. package/templates/app/skeleton/.starcistacks/production/infra/swarm/releases/current.yaml +6 -0
  278. package/templates/app/skeleton/.starcistacks/production/infra/swarm/releases/images.lock.yaml +9 -0
  279. package/templates/app/skeleton/.starcistacks/production/infra/swarm/releases/previous.yaml +6 -0
  280. package/templates/app/skeleton/.starcistacks/production/infra/swarm/stack.yaml +142 -0
  281. package/templates/app/skeleton/.starcistacks/production/runtime/env/KEYS.md +14 -0
  282. package/templates/app/skeleton/README.md +6 -6
  283. package/templates/app/skeleton/scripts/codegen.mjs +1 -2
  284. package/templates/app/skeleton-lite/.editorconfig +16 -0
  285. package/templates/app/skeleton-lite/.gitattributes +8 -0
  286. package/templates/app/skeleton-lite/.github/dependabot.yml +17 -0
  287. package/templates/app/skeleton-lite/.nvmrc +1 -0
  288. package/templates/app/skeleton-lite/.sops.yaml +3 -2
  289. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +193 -0
  290. package/templates/app/skeleton-lite/.starcistacks/dev/README.md +20 -0
  291. package/templates/app/skeleton-lite/.starcistacks/dev/environment.json +2 -1
  292. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/Caddyfile +16 -0
  293. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/owner-actions.md +11 -0
  294. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/recovery/restore-proof.md +7 -0
  295. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/releases/current.yaml +6 -0
  296. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/releases/images.lock.yaml +8 -0
  297. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/releases/previous.yaml +6 -0
  298. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +120 -0
  299. package/templates/app/skeleton-lite/.starcistacks/dev/runtime/env/KEYS.md +18 -0
  300. package/templates/app/skeleton-lite/.starcistacks/production/README.md +38 -0
  301. package/templates/app/skeleton-lite/.starcistacks/production/environment.json +6 -0
  302. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/Caddyfile +11 -0
  303. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/owner-actions.md +11 -0
  304. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/recovery/restore-proof.md +7 -0
  305. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/releases/current.yaml +6 -0
  306. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/releases/images.lock.yaml +8 -0
  307. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/releases/previous.yaml +6 -0
  308. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +121 -0
  309. package/templates/app/skeleton-lite/.starcistacks/production/runtime/env/KEYS.md +21 -0
  310. package/templates/app/skeleton-lite/.starciwork/brand/index.yaml +12 -0
  311. package/templates/app/skeleton-lite/.starciwork/workspace.yaml +9 -0
  312. package/templates/app/skeleton-lite/README.md +8 -4
  313. package/templates/app/skeleton-lite/supabase/config.toml +8 -1
  314. package/templates/app/skeleton-lite/supabase/migrations/__timestamp___baseline.sql +15 -6
  315. package/templates/app/starciwork.gitignore-lite +6 -0
  316. package/templates/app/upgrade-full/.starcistacks/dev/README.md +13 -7
  317. package/templates/be/image/worker/Dockerfile +1 -1
  318. package/templates/be/inbox/database-client.spec.ts.tpl +22 -28
  319. package/templates/be/inbox/database-client.ts.tpl +14 -28
  320. package/templates/be/inbox/delivery-recorded.event.ts.tpl +36 -0
  321. package/templates/be/inbox/event-index.ts.tpl +1 -0
  322. package/templates/be/inbox/events.json.tpl +10 -0
  323. package/templates/be/inbox/migration.sql.tpl +1 -0
  324. package/templates/be/inbox/platform/event-bus/event-bus.contracts.ts.tpl +42 -0
  325. package/templates/be/inbox/platform/event-bus/event-bus.decorators.ts.tpl +9 -0
  326. package/templates/be/inbox/platform/event-bus/event-bus.module-definition.ts.tpl +6 -0
  327. package/templates/be/inbox/platform/event-bus/event-bus.module.ts.tpl +19 -0
  328. package/templates/be/inbox/platform/event-bus/event-bus.options.ts.tpl +2 -0
  329. package/templates/be/inbox/platform/event-bus/event-bus.port.ts.tpl +16 -0
  330. package/templates/be/inbox/platform/event-bus/event-bus.service.spec.ts.tpl +45 -0
  331. package/templates/be/inbox/platform/event-bus/event-bus.service.ts.tpl +15 -0
  332. package/templates/be/inbox/platform/event-bus/index.ts.tpl +5 -0
  333. package/templates/be/inbox/platform/event-bus/persistence/event-bus.sql.ts.tpl +5 -0
  334. package/templates/be/inbox/platform/inbox/inbox.decorators.ts.tpl +9 -0
  335. package/templates/be/inbox/platform/inbox/inbox.module-definition.ts.tpl +6 -0
  336. package/templates/be/inbox/platform/inbox/inbox.module.ts.tpl +19 -0
  337. package/templates/be/inbox/platform/inbox/inbox.options.ts.tpl +2 -0
  338. package/templates/be/inbox/platform/inbox/inbox.port.ts.tpl +10 -0
  339. package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +41 -0
  340. package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +19 -0
  341. package/templates/be/inbox/platform/inbox/index.ts.tpl +3 -0
  342. package/templates/be/inbox/platform/inbox/persistence/inbox.sql.ts.tpl +7 -0
  343. package/templates/be/inbox/platform/migration.sql.tpl +48 -0
  344. package/templates/be/inbox/service.spec.ts.tpl +49 -20
  345. package/templates/be/inbox/service.ts.tpl +27 -16
  346. package/templates/be/inbox/sql.ts.tpl +2 -2
  347. package/templates/be/inbox/webhook-door.spec.ts.tpl +3 -3
  348. package/templates/be/inbox/webhook-door.ts.tpl +4 -3
  349. package/templates/be/patterns/api/feature.module-definition.ts.tpl +6 -0
  350. package/templates/be/patterns/api/feature.module.ts.tpl +2 -1
  351. package/templates/be/patterns/api/feature.options.ts.tpl +2 -0
  352. package/templates/be/patterns/api/graphql.module-definition.ts.tpl +6 -0
  353. package/templates/be/patterns/api/graphql.module.ts.tpl +3 -2
  354. package/templates/be/patterns/api/graphql.options.ts.tpl +2 -0
  355. package/templates/be/patterns/cli/group.module-definition.ts.tpl +6 -0
  356. package/templates/be/patterns/cli/group.module.ts.tpl +2 -1
  357. package/templates/be/patterns/cli/group.options.ts.tpl +2 -0
  358. package/templates/be/patterns/event-bus/platform/errors/event-bus.error.ts.tpl +0 -3
  359. package/templates/be/patterns/event-bus/platform/messages/event-bus.messages.ts.tpl +0 -2
  360. package/templates/be/patterns/jobs/job.module-definition.ts.tpl +6 -0
  361. package/templates/be/patterns/jobs/job.module.ts.tpl +2 -1
  362. package/templates/be/patterns/jobs/job.options.ts.tpl +2 -0
  363. package/templates/be/patterns/jobs/job.queue.module-definition.ts.tpl +6 -0
  364. package/templates/be/patterns/jobs/job.queue.module.ts.tpl +6 -3
  365. package/templates/be/patterns/jobs/job.queue.options.ts.tpl +2 -0
  366. package/templates/be/patterns/jobs/platform/fenced.processor.spec.ts.tpl +3 -1
  367. package/templates/be/patterns/outbox/platform/outbox-relay.policy.spec.ts.tpl +25 -9
  368. package/templates/be/patterns/queues/platform/queue.config.ts.tpl +1 -1
  369. package/templates/be/patterns/queues/platform/queue.index.ts.tpl +1 -1
  370. package/templates/be/patterns/queues/platform/queue.module-definition.ts.tpl +2 -2
  371. package/templates/be/patterns/queues/platform/queue.module.ts.tpl +11 -0
  372. package/templates/be/patterns/queues/platform/queue.options.ts.tpl +10 -0
  373. package/templates/be/patterns/queues/queue.module-definition.ts.tpl +6 -0
  374. package/templates/be/patterns/queues/queue.module.ts.tpl +2 -1
  375. package/templates/be/patterns/queues/queue.options.ts.tpl +2 -0
  376. package/templates/be/patterns/reactors/message.module-definition.ts.tpl +6 -0
  377. package/templates/be/patterns/reactors/message.module.ts.tpl +6 -3
  378. package/templates/be/patterns/reactors/message.options.ts.tpl +2 -0
  379. package/templates/be/patterns/reactors/reactor.module-definition.ts.tpl +6 -0
  380. package/templates/be/patterns/reactors/reactor.module.ts.tpl +2 -1
  381. package/templates/be/patterns/reactors/reactor.options.ts.tpl +2 -0
  382. package/templates/be/patterns/realtime/channel.gateway.spec.ts.tpl +188 -0
  383. package/templates/be/patterns/realtime/channel.gateway.ts.tpl +22 -0
  384. package/templates/be/patterns/realtime/channel.graphql.module-definition.ts.tpl +6 -0
  385. package/templates/be/patterns/realtime/channel.graphql.module.ts.tpl +2 -1
  386. package/templates/be/patterns/realtime/channel.graphql.options.ts.tpl +2 -0
  387. package/templates/be/patterns/realtime/channel.websocket.module-definition.ts.tpl +6 -0
  388. package/templates/be/patterns/realtime/channel.websocket.module.ts.tpl +110 -0
  389. package/templates/be/patterns/realtime/channel.websocket.options.ts.tpl +7 -0
  390. package/templates/be/patterns/saga/saga.message.module-definition.ts.tpl +6 -0
  391. package/templates/be/patterns/saga/saga.message.module.ts.tpl +6 -3
  392. package/templates/be/patterns/saga/saga.message.options.ts.tpl +2 -0
  393. package/templates/be/patterns/saga/saga.module-definition.ts.tpl +6 -0
  394. package/templates/be/patterns/saga/saga.module.ts.tpl +2 -1
  395. package/templates/be/patterns/saga/saga.options.ts.tpl +2 -0
  396. package/templates/be/patterns/webhooks/webhook.door.spec.ts.tpl +3 -3
  397. package/templates/be/patterns/webhooks/webhook.door.ts.tpl +3 -3
  398. package/templates/be/patterns/webhooks/webhook.http.module-definition.ts.tpl +6 -0
  399. package/templates/be/patterns/webhooks/webhook.http.module.ts.tpl +2 -1
  400. package/templates/be/patterns/webhooks/webhook.http.options.ts.tpl +2 -0
  401. package/templates/be/platform/graphql/localize-errors.mapper.spec.ts +80 -26
  402. package/templates/be/platform/graphql/localize-errors.mapper.ts +2 -5
  403. package/templates/be/skeleton/apps/__app__/src/__app__.env.ts +21 -0
  404. package/templates/be/skeleton/apps/__app__/src/__app__.options.ts +1 -1
  405. package/templates/be/skeleton/apps/__app__/src/app.module.ts +15 -45
  406. package/templates/be/skeleton/apps/__app__/src/compose/api.ts +4 -0
  407. package/templates/be/skeleton/apps/__app__/src/compose/domain.ts +11 -0
  408. package/templates/be/skeleton/apps/__app__/src/compose/platform.ts +44 -0
  409. package/templates/be/skeleton/apps/__app__/src/compose/system.ts +5 -0
  410. package/templates/be/skeleton/apps/__app__/src/compose/webhooks.ts +4 -0
  411. package/templates/be/skeleton/apps/__app__/src/main.ts +18 -10
  412. package/templates/be/skeleton/apps/cli/src/app.module.ts +3 -13
  413. package/templates/be/skeleton/apps/cli/src/cli.env.ts +13 -0
  414. package/templates/be/skeleton/apps/cli/src/cli.options.ts +7 -17
  415. package/templates/be/skeleton/apps/cli/src/compose/cli.ts +5 -0
  416. package/templates/be/skeleton/apps/cli/src/compose/platform.ts +24 -0
  417. package/templates/be/skeleton/apps/cli/src/main.ts +3 -2
  418. package/templates/be/skeleton/src/features/api/system-health/application/check-readiness.contracts.ts +2 -0
  419. package/templates/be/skeleton/src/features/api/system-health/application/check-readiness.handler.ts +22 -0
  420. package/templates/be/skeleton/src/features/api/system-health/application/check-readiness.query.ts +11 -0
  421. package/templates/be/skeleton/src/features/api/system-health/system-health.module-definition.ts +6 -0
  422. package/templates/be/skeleton/src/features/api/system-health/system-health.module.ts +5 -3
  423. package/templates/be/skeleton/src/features/api/system-health/system-health.options.ts +2 -0
  424. package/templates/be/skeleton/src/features/api/system-health/transport/http/ready.controller.ts +19 -0
  425. package/templates/be/skeleton/src/features/api/system-health/transport/http/system-health-http.module-definition.ts +6 -0
  426. package/templates/be/skeleton/src/features/api/system-health/transport/http/system-health-http.module.ts +8 -3
  427. package/templates/be/skeleton/src/features/api/system-health/transport/http/system-health-http.options.ts +2 -0
  428. package/templates/be/skeleton/src/features/cli/cli.module-definition.ts +6 -0
  429. package/templates/be/skeleton/src/features/cli/cli.module.ts +3 -2
  430. package/templates/be/skeleton/src/features/cli/cli.options.ts +2 -0
  431. package/templates/be/skeleton/src/features/cli/migrate/migrate.module-definition.ts +6 -0
  432. package/templates/be/skeleton/src/features/cli/migrate/migrate.module.ts +2 -1
  433. package/templates/be/skeleton/src/features/cli/migrate/migrate.options.ts +2 -0
  434. package/templates/be/skeleton/src/features/cli/seed/seed.module-definition.ts +6 -0
  435. package/templates/be/skeleton/src/features/cli/seed/seed.module.ts +2 -1
  436. package/templates/be/skeleton/src/features/cli/seed/seed.options.ts +2 -0
  437. package/templates/be/skeleton/src/features/cli/seed/subs/run.cli.spec.ts +5 -6
  438. package/templates/be/skeleton/src/modules/domain/identity/admission.policy.spec.ts +56 -4
  439. package/templates/be/skeleton/src/modules/domain/identity/admission.policy.ts +36 -5
  440. package/templates/be/skeleton/src/modules/domain/identity/auth.guard.spec.ts +39 -8
  441. package/templates/be/skeleton/src/modules/domain/identity/auth.guard.ts +14 -8
  442. package/templates/be/skeleton/src/modules/domain/identity/credential.contracts.ts +26 -0
  443. package/templates/be/skeleton/src/modules/domain/identity/credential.guard.spec.ts +119 -0
  444. package/templates/be/skeleton/src/modules/domain/identity/credential.guard.ts +43 -0
  445. package/templates/be/skeleton/src/modules/domain/identity/identity.contracts.ts +16 -0
  446. package/templates/be/skeleton/src/modules/domain/identity/identity.decorators.ts +18 -1
  447. package/templates/be/skeleton/src/modules/domain/identity/identity.module.ts +22 -4
  448. package/templates/be/skeleton/src/modules/domain/identity/identity.options.ts +8 -2
  449. package/templates/be/skeleton/src/modules/domain/identity/index.ts +1 -0
  450. package/templates/be/skeleton/src/modules/platform/config/env-source.config.ts +6 -6
  451. package/templates/be/skeleton/src/modules/platform/database/connection-source.client.spec.ts +11 -4
  452. package/templates/be/skeleton/src/modules/platform/database/database-probe.service.spec.ts +32 -0
  453. package/templates/be/skeleton/src/modules/platform/database/database-probe.service.ts +14 -0
  454. package/templates/be/skeleton/src/modules/platform/database/database.module.ts +7 -1
  455. package/templates/be/skeleton/src/modules/platform/database/index.ts +2 -0
  456. package/templates/be/skeleton/src/modules/platform/database/primary.decorators.ts +8 -0
  457. package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.spec.ts +25 -6
  458. package/templates/be/skeleton/src/modules/platform/errors/errors.filter.spec.ts +16 -6
  459. package/templates/be/skeleton/src/modules/platform/http-security/edge-security.service.spec.ts +130 -0
  460. package/templates/be/skeleton/src/modules/platform/http-security/edge-security.service.ts +58 -0
  461. package/templates/be/skeleton/src/modules/platform/http-security/errors/http-security.error.ts +3 -0
  462. package/templates/be/skeleton/src/modules/platform/http-security/execution-request.mapper.spec.ts +16 -4
  463. package/templates/be/skeleton/src/modules/platform/http-security/http-security.config.ts +16 -10
  464. package/templates/be/skeleton/src/modules/platform/http-security/http-security.module.ts +3 -1
  465. package/templates/be/skeleton/src/modules/platform/http-security/http-security.options.ts +2 -0
  466. package/templates/be/skeleton/src/modules/platform/http-security/index.ts +5 -0
  467. package/templates/be/skeleton/src/modules/platform/http-security/messages/http-security.messages.ts +12 -5
  468. package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.spec.ts +13 -8
  469. package/templates/be/skeleton/src/modules/platform/http-security/readiness.contracts.ts +5 -0
  470. package/templates/be/skeleton/src/modules/platform/http-security/readiness.decorators.ts +7 -0
  471. package/templates/be/skeleton/src/modules/platform/http-security/readiness.service.spec.ts +86 -0
  472. package/templates/be/skeleton/src/modules/platform/http-security/readiness.service.ts +39 -0
  473. package/templates/be/skeleton/src/modules/platform/http-security/webhook-signature.service.spec.ts +139 -131
  474. package/templates/be/skeleton/src/modules/platform/i18n/index.ts +1 -1
  475. package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.spec.ts +91 -67
  476. package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.ts +5 -4
  477. package/templates/be/skeleton/src/modules/platform/logging/logging.log-events.ts +0 -2
  478. package/templates/be/skeleton/src/modules/platform/primitives/outcome.mapper.spec.ts +20 -6
  479. package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.spec.ts +16 -4
  480. package/templates/be/skeleton/src/modules/platform/retry/index.ts +3 -0
  481. package/templates/be/skeleton/src/modules/platform/retry/retry.decorators.ts +9 -0
  482. package/templates/be/skeleton/src/modules/platform/retry/retry.module-definition.ts +7 -0
  483. package/templates/be/skeleton/src/modules/platform/retry/retry.module.ts +19 -0
  484. package/templates/be/skeleton/src/modules/platform/retry/retry.options.ts +2 -0
  485. package/templates/be/skeleton/src/modules/platform/retry/retry.port.ts +11 -0
  486. package/templates/be/skeleton/src/modules/platform/retry/system-timer.service.spec.ts +36 -0
  487. package/templates/be/skeleton/src/modules/platform/retry/system-timer.service.ts +10 -0
  488. package/templates/be/skeleton/src/tests/contract/http-security/http-security.contract-spec.ts +54 -0
  489. package/templates/be/skeleton-lite/apps/api/src/api.env.ts +25 -0
  490. package/templates/be/skeleton-lite/apps/api/src/api.options.ts +1 -1
  491. package/templates/be/skeleton-lite/apps/api/src/app.module.ts +18 -58
  492. package/templates/be/skeleton-lite/apps/api/src/compose/api.ts +4 -0
  493. package/templates/be/skeleton-lite/apps/api/src/compose/domain.ts +10 -0
  494. package/templates/be/skeleton-lite/apps/api/src/compose/integrations.ts +16 -0
  495. package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +47 -0
  496. package/templates/be/skeleton-lite/apps/api/src/compose/system.ts +5 -0
  497. package/templates/be/skeleton-lite/apps/api/src/compose/webhooks.ts +4 -0
  498. package/templates/be/skeleton-lite/apps/api/src/main.ts +5 -13
  499. package/templates/be/skeleton-lite/apps/cli/src/cli.env.ts +17 -0
  500. package/templates/be/skeleton-lite/apps/cli/src/compose/platform.ts +12 -0
  501. package/templates/be/skeleton-lite/src/modules/domain/identity/admission.policy.spec.ts +71 -5
  502. package/templates/be/skeleton-lite/src/modules/domain/identity/admission.policy.ts +29 -2
  503. package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.spec.ts +68 -15
  504. package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.ts +14 -9
  505. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.contracts.ts +9 -2
  506. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +16 -1
  507. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.module.ts +19 -4
  508. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.options.ts +5 -1
  509. package/templates/be/skeleton-lite/src/modules/domain/identity/index.ts +2 -1
  510. package/templates/be/skeleton-lite/src/modules/integrations/supabase/errors/supabase.error.ts +0 -3
  511. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.client.spec.ts +17 -5
  512. package/templates/be/skeleton-lite/src/modules/platform/database/database.module.ts +24 -13
  513. package/templates/be/skeleton-lite/src/modules/platform/database/database.port.ts +5 -0
  514. package/templates/be/skeleton-lite/src/modules/platform/database/errors/database.error.ts +16 -0
  515. package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +3 -0
  516. package/templates/be/skeleton-lite/src/modules/platform/http-security/http-security.module.ts +5 -2
  517. package/templates/be/skeleton-lite/src/modules/platform/http-security/index.ts +4 -1
  518. package/templates/be/skeleton-lite/src/modules/platform/i18n/index.ts +1 -1
  519. package/templates/be/skeleton-lite/src/modules/platform/logging/logging.log-events.ts +7 -0
  520. package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +29 -0
  521. package/templates/be/skeleton-lite/src/tests/fixtures/builders/cli.builder.ts +16 -0
  522. package/templates/be/table/api/feature.module-definition.ts.tpl +6 -0
  523. package/templates/be/table/api/feature.module.ts.tpl +2 -1
  524. package/templates/be/table/api/feature.options.ts.tpl +2 -0
  525. package/templates/be/table/api/graphql.module-definition.ts.tpl +6 -0
  526. package/templates/be/table/api/graphql.module.ts.tpl +3 -2
  527. package/templates/be/table/api/graphql.options.ts.tpl +2 -0
  528. package/templates/be/table/migration.sql.tpl +1 -0
  529. package/templates/be/upgrade-full/apps/cli/src/app.module.ts +4 -6
  530. package/templates/be/upgrade-full/apps/cli/src/cli.env.ts +7 -0
  531. package/templates/be/upgrade-full/apps/cli/src/compose/cli.ts +5 -0
  532. package/templates/be/upgrade-full/apps/cli/src/compose/platform.ts +9 -0
  533. package/templates/be/upgrade-full/apps/cli/src/main.ts +3 -0
  534. package/templates/be/upgrade-full/src/features/cli/cli.module.ts +3 -2
  535. package/templates/fe/auth/supabase-client.ts.tpl +34 -0
  536. package/templates/fe/skeleton/apps/__app__/src/app/auth/callback/route.ts +11 -0
  537. package/templates/fe/skeleton/apps/__app__/src/components/blocks/SignInForm/index.tsx +6 -1
  538. package/templates/fe/skeleton/apps/__app__/src/components/leaves/Icon/index.tsx +4 -0
  539. package/templates/fe/skeleton/apps/__app__/src/features/overlays/AppGuideOverlay/component.tsx +31 -0
  540. package/templates/fe/skeleton/apps/__app__/src/features/overlays/AppGuideOverlay/index.tsx +24 -0
  541. package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSession.ts +6 -1
  542. package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSignOut.ts +3 -4
  543. package/templates/fe/skeleton/apps/__app__/src/hooks/zustand/appGuide/index.ts +1 -0
  544. package/templates/fe/skeleton/apps/__app__/src/hooks/zustand/appGuide/store.ts +15 -0
  545. package/templates/fe/skeleton/apps/__app__/src/modules/auth/pkce-client.ts +18 -0
  546. package/templates/fe/skeleton/apps/__app__/src/modules/auth/server.ts +19 -0
  547. package/templates/fe/skeleton/apps/app/src/features/pages/AppHomePage/index.tsx +7 -1
  548. package/templates/fe/skeleton/apps/app/src/modules/i18n/messages/en.json +57 -0
  549. package/templates/fe/skeleton/apps/app/src/modules/i18n/messages/vi.json +6 -0
  550. package/templates/fe/skeleton/apps/landing/src/modules/i18n/messages/en.json +31 -0
  551. package/templates/fe/skeleton/packages/__project__-i18n/src/routing.ts +1 -1
  552. package/templates/fe/skeleton/packages/__project__-ui/src/index.ts +1 -0
  553. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/client.ts +1 -34
  554. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppHomePage/index.tsx +7 -1
  555. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/en.json +57 -0
  556. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/vi.json +30 -24
  557. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/routing.ts +1 -1
  558. package/templates/fe/transport/graphql/clients/clients/auth.ts +1 -1
  559. package/templates/fe/transport/graphql/clients/links/locale.ts +2 -2
  560. package/upgrade/check.mjs +3 -1
  561. package/upgrade/file-state.mjs +68 -0
  562. package/upgrade/index.mjs +58 -94
  563. package/runtime/scripts/api/git/log.mjs +0 -5
  564. package/templates/app/skeleton-lite/.starciwork/shell/index.yaml +0 -55
@@ -0,0 +1,87 @@
1
+ // release-store.mjs - the release record protocol on the manager: /srv/<stack>/releases/<digest16>/{stack.yaml,release.json} behind the
2
+ // stable `current` and `previous` pointers, which move only after verification. A JavaScript object over an injected ssh runner:
3
+ // ssh(command, { input }) -> { status, stdout, stderr } runs a shell command on the manager (remote.mjs provides the real one)
4
+ // Every remote command is composed ONLY from a validated release id (16 lowercase hex) and a validated base path, so no free text reaches
5
+ // the remote shell. The record holds names and digests, never a secret value. Files travel as text on stdin / stdout.
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { eachInOrder, mapInOrder } from '../runtime/scripts/lib/in-order.mjs';
9
+ import { firstLine, lines as splitLines } from './context.mjs';
10
+ import { isReleaseId } from './digest.mjs';
11
+ import { failure, refusal } from './errors.mjs';
12
+
13
+ const BASE = /^\/[A-Za-z0-9._/-]+$/u;
14
+ const FILES = ['stack.yaml', 'release.json'];
15
+
16
+ /** Removes every carriage return: a snippet from a Windows checkout must never reach a remote shell with CRLF. */
17
+ export const lfOnly = (text) => String(text).replaceAll('\r', '');
18
+
19
+ const checkedId = (id) => {
20
+ if (!isReleaseId(id)) throw refusal('STACK_RELEASE_ID', 'a release id is exactly 16 lowercase hex characters');
21
+ return id;
22
+ };
23
+
24
+ const checkedBase = (config) => {
25
+ const base = config.remote?.releasesBase ?? `/srv/${config.stack}`;
26
+ if (!BASE.test(base) || base.split('/').includes('..')) throw refusal('STACK_RELEASE_BASE', `the releases base ${base} must be an absolute path without ..`);
27
+ return base;
28
+ };
29
+
30
+ /**
31
+ * The release store of one stack. `ssh` is the runner; the returned API is async:
32
+ * store(dir, id) accept(id) current() previous() fetch(id, targetDir) list()
33
+ */
34
+ export function createReleaseStore(config, ssh) {
35
+ const base = checkedBase(config);
36
+ const dirOf = (id) => `${base}/releases/${checkedId(id)}`;
37
+
38
+ async function run(command, input) {
39
+ const answer = await ssh(lfOnly(command), { input });
40
+ if (answer.status !== 0) throw failure('STACK_RELEASE_SSH', `remote command failed: ${firstLine(answer.stderr)}`);
41
+ return answer.stdout;
42
+ }
43
+
44
+ function readLocal(dir, name) {
45
+ try { return fs.readFileSync(path.join(dir, name), 'utf8'); } catch { throw failure('STACK_RELEASE_STORE', `cannot read ${name} in ${dir}`); }
46
+ }
47
+
48
+ async function pointer(name) {
49
+ const out = await run(`cd "${base}" 2>/dev/null && [ -L ${name} ] && basename "$(readlink ${name})" || true`);
50
+ const id = out.trim();
51
+ return isReleaseId(id) ? id : '';
52
+ }
53
+
54
+ return {
55
+ /** Uploads stack.yaml and release.json of a rendered release as text; read-only afterwards. */
56
+ async store(dir, id) {
57
+ const target = dirOf(id);
58
+ const contents = FILES.map((name) => [name, readLocal(dir, name)]);
59
+ await run(`set -e; d="${target}"; if [ -d "$d" ]; then chmod -R u+w "$d"; else mkdir -p "$d"; fi`);
60
+ await eachInOrder(contents, ([name, text]) => run(`set -e; d="${target}"; cat > "$d/${name}"`, text));
61
+ await run(`set -e; d="${target}"; chmod 440 "$d/stack.yaml" "$d/release.json"; chmod 550 "$d"`);
62
+ },
63
+ /** The release was verified: previous := current, current := id. */
64
+ async accept(id) {
65
+ checkedId(id);
66
+ await run(`set -e; cd "${base}"; test -d "releases/${id}"; if [ -L current ] && [ "$(basename "$(readlink current)")" != "${id}" ]; then ln -sfn "$(readlink current)" previous; fi; ln -sfn "releases/${id}" current.new; mv -Tf current.new current`);
67
+ },
68
+ /** The accepted release id, '' when none. */
69
+ current: () => pointer('current'),
70
+ /** The release accepted before the current one, '' when none. */
71
+ previous: () => pointer('previous'),
72
+ /** Downloads a stored release (both files) into `targetDir` for a rollback or an audit. */
73
+ async fetch(id, targetDir) {
74
+ const source = dirOf(id);
75
+ const files = await mapInOrder(FILES, async (name) => [name, await run(`cat "${source}/${name}"`)]);
76
+ fs.mkdirSync(targetDir, { recursive: true });
77
+ for (const [name, text] of files) fs.writeFileSync(path.join(targetDir, name), text);
78
+ return targetDir;
79
+ },
80
+ /** The stored releases, newest first: [{ id, current, previous }]. */
81
+ async list() {
82
+ const [now, before] = [await pointer('current'), await pointer('previous')];
83
+ const out = await run(`cd "${base}/releases" 2>/dev/null && ls -1t || true`);
84
+ return splitLines(out).filter(isReleaseId).map((id) => ({ id, current: id === now, previous: id === before }));
85
+ },
86
+ };
87
+ }
@@ -0,0 +1,205 @@
1
+ // remote.mjs - how the stack verbs reach the Swarm manager: the ssh runner, remote command quoting, the CI transport setup.
2
+ // sshRunner(ctx, config, { sshCmd }) -> run(command, { input }) -> { status, stdout, stderr } (`.cleanup()`, `.notes`)
3
+ // transports, in this order: --ssh-cmd "ssh vps" (no shell) -> the owner-machine session opened from the sealed members
4
+ // (only when config.remote.sealed is declared and STARCI_STACK_SEALED_SSH=1) -> the ssh alias config.remote.host (CI after `connect`,
5
+ // or the operator's own ssh config).
6
+ // connectCi / disconnectCi GitHub Actions: write and remove the pinned key, known_hosts, ssh config and DOCKER_HOST.
7
+ // A remote command is ONE ssh argument with every carriage return removed (lfOnly: a script that reached a Windows checkout with CRLF made
8
+ // `bash -s` fail with `set: pipefail`). Key material lives in memory and in a private temp directory only; it is never in argv (only the key
9
+ // FILE path is), in a line, in a result or in an error message.
10
+ import fs from 'node:fs';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+ import { tool } from './context.mjs';
14
+ import { restrictToUser, writePrivate } from './private-file.mjs';
15
+ import { refusal } from './errors.mjs';
16
+
17
+ const ACCOUNT = /^[A-Za-z0-9._@-]+$/u;
18
+ const CI_VARIABLES = ['VPS_SSH_KEY', 'VPS_KNOWN_HOSTS', 'VPS_HOST', 'VPS_USER'];
19
+ const SSH_OPTIONS = ['-T', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=20'];
20
+
21
+ /** Removes every carriage return. */
22
+ export const lfOnly = (text) => String(text).replaceAll('\r', '');
23
+
24
+ /** POSIX single-quote quoting; NUL and line breaks cannot be quoted safely and are refused. */
25
+ export function shellQuote(value) {
26
+ const text = String(value);
27
+ if (text.includes('\0') || /[\r\n]/u.test(text)) throw refusal('STACK_REMOTE_ARGUMENT', 'a remote argument must not contain NUL or a line break');
28
+ return ["'", text.replaceAll("'", String.raw`'\''`), "'"].join('');
29
+ }
30
+
31
+ /** Runs `docker <args>` on the manager through `run` (every argument quoted). */
32
+ export const remoteDocker = (run, args) => run(['docker', ...args.map(shellQuote)].join(' '));
33
+
34
+ const noop = () => {};
35
+
36
+ function makeRunner(ctx, argv, { cleanup = noop, notes = [] } = {}) {
37
+ const [command, ...head] = argv;
38
+ const run = (remote, { input } = {}) => {
39
+ const options = input === undefined ? {} : { input: lfOnly(input) };
40
+ const result = tool(ctx, command, [...head, lfOnly(remote)], options);
41
+ if (result.error) return { status: 127, stdout: '', stderr: `cannot run ${command}: ${result.error.message}` };
42
+ return { status: result.status ?? 1, stdout: result.stdout, stderr: result.stderr };
43
+ };
44
+ return Object.assign(run, { cleanup, notes });
45
+ }
46
+
47
+ function sshCommandArgv(sshCmd) {
48
+ const parts = String(sshCmd).split(/\s+/u).filter(Boolean);
49
+ if (parts.length === 0) throw refusal('STACK_REMOTE_SSH_CMD', '--ssh-cmd is empty');
50
+ return parts;
51
+ }
52
+
53
+ /** A sealed binary member is the file itself; a JSON object around it ({ data | <names> }, the re-sealed shape of `starci app secret rename`) is unwrapped. */
54
+ function unwrap(text, names) {
55
+ const body = lfOnly(text);
56
+ const trimmed = body.trim();
57
+ if (!trimmed.startsWith('{')) return body;
58
+ let parsed;
59
+ try { parsed = JSON.parse(trimmed); } catch { return body; }
60
+ for (const name of ['data', ...names]) if (typeof parsed[name] === 'string') return lfOnly(parsed[name]);
61
+ return body;
62
+ }
63
+
64
+ function openMember(slug, read) {
65
+ try { return read(); } catch (error) { throw refusal('STACK_REMOTE_SEALED', `cannot open the sealed member ${slug}: ${error.message}`); }
66
+ }
67
+
68
+ function opsAccount(ctx, config) {
69
+ const slug = config.remote.sealed.ops;
70
+ const map = openMember(slug, () => ctx.sops.decrypt(path.join(config.secretsDir, `${slug}.enc`), 'dotenv'));
71
+ for (const name of ['VPS_HOST', 'VPS_USER']) {
72
+ const value = map[name];
73
+ if (typeof value !== 'string' || !ACCOUNT.test(value) || value.startsWith('-')) throw refusal('STACK_REMOTE_OPS', `${slug} must hold ${name} (letters, digits, . _ @ -)`);
74
+ }
75
+ return `${map.VPS_USER}@${map.VPS_HOST}`;
76
+ }
77
+
78
+ function binaryMember(ctx, config, slug, names) {
79
+ return unwrap(openMember(slug, () => ctx.sops.decryptBinary(path.join(config.secretsDir, `${slug}.enc`))), names);
80
+ }
81
+
82
+ const withNewline = (text) => `${text.trim()}\n`;
83
+
84
+ /** CI (a Linux runner): the file with exactly this mode. */
85
+ function writeWithMode(file, text, mode) {
86
+ fs.writeFileSync(file, text, { mode });
87
+ fs.chmodSync(file, mode);
88
+ }
89
+
90
+ /** The owner-machine session files: key and known_hosts in a private temp directory, removed (key overwritten) by cleanup(). */
91
+ export function openSealedSession(ctx, config) {
92
+ const { sealed } = config.remote;
93
+ const account = opsAccount(ctx, config);
94
+ const key = binaryMember(ctx, config, sealed.sshKey, ['privateKey', 'key']);
95
+ if (!key.includes('PRIVATE KEY')) throw refusal('STACK_REMOTE_KEY', `${sealed.sshKey} does not hold a PEM private key`);
96
+ const known = binaryMember(ctx, config, sealed.knownHosts, ['knownHosts', 'known_hosts']).trim();
97
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stack-ssh-'));
98
+ const keyFile = path.join(dir, 'id');
99
+ const knownFile = path.join(dir, 'known_hosts');
100
+ const cleanup = () => {
101
+ if (fs.existsSync(keyFile)) {
102
+ restrictToUser(ctx, keyFile, 'F');
103
+ fs.writeFileSync(keyFile, '0'.repeat(key.length));
104
+ }
105
+ fs.rmSync(dir, { recursive: true, force: true });
106
+ };
107
+ try {
108
+ writePrivate(ctx, keyFile, withNewline(key));
109
+ writePrivate(ctx, knownFile, known ? withNewline(known) : '');
110
+ } catch (error) {
111
+ fs.rmSync(dir, { recursive: true, force: true });
112
+ throw error;
113
+ }
114
+ const notes = known ? [] : ['owner-machine session without known_hosts: the host key is accepted on first contact (accept-new); verify it, CI never does this'];
115
+ return { account, dir, keyFile, knownFile, strict: known ? 'yes' : 'accept-new', notes, cleanup };
116
+ }
117
+
118
+ /** The owner-machine runner over that session. */
119
+ function sealedRunner(ctx, config) {
120
+ const session = openSealedSession(ctx, config);
121
+ const argv = ['ssh', '-T', '-i', session.keyFile, '-o', 'IdentitiesOnly=yes', '-o', `UserKnownHostsFile=${session.knownFile}`, '-o', `StrictHostKeyChecking=${session.strict}`, '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=20', session.account];
122
+ return makeRunner(ctx, argv, { cleanup: session.cleanup, notes: session.notes });
123
+ }
124
+
125
+ /** The ssh runner of one invocation (see the top of the file). */
126
+ export function sshRunner(ctx, config, { sshCmd = null } = {}) {
127
+ if (sshCmd) return makeRunner(ctx, sshCommandArgv(sshCmd));
128
+ if (config.remote.sealed && ctx.env.STARCI_STACK_SEALED_SSH === '1') return sealedRunner(ctx, config);
129
+ return makeRunner(ctx, ['ssh', ...SSH_OPTIONS, config.remote.host]);
130
+ }
131
+
132
+ /**
133
+ * The context to open the runner with. On an operator machine (not CI, no --ssh-cmd, the sealed members exist, no explicit
134
+ * STARCI_STACK_SEALED_SSH) the sealed session is selected; otherwise the context is returned unchanged.
135
+ */
136
+ export function withOwnerTransport(ctx, config, { sshCmd = null } = {}) {
137
+ const { sealed } = config.remote;
138
+ if (sshCmd || !sealed || ctx.env.GITHUB_ACTIONS === 'true' || ctx.env.STARCI_STACK_SEALED_SSH !== undefined) return ctx;
139
+ const present = [sealed.ops, sealed.sshKey, sealed.knownHosts].every((slug) => fs.existsSync(path.join(config.secretsDir, `${slug}.enc`)));
140
+ if (!present) return ctx;
141
+ return Object.create(ctx, { env: { value: { ...ctx.env, STARCI_STACK_SEALED_SSH: '1' }, enumerable: true } });
142
+ }
143
+
144
+ /** The runner of a verb that opens it itself (deploy, rollback, accept): the owner-machine session is selected exactly as `withRunner` does. */
145
+ export const ownerSshRunner = (ctx, config, options = {}) => sshRunner(withOwnerTransport(ctx, config, options), config, options);
146
+
147
+ /** Opens the runner (`deps.ssh` wins in specs), calls `body(run, notes)` and always cleans the session up. */
148
+ export async function withRunner(ctx, config, options, deps, body) {
149
+ const run = deps.ssh ?? sshRunner(withOwnerTransport(ctx, config, options), config, options);
150
+ try {
151
+ return await body(run, run.notes ?? []);
152
+ } finally {
153
+ run.cleanup?.();
154
+ }
155
+ }
156
+
157
+ function ciHome(ctx) {
158
+ if (ctx.env.GITHUB_ACTIONS !== 'true') throw refusal('STACK_CONNECT_CI_ONLY', 'connect and disconnect run only in GitHub Actions (GITHUB_ACTIONS=true)');
159
+ if (!ctx.env.HOME) throw refusal('STACK_CONNECT_NO_HOME', 'HOME is not set');
160
+ return path.join(ctx.env.HOME, '.ssh');
161
+ }
162
+
163
+ function ciInputs(ctx) {
164
+ for (const name of CI_VARIABLES) if (!ctx.env[name]) throw refusal('STACK_CONNECT_MISSING', `${name} is not set`);
165
+ for (const name of ['VPS_HOST', 'VPS_USER']) {
166
+ if (!ACCOUNT.test(ctx.env[name]) || ctx.env[name].startsWith('-')) throw refusal('STACK_CONNECT_INVALID', `${name} must match ${ACCOUNT}`);
167
+ }
168
+ const key = lfOnly(ctx.env.VPS_SSH_KEY);
169
+ if (!key.includes('PRIVATE KEY')) throw refusal('STACK_CONNECT_INVALID', 'VPS_SSH_KEY is not a PEM private key');
170
+ if (!ctx.env.GITHUB_ENV) throw refusal('STACK_CONNECT_NO_GITHUB_ENV', 'GITHUB_ENV is not set');
171
+ return { key: withNewline(key), known: withNewline(lfOnly(ctx.env.VPS_KNOWN_HOSTS)), host: ctx.env.VPS_HOST, user: ctx.env.VPS_USER };
172
+ }
173
+
174
+ const sshBlock = (alias, inputs) => [
175
+ '', `Host ${alias}`, ` HostName ${inputs.host}`, ` User ${inputs.user}`, ' IdentityFile ~/.ssh/deploy_key',
176
+ ' StrictHostKeyChecking yes', ' BatchMode yes', ' ConnectTimeout 20', ' ServerAliveInterval 30',
177
+ ' ControlMaster auto', ' ControlPath ~/.ssh/cm-%C', ' ControlPersist 15m', '',
178
+ ].join('\n');
179
+
180
+ /** CI transport: pinned key, known_hosts, ssh config block and DOCKER_HOST=ssh://<alias> in $GITHUB_ENV. */
181
+ export async function connectCi(ctx, config, { execute = false } = {}) {
182
+ const dir = ciHome(ctx);
183
+ const inputs = ciInputs(ctx);
184
+ const alias = config.remote.host;
185
+ const plan = [`${dir}/deploy_key (0600) from VPS_SSH_KEY`, `${dir}/known_hosts (0644) from VPS_KNOWN_HOSTS`, `a Host ${alias} block in ${dir}/config`, `DOCKER_HOST=ssh://${alias} in GITHUB_ENV`];
186
+ const data = { host: alias, files: ['deploy_key', 'known_hosts', 'config'] };
187
+ if (!execute) return { ok: true, lines: plan.map((item) => `dry run: would write ${item}`), data };
188
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
189
+ writeWithMode(path.join(dir, 'deploy_key'), inputs.key, 0o600);
190
+ writeWithMode(path.join(dir, 'known_hosts'), inputs.known, 0o644);
191
+ fs.appendFileSync(path.join(dir, 'config'), sshBlock(alias, inputs), { mode: 0o600 });
192
+ fs.appendFileSync(ctx.env.GITHUB_ENV, `DOCKER_HOST=ssh://${alias}\n`);
193
+ return { ok: true, lines: plan.map((item) => `wrote ${item}`), data };
194
+ }
195
+
196
+ /** CI cleanup: closes the ssh control master (failure ignored) and removes the deploy key. */
197
+ export async function disconnectCi(ctx, config, { execute = false } = {}) {
198
+ const dir = ciHome(ctx);
199
+ const alias = config.remote.host;
200
+ const data = { host: alias };
201
+ if (!execute) return { ok: true, lines: [`dry run: would run ssh -O exit ${alias} and remove ${dir}/deploy_key`], data };
202
+ tool(ctx, 'ssh', ['-O', 'exit', alias]);
203
+ fs.rmSync(path.join(dir, 'deploy_key'), { force: true });
204
+ return { ok: true, lines: [`closed the ssh session to ${alias} and removed ${dir}/deploy_key`], data };
205
+ }
@@ -0,0 +1,149 @@
1
+ // render.mjs - renders a release: the committed stack source plus a generated override (image references, plain environment, the
2
+ // `<KEY>_FILE` pointers and the versioned secret grants) resolved by `docker stack config`. Writes `stack.generated.yaml`, `stack.yaml`
3
+ // and `release.json` under the output directory, and nothing at all when a guard refuses. Output carries names and counts only; the
4
+ // docker child gets a minimal environment, never the age identity or DOCKER_HOST.
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import { tool } from './context.mjs';
9
+ import { hasPlaceholderDigest, isImageReference, isReleaseId } from './digest.mjs';
10
+ import { failure, refusal } from './errors.mjs';
11
+ import { configObjects, loadSealed, sha } from './sealed.mjs';
12
+
13
+ const SOURCE_SHA = /^[0-9a-f]{7,64}$/u;
14
+ const ENV_LINE = /^([A-Z][A-Z0-9_]*)=(.*)$/u;
15
+ const BASE_ENV = ['PATH', 'HOME', 'USERPROFILE', 'SystemRoot', 'TEMP', 'TMP', 'DOCKER_CONFIG'];
16
+ const SECRET_MODE = 0o444;
17
+
18
+ function checkImages(config, options) {
19
+ const wanted = Object.keys(config.images);
20
+ const given = Object.keys(options.images ?? {});
21
+ const missing = wanted.filter((service) => !given.includes(service));
22
+ if (missing.length > 0) throw refusal('STACK_IMAGE_MISSING', `no image given for ${missing.join(', ')}`);
23
+ const unknown = given.filter((service) => !wanted.includes(service));
24
+ if (unknown.length > 0) throw refusal('STACK_IMAGE_UNKNOWN', `${unknown.join(', ')} is not an image of the deploy block`);
25
+ for (const service of wanted) {
26
+ const reference = options.images[service];
27
+ if (hasPlaceholderDigest(reference)) throw refusal('STACK_PLACEHOLDER_DIGEST', `the image of ${service} carries the all-zero placeholder digest`);
28
+ if (!isImageReference(reference, options.allowUnpinned === true)) throw refusal('STACK_IMAGE_UNPINNED', `the image of ${service} must be a registry reference pinned by @sha256 digest`);
29
+ }
30
+ }
31
+
32
+ function checkInputs(config, options) {
33
+ checkImages(config, options);
34
+ if (!SOURCE_SHA.test(String(options.sourceSha))) throw refusal('STACK_SOURCE_SHA_INVALID', 'sourceSha must be 7 to 64 lowercase hex characters');
35
+ if (options.previous !== null && options.previous !== undefined && !isReleaseId(options.previous)) throw refusal('STACK_PREVIOUS_INVALID', 'previous must be 16 lowercase hex characters');
36
+ }
37
+
38
+ /** Compose interpolation treats `$` specially; the override doubles it so a value reaches the service unchanged. */
39
+ const escapeDollar = (value) => value.replaceAll('$', '$$$$');
40
+
41
+ function envFile(config, rel) {
42
+ const out = {};
43
+ for (const line of fs.readFileSync(path.join(config.envDir, rel), 'utf8').split(/\r?\n/u)) {
44
+ const match = ENV_LINE.exec(line);
45
+ if (match) out[match[1]] = match[2];
46
+ }
47
+ return out;
48
+ }
49
+
50
+ function plainEnvironment(config, settings, entry) {
51
+ const out = {};
52
+ for (const rel of entry.files) Object.assign(out, envFile(config, rel));
53
+ Object.assign(out, entry.values);
54
+ for (const [name, setting] of Object.entries(entry.settings)) {
55
+ const value = settings[setting.key] ?? setting.default;
56
+ if (value !== null) out[name] = value;
57
+ }
58
+ return out;
59
+ }
60
+
61
+ function serviceOverride(config, sealed, images, service) {
62
+ const grant = config.secrets.grants.find((item) => item.service === service);
63
+ const environment = plainEnvironment(config, sealed.settings, config.environment[service] ?? { files: [], values: {}, settings: {} });
64
+ const keys = sealed.grants[service] ?? [];
65
+ if (grant?.filePointers) for (const key of keys) environment[`${key}_FILE`] = `/run/secrets/${key}`;
66
+ const out = {};
67
+ if (images[service] !== undefined) out.image = images[service];
68
+ if (Object.keys(environment).length > 0) out.environment = Object.fromEntries(Object.entries(environment).map(([name, value]) => [name, escapeDollar(String(value))]));
69
+ if (keys.length > 0) out.secrets = keys.map((key) => ({ source: key, target: key, mode: SECRET_MODE }));
70
+ return out;
71
+ }
72
+
73
+ function buildOverride(config, sealed, images) {
74
+ const services = new Set([...Object.keys(config.images), ...Object.keys(config.environment), ...Object.keys(sealed.grants)]);
75
+ return {
76
+ services: Object.fromEntries([...services].sort((a, b) => a.localeCompare(b)).map((service) => [service, serviceOverride(config, sealed, images, service)])),
77
+ secrets: Object.fromEntries(Object.entries(sealed.secrets).map(([key, entry]) => [key, { external: true, name: entry.runtimeName }])),
78
+ };
79
+ }
80
+
81
+ function minimalEnv(ctx, config, configs) {
82
+ const env = {};
83
+ for (const name of BASE_ENV) if (ctx.env[name] !== undefined) env[name] = ctx.env[name];
84
+ for (const name of config.passEnv) env[name] = ctx.env[name] ?? '';
85
+ for (const object of configs) env[object.env] = object.name;
86
+ return env;
87
+ }
88
+
89
+ function dockerRender(ctx, config, overrideFile, options, env) {
90
+ const files = [config.stackFile, overrideFile, ...(options.extraCompose ?? [])].flatMap((file) => ['-c', file]);
91
+ const run = tool(ctx, 'docker', ['stack', 'config', ...files], { env });
92
+ if (run.error) throw failure('STACK_RENDER_FAILED', `cannot run docker (${run.error.message})`);
93
+ if (run.status !== 0) throw failure('STACK_RENDER_FAILED', run.stderr.trim().split('\n').slice(0, 6).join(' | '));
94
+ if (hasPlaceholderDigest(run.stdout)) throw refusal('STACK_PLACEHOLDER_DIGEST', 'rendered stack still carries a placeholder digest');
95
+ return run.stdout;
96
+ }
97
+
98
+ function releaseRecord(ctx, config, parts) {
99
+ const { sealed, configs, options, stackBytes, env } = parts;
100
+ const passed = Object.fromEntries(config.passEnv.map((name) => [name, env[name]]));
101
+ const digestInput = { environment: config.environment, settings: Object.entries(sealed.settings).sort(([a], [b]) => a.localeCompare(b)), configs: configs.map((item) => item.name), passed };
102
+ return {
103
+ schema: 'starci/stack-release@1',
104
+ stack: config.stack,
105
+ releaseDigest: sha(stackBytes),
106
+ sourceSha: options.sourceSha,
107
+ images: options.images,
108
+ configDigest: sha(JSON.stringify(digestInput)),
109
+ configs: configs.map((item) => ({ logical: item.logical, name: item.name })),
110
+ secrets: Object.entries(sealed.secrets).map(([key, entry]) => ({ key, runtimeName: entry.runtimeName })).sort((a, b) => a.key.localeCompare(b.key)),
111
+ healthAssertions: [...config.verify.http, ...config.verify.exec].map((check) => check.name),
112
+ previousAccepted: options.previous ?? null,
113
+ renderedAt: new Date(ctx.now()).toISOString(),
114
+ };
115
+ }
116
+
117
+ function writeOutput(out, override, stackBytes, release) {
118
+ fs.mkdirSync(out, { recursive: true });
119
+ fs.writeFileSync(path.join(out, 'stack.generated.yaml'), `${JSON.stringify(override, null, 2)}\n`);
120
+ fs.writeFileSync(path.join(out, 'stack.yaml'), stackBytes);
121
+ fs.writeFileSync(path.join(out, 'release.json'), `${JSON.stringify(release, null, 2)}\n`);
122
+ }
123
+
124
+ /** Renders a release into `options.out` (a fresh temp directory when null). Effects stay local; nothing leaves the machine. */
125
+ export async function renderRelease(ctx, config, options) {
126
+ checkInputs(config, options);
127
+ const sealed = loadSealed(ctx, config);
128
+ const configs = configObjects(config);
129
+ const override = buildOverride(config, sealed, options.images);
130
+ const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'stack-override-'));
131
+ try {
132
+ const overrideFile = path.join(scratch, 'stack.generated.yaml');
133
+ fs.writeFileSync(overrideFile, `${JSON.stringify(override, null, 2)}\n`);
134
+ const env = minimalEnv(ctx, config, configs);
135
+ const stackBytes = dockerRender(ctx, config, overrideFile, options, env);
136
+ const release = releaseRecord(ctx, config, { sealed, configs, options, stackBytes, env });
137
+ const out = options.out ?? fs.mkdtempSync(path.join(os.tmpdir(), 'stack-render-'));
138
+ writeOutput(out, override, stackBytes, release);
139
+ const releaseId = release.releaseDigest.slice(0, 16);
140
+ const counts = { secrets: release.secrets.length, configs: configs.length };
141
+ return {
142
+ ok: true,
143
+ lines: [`render: release ${releaseId}: ${counts.secrets} secrets, ${counts.configs} configs; images pinned`, `out: ${out}`],
144
+ data: { releaseDigest: release.releaseDigest, releaseId, out, ...counts },
145
+ };
146
+ } finally {
147
+ fs.rmSync(scratch, { recursive: true, force: true });
148
+ }
149
+ }
@@ -0,0 +1,47 @@
1
+ // retire.mjs - `starci app stack retire --project <compose project>... [--execute]`: stops the containers of the OLD docker compose projects once the swarm
2
+ // stack has taken over, WITHOUT removing anything. Each container gets `--restart=no` first (a restart policy or a reboot would otherwise start the old
3
+ // model again beside the new one), then `docker stop`; applications first, data stores last (the reverse of the recovery order). Containers, volumes,
4
+ // images and networks stay, so the old model can be started again for a rollback. The legacy edge container is never touched here (it is kept stopped
5
+ // by the takeover for its own retention), and a project that holds it is refused.
6
+ import { refusal } from './errors.mjs';
7
+ import { remoteDocker, withRunner } from './remote.mjs';
8
+ import { snapshotContainers, startOrder } from './live-restore.mjs';
9
+
10
+ const PROJECT = /^[a-z0-9][a-z0-9_-]*$/u;
11
+ const STOP_GRACE_SECONDS = 45;
12
+
13
+ function checkProjects(config, projects) {
14
+ if (projects.length === 0) throw refusal('STACK_RETIRE_NO_PROJECT', 'name the compose projects to retire with --project <name> (repeatable)');
15
+ for (const project of projects) {
16
+ if (!PROJECT.test(project)) throw refusal('STACK_RETIRE_PROJECT', `${project} is not a compose project name`);
17
+ if (project === config.stack) throw refusal('STACK_RETIRE_PROJECT', `${project} is the swarm stack, not an old compose project`);
18
+ }
19
+ }
20
+
21
+ /** The running containers of the selected projects, applications first and data stores last. */
22
+ function selected(run, projects) {
23
+ const all = snapshotContainers(run);
24
+ const chosen = all.filter((entry) => projects.includes(entry.project));
25
+ return startOrder(chosen).reverse();
26
+ }
27
+
28
+ /** `retire`: dry run unless execute. */
29
+ export async function retireCompose(ctx, config, { projects = [], execute = false, sshCmd = null } = {}, deps = {}) {
30
+ checkProjects(config, projects);
31
+ return withRunner(ctx, config, { sshCmd }, deps, (run, notes) => {
32
+ const containers = selected(run, projects);
33
+ const edge = config.ingress?.legacy?.container;
34
+ if (edge && containers.some((entry) => entry.name === edge)) throw refusal('STACK_RETIRE_EDGE', `the project of ${edge} (the legacy edge) is not retired here: the takeover keeps that container stopped for its own retention`);
35
+ const lines = [...notes, `${containers.length} running container(s) in ${projects.join(', ')}: ${containers.map((entry) => entry.name).join(', ') || 'none'}`];
36
+ if (containers.length === 0) return { ok: true, lines: [...lines, 'nothing to retire'], data: { stopped: [], failed: [] } };
37
+ if (!execute) return { ok: true, lines: [...lines, 'dry run: each would get --restart=no and then be stopped (nothing is removed)'], data: { stopped: [], failed: [] } };
38
+ const stopped = [];
39
+ const failed = [];
40
+ for (const entry of containers) {
41
+ const done = remoteDocker(run, ['update', '--restart=no', entry.name]).status === 0 && remoteDocker(run, ['stop', '-t', String(STOP_GRACE_SECONDS), entry.name]).status === 0;
42
+ (done ? stopped : failed).push(entry.name);
43
+ }
44
+ const outcome = failed.length === 0 ? `retired ${stopped.length} container(s); nothing was removed, start them again with docker start for a rollback` : `FAILED to retire: ${failed.join(', ')}`;
45
+ return { ok: failed.length === 0, lines: [...lines, outcome], data: { stopped, failed } };
46
+ });
47
+ }
@@ -0,0 +1,149 @@
1
+ // sealed.mjs - opens the sealed custody members in memory and derives, from names and keyed hashes only, which Swarm secret each
2
+ // service is granted (the shared half of the release tools). No value is ever written to disk, printed or put in an argument list:
3
+ // callers receive `value` and must keep it in memory. Config objects (non-secret files mounted into services) are versioned by content.
4
+ import { createHash, createHmac } from 'node:crypto';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { secretRoots } from '../runtime/scripts/hfs/secret-files.mjs';
8
+ import { readMap } from '../runtime/scripts/hfs/secret-store.mjs';
9
+ import { refusal } from './errors.mjs';
10
+
11
+ const KEY_NAME = /^[A-Z][A-Z0-9_]*$/u;
12
+ const UNSAFE_SETTING = /[\r\n"'\\]/u;
13
+ const MAX_NAME = 64;
14
+
15
+ /** sha256 of the bytes, as lowercase hex. */
16
+ export const sha = (bytes) => createHash('sha256').update(bytes).digest('hex');
17
+
18
+ const CR = String.fromCodePoint(13);
19
+ const LF = String.fromCodePoint(10);
20
+
21
+ /** True for content that is not text: a NUL byte in the first 8 KiB. Binary files are never rewritten. */
22
+ export const isBinary = (bytes) => bytes.subarray(0, 8192).includes(0);
23
+
24
+ /** Every CRLF of a string becomes LF. */
25
+ export const lfText = (text) => text.replaceAll(CR + LF, LF);
26
+
27
+ /**
28
+ * Line endings of a text file as the services need them: every CRLF becomes LF (a script rendered from a Windows checkout failed with
29
+ * `set: Illegal option -`). Binary content is returned untouched. Takes and returns a Buffer.
30
+ */
31
+ export function lfBytes(bytes) {
32
+ if (isBinary(bytes) || !bytes.includes(13)) return bytes;
33
+ return Buffer.from(lfText(bytes.toString('latin1')), 'latin1');
34
+ }
35
+
36
+ /** The bytes of a config file as they are hashed and created: LF-normalised text, untouched binary. */
37
+ export const configBytes = (file) => lfBytes(fs.readFileSync(file));
38
+
39
+ /** The key that makes the version suffix of a secret name unguessable from the name alone: a local fixture key or the age identity. */
40
+ export function versionKeyOf(ctx) {
41
+ const key = ctx.env.STARCI_SECRET_VERSION_KEY || ctx.env.SOPS_AGE_KEY;
42
+ if (!key) throw refusal('STACK_VERSION_KEY_MISSING', 'STARCI_SECRET_VERSION_KEY (a local fixture) or SOPS_AGE_KEY is needed to version the secret names');
43
+ return key;
44
+ }
45
+
46
+ /** `<stack>-<key>-v<10 hex of HMAC(versionKey, key NUL value)>`: a changed value is a new name, an unchanged one keeps its name. */
47
+ export function secretRuntimeName(stack, key, value, versionKey) {
48
+ const version = createHmac('sha256', versionKey).update(`${key}\0${value}`).digest('hex').slice(0, 10);
49
+ const name = `${stack}-${key.toLowerCase().replaceAll('_', '-')}-v${version}`;
50
+ if (name.length > MAX_NAME) throw refusal('STACK_SECRET_NAME_TOO_LONG', `the Swarm secret name for ${key} would exceed ${MAX_NAME} characters`);
51
+ return name;
52
+ }
53
+
54
+ const AGE_IDENTITY_ENV = /^SOPS_AGE_KEY(?:_FILE)?$/iu;
55
+ const AGE_HINT = 'load the age identity into SOPS_AGE_KEY (the secret.env of the owner machine, or the CI secret) and retry';
56
+
57
+ /** True when an age identity is reachable: SOPS_AGE_KEY or SOPS_AGE_KEY_FILE in the environment, the repository's .claude/secret.env or the per-user keys.txt. */
58
+ export function hasAgeIdentity(ctx, config) {
59
+ const named = Object.entries(ctx.env).some(([name, value]) => AGE_IDENTITY_ENV.test(name) && value);
60
+ return named || fs.existsSync(path.join(config.repoRoot, '.claude', 'secret.env')) || fs.existsSync(secretRoots(ctx.env).identity);
61
+ }
62
+
63
+ /** Refuses up front, before any work, when sealed custody must be opened and no age identity is reachable. */
64
+ export function requireAgeIdentity(ctx, config, purpose) {
65
+ if (!hasAgeIdentity(ctx, config)) throw refusal('STACK_AGE_IDENTITY_MISSING', `${purpose} needs the sealed members, and no age identity is available: ${AGE_HINT}`);
66
+ }
67
+
68
+ /** Opens one sealed member (`<secretsDir>/<member>.enc`) into a { KEY: value } map. Names the member only on failure. */
69
+ export function openMember(ctx, config, member) {
70
+ try {
71
+ const map = readMap(path.join(config.secretsDir, `${member}.enc`), ctx.sops);
72
+ return Object.fromEntries(Object.entries(map).map(([key, value]) => [key, typeof value === 'string' && !value.includes(String.fromCodePoint(0)) ? lfText(value) : value]));
73
+ } catch {
74
+ throw refusal('STACK_SECRET_MEMBER_UNREADABLE', `the sealed member ${member} cannot be opened${hasAgeIdentity(ctx, config) ? ' (is the age identity a recipient of this member?)' : `: no age identity is available, ${AGE_HINT}`}`);
75
+ }
76
+ }
77
+
78
+ const present = (map, key) => typeof map[key] === 'string' && map[key] !== '';
79
+
80
+ function sourceKeys(map, source) {
81
+ if (source.keys !== null) return source.keys;
82
+ return Object.keys(map).filter((key) => KEY_NAME.test(key) && !key.endsWith('_FILE'));
83
+ }
84
+
85
+ /** The KEY -> value pairs one grant source contributes, given what the same service already holds. */
86
+ function takeFromSource(map, source, held) {
87
+ const taken = {};
88
+ for (const key of sourceKeys(map, source)) {
89
+ if (!present(map, key)) {
90
+ if (source.required) throw refusal('STACK_SECRET_MISSING', `the sealed member ${source.member} has no value for ${key}`);
91
+ } else if (!(source.fill && Object.hasOwn(held, key))) taken[key] = map[key];
92
+ }
93
+ return taken;
94
+ }
95
+
96
+ function serviceValues(grant, memberOf) {
97
+ const held = {};
98
+ for (const source of grant.from) Object.assign(held, takeFromSource(memberOf(source.member), source, held));
99
+ return held;
100
+ }
101
+
102
+ function sealGrants(config, memberOf, versionKey) {
103
+ const grants = {};
104
+ const secrets = {};
105
+ const owner = {};
106
+ for (const grant of config.secrets.grants) {
107
+ const values = { ...grants[grant.service]?.values, ...serviceValues(grant, memberOf) };
108
+ grants[grant.service] = { values };
109
+ for (const [key, value] of Object.entries(values)) {
110
+ if (Object.hasOwn(secrets, key) && secrets[key].value !== value) throw refusal('STACK_SECRET_CONFLICT', `${key} has different values for ${owner[key]} and ${grant.service}`);
111
+ owner[key] ??= grant.service;
112
+ secrets[key] ??= { runtimeName: secretRuntimeName(config.stack, key, value, versionKey), value };
113
+ }
114
+ }
115
+ return { secrets, grants: Object.fromEntries(Object.entries(grants).map(([service, entry]) => [service, Object.keys(entry.values).sort()])) };
116
+ }
117
+
118
+ function sealSettings(config, memberOf) {
119
+ const block = config.secrets.settings;
120
+ if (block === null) return {};
121
+ const map = memberOf(block.member);
122
+ const settings = {};
123
+ for (const key of block.keys.filter((name) => present(map, name))) {
124
+ if (UNSAFE_SETTING.test(map[key])) throw refusal('STACK_SETTING_INVALID', `${key} must be a plain one-line value`);
125
+ settings[key] = map[key];
126
+ }
127
+ return settings;
128
+ }
129
+
130
+ /**
131
+ * Opens the sealed members once each and returns what every service is granted:
132
+ * { secrets: { KEY: { runtimeName, value } }, grants: { service: [KEY...] }, settings: { KEY: value } }. The caller must not log a value.
133
+ */
134
+ export function loadSealed(ctx, config) {
135
+ const versionKey = versionKeyOf(ctx);
136
+ const opened = new Map();
137
+ const memberOf = (member) => {
138
+ if (!opened.has(member)) opened.set(member, openMember(ctx, config, member));
139
+ return opened.get(member);
140
+ };
141
+ const { secrets, grants } = sealGrants(config, memberOf, versionKey);
142
+ return { secrets, grants, settings: sealSettings(config, memberOf) };
143
+ }
144
+
145
+ /** The config objects: non-secret files mounted into services, named by the digest of their content. */
146
+ export const configObjects = (config) => config.configs.map((entry) => {
147
+ const file = path.join(config.envDir, entry.file);
148
+ return { logical: entry.name, env: entry.env, file, name: `${config.stack}-${entry.name}-c${sha(configBytes(file)).slice(0, 10)}` };
149
+ });