@starci/hfs 4.3.0 → 4.4.1

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 (538) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +1 -1
  3. package/db/migrate.mjs +361 -0
  4. package/db/post-cutover.mjs +136 -0
  5. package/emit/contracts.mjs +2 -2
  6. package/emit/db-types.mjs +12 -3
  7. package/lint/run.mjs +3 -3
  8. package/package.json +4 -1
  9. package/release/gate.mjs +298 -0
  10. package/release/index.mjs +103 -0
  11. package/release/push.mjs +110 -0
  12. package/report/sonar.mjs +1 -1
  13. package/runtime/config.example.yaml +7 -6
  14. package/runtime/engine/config.mjs +10 -18
  15. package/runtime/engine/removed-vocabulary.mjs +0 -2
  16. package/runtime/knowledge/hfs/canon-pins.yaml +11 -11
  17. package/runtime/knowledge/hfs/fe-stack.yaml +22 -1
  18. package/runtime/knowledge/hfs/rules.yaml +2855 -163
  19. package/runtime/knowledge/hfs/slots.yaml +333 -62
  20. package/runtime/knowledge/patterns/be/api.yaml +32 -5
  21. package/runtime/knowledge/patterns/be/cli.yaml +14 -4
  22. package/runtime/knowledge/patterns/be/event-bus.yaml +3 -3
  23. package/runtime/knowledge/patterns/be/jobs.yaml +20 -2
  24. package/runtime/knowledge/patterns/be/projections.yaml +4 -2
  25. package/runtime/knowledge/patterns/be/queues.yaml +11 -3
  26. package/runtime/knowledge/patterns/be/reactors.yaml +20 -1
  27. package/runtime/knowledge/patterns/be/realtime.yaml +25 -3
  28. package/runtime/knowledge/patterns/be/saga.yaml +20 -3
  29. package/runtime/knowledge/patterns/be/webhooks.yaml +23 -4
  30. package/runtime/knowledge/patterns/fe/folder.yaml +1 -0
  31. package/runtime/knowledge/sonar-gate.yaml +1 -1
  32. package/runtime/modules/kernel/failure-codes.yaml +3091 -43
  33. package/runtime/modules/kernel/removed-vocabulary.yaml +30 -0
  34. package/runtime/modules/models/runtimes.yaml +5 -7
  35. package/runtime/modules/schemas/application-stacks.schema.yaml +663 -0
  36. package/runtime/packages/cli/src/catalog.generated.mjs +13769 -0
  37. package/runtime/packages/cli/src/validate-args.mjs +213 -0
  38. package/runtime/scripts/api/git/add.mjs +5 -0
  39. package/runtime/scripts/api/git/init.mjs +7 -0
  40. package/runtime/scripts/api/git/lib.mjs +4 -1
  41. package/runtime/scripts/api/git/tag.mjs +5 -0
  42. package/runtime/scripts/api/node/lib.mjs +20 -0
  43. package/runtime/scripts/api/node/run-node.mjs +6 -0
  44. package/runtime/scripts/api/sops/lib.mjs +42 -1
  45. package/runtime/scripts/api/sops/selected-age-recipient.mjs +7 -0
  46. package/runtime/scripts/api/supabase/database-dump.mjs +5 -0
  47. package/runtime/scripts/api/supabase/database-query.mjs +5 -0
  48. package/runtime/scripts/api/supabase/lib.mjs +44 -0
  49. package/runtime/scripts/api/supabase/migration-repair.mjs +5 -0
  50. package/runtime/scripts/hfs/app-wiring.mjs +201 -0
  51. package/runtime/scripts/hfs/architecture/backend-app-files.mjs +1 -8
  52. package/runtime/scripts/hfs/architecture/backend.mjs +2 -7
  53. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +6 -3
  54. package/runtime/scripts/hfs/architecture/config.mjs +1 -1
  55. package/runtime/scripts/hfs/architecture/dead-exports-bindings.mjs +149 -0
  56. package/runtime/scripts/hfs/architecture/dead-exports.mjs +9 -41
  57. package/runtime/scripts/hfs/architecture/default-deny.mjs +8 -6
  58. package/runtime/scripts/hfs/architecture/frontend-world.mjs +1 -1
  59. package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +153 -0
  60. package/runtime/scripts/hfs/architecture/hfs.mjs +3 -3
  61. package/runtime/scripts/hfs/architecture/index.mjs +1 -1
  62. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +28 -13
  63. package/runtime/scripts/hfs/architecture/next-data-inference.mjs +176 -0
  64. package/runtime/scripts/hfs/architecture/next-data-symbols.mjs +26 -0
  65. package/runtime/scripts/hfs/architecture/next-data.mjs +31 -23
  66. package/runtime/scripts/hfs/architecture/reachability.mjs +14 -0
  67. package/runtime/scripts/hfs/architecture/register-once.mjs +2 -2
  68. package/runtime/scripts/hfs/architecture/source-names.mjs +48 -3
  69. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +211 -11
  70. package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -1
  71. package/runtime/scripts/hfs/architecture/supabase.mjs +59 -21
  72. package/runtime/scripts/hfs/architecture/surface.mjs +2 -1
  73. package/runtime/scripts/hfs/architecture/tiers.mjs +4 -4
  74. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +8 -2
  75. package/runtime/scripts/hfs/architecture.mjs +1 -1
  76. package/runtime/scripts/hfs/check-results.mjs +28 -0
  77. package/runtime/scripts/hfs/check.mjs +46 -47
  78. package/runtime/scripts/hfs/coverage-from.mjs +70 -0
  79. package/runtime/scripts/hfs/coverage-scope.mjs +1 -1
  80. package/runtime/scripts/hfs/db-baseline-verb.mjs +276 -0
  81. package/runtime/scripts/hfs/db-plan-verb.mjs +254 -0
  82. package/runtime/scripts/hfs/db-restamp-verb.mjs +60 -0
  83. package/runtime/scripts/hfs/declaration-shape.mjs +39 -3
  84. package/runtime/scripts/hfs/doc-commands.mjs +367 -0
  85. package/runtime/scripts/hfs/edition-slots.mjs +2 -2
  86. package/runtime/scripts/hfs/example-secrets.mjs +158 -0
  87. package/runtime/scripts/hfs/manifest-shape.mjs +69 -9
  88. package/runtime/scripts/hfs/param-names.mjs +1 -1
  89. package/runtime/scripts/hfs/refusal-codes.mjs +6 -0
  90. package/runtime/scripts/hfs/rule-catalog.mjs +158 -11
  91. package/runtime/scripts/hfs/rule-params-shape.mjs +22 -10
  92. package/runtime/scripts/hfs/rules/a15-findings.mjs +88 -0
  93. package/runtime/scripts/hfs/rules/app-env-required.mjs +20 -0
  94. package/runtime/scripts/hfs/rules/app-root.mjs +2 -2
  95. package/runtime/scripts/hfs/rules/assets.mjs +79 -0
  96. package/runtime/scripts/hfs/rules/boot-proof.mjs +16 -0
  97. package/runtime/scripts/hfs/rules/canon-copy-drift.mjs +59 -0
  98. package/runtime/scripts/hfs/rules/cli.mjs +1 -1
  99. package/runtime/scripts/hfs/rules/contract-compat.mjs +1 -1
  100. package/runtime/scripts/hfs/rules/contract.mjs +67 -6
  101. package/runtime/scripts/hfs/rules/coverage-below.mjs +230 -0
  102. package/runtime/scripts/hfs/rules/database-access.mjs +343 -0
  103. package/runtime/scripts/hfs/rules/database-baseline.mjs +142 -0
  104. package/runtime/scripts/hfs/rules/database-config.mjs +250 -2
  105. package/runtime/scripts/hfs/rules/database-constants.mjs +9 -0
  106. package/runtime/scripts/hfs/rules/database-migrations.mjs +286 -7
  107. package/runtime/scripts/hfs/rules/database-plpgsql.mjs +119 -9
  108. package/runtime/scripts/hfs/rules/database-queue-sources.mjs +176 -0
  109. package/runtime/scripts/hfs/rules/database-queues.mjs +361 -0
  110. package/runtime/scripts/hfs/rules/database-sql.mjs +91 -9
  111. package/runtime/scripts/hfs/rules/database.mjs +9 -0
  112. package/runtime/scripts/hfs/rules/deploy-scan.mjs +192 -0
  113. package/runtime/scripts/hfs/rules/deploy-workflow.mjs +219 -0
  114. package/runtime/scripts/hfs/rules/doc-fresh.mjs +286 -0
  115. package/runtime/scripts/hfs/rules/docker-health.mjs +249 -0
  116. package/runtime/scripts/hfs/rules/docker.mjs +24 -7
  117. package/runtime/scripts/hfs/rules/edition.mjs +9 -8
  118. package/runtime/scripts/hfs/rules/env-parity.mjs +373 -0
  119. package/runtime/scripts/hfs/rules/env-value-shape.mjs +113 -0
  120. package/runtime/scripts/hfs/rules/event-bus.mjs +3 -2
  121. package/runtime/scripts/hfs/rules/event-tables.mjs +100 -0
  122. package/runtime/scripts/hfs/rules/fe-contract-documents.mjs +115 -15
  123. package/runtime/scripts/hfs/rules/fe-stack.mjs +57 -0
  124. package/runtime/scripts/hfs/rules/feature-files.mjs +28 -0
  125. package/runtime/scripts/hfs/rules/frontend-tree.mjs +54 -35
  126. package/runtime/scripts/hfs/rules/integration-specs.mjs +3 -2
  127. package/runtime/scripts/hfs/rules/lexicon.mjs +62 -0
  128. package/runtime/scripts/hfs/rules/lite-proof-lane.mjs +89 -0
  129. package/runtime/scripts/hfs/rules/monorepo.mjs +14 -1
  130. package/runtime/scripts/hfs/rules/pipeline.mjs +8 -1
  131. package/runtime/scripts/hfs/rules/release-provenance.mjs +211 -0
  132. package/runtime/scripts/hfs/rules/saga-delivery.mjs +66 -0
  133. package/runtime/scripts/hfs/rules/saga.mjs +8 -16
  134. package/runtime/scripts/hfs/rules/script-hygiene.mjs +151 -0
  135. package/runtime/scripts/hfs/rules/secret-custody-only.mjs +65 -0
  136. package/runtime/scripts/hfs/rules/secrets.mjs +13 -0
  137. package/runtime/scripts/hfs/rules/services.mjs +4 -4
  138. package/runtime/scripts/hfs/rules/stack-tooling.mjs +93 -0
  139. package/runtime/scripts/hfs/rules/stacks-deploy.mjs +76 -0
  140. package/runtime/scripts/hfs/rules/stacks-ingress.mjs +87 -0
  141. package/runtime/scripts/hfs/rules/stacks-lite-backing.mjs +111 -0
  142. package/runtime/scripts/hfs/rules/stacks-release.mjs +366 -0
  143. package/runtime/scripts/hfs/rules/stacks-runtime.mjs +41 -1
  144. package/runtime/scripts/hfs/rules/stacks-swarm.mjs +417 -0
  145. package/runtime/scripts/hfs/rules/supabase-secrets.mjs +2 -2
  146. package/runtime/scripts/hfs/rules/supply-chain.mjs +167 -0
  147. package/runtime/scripts/hfs/rules/unused-symbol-flow.mjs +101 -0
  148. package/runtime/scripts/hfs/rules/unused-symbol-ledger.mjs +77 -0
  149. package/runtime/scripts/hfs/rules/unused-symbol-translators.mjs +165 -0
  150. package/runtime/scripts/hfs/rules/unused-symbol-values.mjs +103 -0
  151. package/runtime/scripts/hfs/rules/unused-symbol.mjs +320 -0
  152. package/runtime/scripts/hfs/runtime-rules/source-ast.mjs +185 -0
  153. package/runtime/scripts/hfs/runtime-rules/workflow-source.mjs +19 -0
  154. package/runtime/scripts/hfs/sealed-members.mjs +101 -0
  155. package/runtime/scripts/hfs/secret-bootstrap-crypto.mjs +200 -0
  156. package/runtime/scripts/hfs/secret-edit.mjs +170 -0
  157. package/runtime/scripts/hfs/secret-init.mjs +127 -19
  158. package/runtime/scripts/hfs/secret-sops.mjs +38 -6
  159. package/runtime/scripts/hfs/secret-store.mjs +56 -54
  160. package/runtime/scripts/hfs/secret.mjs +34 -5
  161. package/runtime/scripts/hfs/slot-imports.mjs +18 -5
  162. package/runtime/scripts/hfs/slots.mjs +3 -2
  163. package/runtime/scripts/hfs/stack-config-kit.mjs +34 -0
  164. package/runtime/scripts/hfs/stack-deploy-config.mjs +313 -0
  165. package/runtime/scripts/hfs/stack-ingress-config.mjs +237 -0
  166. package/runtime/scripts/hfs/stacks-gate.mjs +414 -0
  167. package/runtime/scripts/hfs/starcistacks-runtime.mjs +61 -0
  168. package/runtime/scripts/hfs/typescript-programs.mjs +0 -8
  169. package/runtime/scripts/lib/command-tokens.mjs +126 -0
  170. package/runtime/scripts/lib/compose-values.mjs +11 -0
  171. package/runtime/scripts/lib/deployment-findings.mjs +7 -0
  172. package/runtime/scripts/lib/env.mjs +2 -2
  173. package/runtime/scripts/lib/git.mjs +1 -1
  174. package/runtime/scripts/lib/i18n.mjs +0 -2
  175. package/runtime/scripts/lib/json-clone.mjs +61 -0
  176. package/runtime/scripts/lib/language.mjs +1 -1
  177. package/runtime/scripts/lib/list.mjs +4 -0
  178. package/runtime/scripts/lib/roots.mjs +2 -4
  179. package/runtime/scripts/lib/runtime-example.mjs +13 -0
  180. package/runtime/scripts/lib/secret-patterns.mjs +60 -5
  181. package/runtime/scripts/lib/sql-ast.mjs +32 -0
  182. package/runtime/scripts/lib/stack-services.mjs +1 -1
  183. package/runtime/scripts/lib/swarm-stack.mjs +5 -18
  184. package/runtime/scripts/lib/trim-end.mjs +8 -0
  185. package/runtime/scripts/lib/ts-analysis.mjs +5 -2
  186. package/runtime/scripts/lib/ts-ast.mjs +27 -2
  187. package/runtime/scripts/lib/workflow-events.mjs +8 -0
  188. package/scaffold/add-app.mjs +77 -59
  189. package/scaffold/add-cli-lite.mjs +14 -6
  190. package/scaffold/add-compose.mjs +79 -0
  191. package/scaffold/add-database-lite.mjs +14 -0
  192. package/scaffold/add-fe-overlay.mjs +1 -0
  193. package/scaffold/add-fe-page.mjs +1 -0
  194. package/scaffold/add-inbox-lite.mjs +74 -42
  195. package/scaffold/add-table.mjs +110 -87
  196. package/scaffold/add.mjs +159 -44
  197. package/scaffold/app.mjs +124 -39
  198. package/scaffold/edition-gate.mjs +3 -3
  199. package/scaffold/lite-exports.mjs +2 -1
  200. package/scaffold/service.mjs +2 -2
  201. package/scaffold/supabase-ports.mjs +1 -1
  202. package/src/main.mjs +229 -116
  203. package/stack/age-install.mjs +37 -0
  204. package/stack/backup.mjs +100 -0
  205. package/stack/build-args.mjs +42 -0
  206. package/stack/build.mjs +173 -0
  207. package/stack/cli.mjs +130 -0
  208. package/stack/config.mjs +57 -0
  209. package/stack/context.mjs +57 -0
  210. package/stack/converge.mjs +107 -0
  211. package/stack/deploy.mjs +301 -0
  212. package/stack/digest.mjs +16 -0
  213. package/stack/docker-transport.mjs +53 -0
  214. package/stack/edge-alias.mjs +118 -0
  215. package/stack/errors.mjs +21 -0
  216. package/stack/image-scan.mjs +152 -0
  217. package/stack/ingress-create.mjs +114 -0
  218. package/stack/ingress-publish.mjs +273 -0
  219. package/stack/ingress.mjs +706 -0
  220. package/stack/jobs.mjs +51 -0
  221. package/stack/live-restore.mjs +177 -0
  222. package/stack/prepare.mjs +266 -0
  223. package/stack/private-file.mjs +22 -0
  224. package/stack/provision.mjs +90 -0
  225. package/stack/prune.mjs +46 -0
  226. package/stack/release-store.mjs +87 -0
  227. package/stack/remote.mjs +205 -0
  228. package/stack/render.mjs +149 -0
  229. package/stack/retire.mjs +47 -0
  230. package/stack/sealed.mjs +149 -0
  231. package/stack/task.mjs +42 -0
  232. package/stack/verify.mjs +99 -0
  233. package/sync/ci-commands.mjs +13 -0
  234. package/sync/format.mjs +1 -1
  235. package/sync/hygiene.mjs +1 -1
  236. package/sync/index.mjs +110 -54
  237. package/sync/stack-images.mjs +127 -0
  238. package/templates/app/ci-workflows/github/workflows/ci.yml +5 -1
  239. package/templates/app/ci-workflows/github/workflows/images.yml +15 -3
  240. package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +148 -0
  241. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +7 -3
  242. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +38 -6
  243. package/templates/app/ci-workflows-lite/github/workflows/images.yml +14 -2
  244. package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +148 -0
  245. package/templates/app/codegen-graphql.mjs.partial +13 -2
  246. package/templates/app/codegen-lite/scripts/codegen.mjs +6 -1
  247. package/templates/app/codegen-prelude.mjs.partial +8 -0
  248. package/templates/app/gitignore +3 -0
  249. package/templates/app/gitignore-lite +3 -0
  250. package/templates/app/package-scripts/package.json +1 -0
  251. package/templates/app/package-scripts/supabase.json +7 -0
  252. package/templates/app/package-scripts-lite/package.json +1 -7
  253. package/templates/app/skeleton/.editorconfig +1 -0
  254. package/templates/app/skeleton/.gitattributes +7 -1
  255. package/templates/app/skeleton/.github/dependabot.yml +17 -0
  256. package/templates/app/skeleton/.nvmrc +1 -1
  257. package/templates/app/skeleton/.starcistacks/application-stacks.yaml +134 -1
  258. package/templates/app/skeleton/.starcistacks/dev/README.md +16 -0
  259. package/templates/app/skeleton/.starcistacks/dev/environment.json +5 -0
  260. package/templates/app/skeleton/.starcistacks/dev/infra/compose/stack.yaml +71 -0
  261. package/templates/app/skeleton/.starcistacks/dev/runtime/env/KEYS.md +14 -0
  262. package/templates/app/skeleton/.starcistacks/production/README.md +37 -0
  263. package/templates/app/skeleton/.starcistacks/production/environment.json +5 -0
  264. package/templates/app/skeleton/.starcistacks/production/infra/swarm/Caddyfile +15 -0
  265. package/templates/app/skeleton/.starcistacks/production/infra/swarm/owner-actions.md +11 -0
  266. package/templates/app/skeleton/.starcistacks/production/infra/swarm/recovery/restore-proof.md +7 -0
  267. package/templates/app/skeleton/.starcistacks/production/infra/swarm/releases/current.yaml +6 -0
  268. package/templates/app/skeleton/.starcistacks/production/infra/swarm/releases/images.lock.yaml +9 -0
  269. package/templates/app/skeleton/.starcistacks/production/infra/swarm/releases/previous.yaml +6 -0
  270. package/templates/app/skeleton/.starcistacks/production/infra/swarm/stack.yaml +142 -0
  271. package/templates/app/skeleton/.starcistacks/production/runtime/env/KEYS.md +14 -0
  272. package/templates/app/skeleton/README.md +6 -6
  273. package/templates/app/skeleton/scripts/codegen.mjs +1 -2
  274. package/templates/app/skeleton-lite/.editorconfig +16 -0
  275. package/templates/app/skeleton-lite/.gitattributes +8 -0
  276. package/templates/app/skeleton-lite/.github/dependabot.yml +17 -0
  277. package/templates/app/skeleton-lite/.nvmrc +1 -0
  278. package/templates/app/skeleton-lite/.sops.yaml +3 -2
  279. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +178 -1
  280. package/templates/app/skeleton-lite/.starcistacks/dev/README.md +20 -0
  281. package/templates/app/skeleton-lite/.starcistacks/dev/environment.json +2 -1
  282. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/Caddyfile +16 -0
  283. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/owner-actions.md +11 -0
  284. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/recovery/restore-proof.md +7 -0
  285. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/releases/current.yaml +6 -0
  286. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/releases/images.lock.yaml +8 -0
  287. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/releases/previous.yaml +6 -0
  288. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +120 -0
  289. package/templates/app/skeleton-lite/.starcistacks/dev/runtime/env/KEYS.md +18 -0
  290. package/templates/app/skeleton-lite/.starcistacks/production/README.md +38 -0
  291. package/templates/app/skeleton-lite/.starcistacks/production/environment.json +6 -0
  292. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/Caddyfile +11 -0
  293. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/owner-actions.md +11 -0
  294. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/recovery/restore-proof.md +7 -0
  295. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/releases/current.yaml +6 -0
  296. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/releases/images.lock.yaml +8 -0
  297. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/releases/previous.yaml +6 -0
  298. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +121 -0
  299. package/templates/app/skeleton-lite/.starcistacks/production/runtime/env/KEYS.md +21 -0
  300. package/templates/app/skeleton-lite/.starciwork/brand/index.yaml +12 -0
  301. package/templates/app/skeleton-lite/README.md +5 -3
  302. package/templates/app/skeleton-lite/supabase/config.toml +8 -1
  303. package/templates/app/skeleton-lite/supabase/migrations/__timestamp___baseline.sql +15 -6
  304. package/templates/app/upgrade-full/.starcistacks/dev/README.md +13 -7
  305. package/templates/be/image/worker/Dockerfile +1 -1
  306. package/templates/be/inbox/database-client.spec.ts.tpl +17 -18
  307. package/templates/be/inbox/database-client.ts.tpl +12 -11
  308. package/templates/be/inbox/delivery-recorded.event.ts.tpl +36 -0
  309. package/templates/be/inbox/event-index.ts.tpl +1 -0
  310. package/templates/be/inbox/events.json.tpl +10 -0
  311. package/templates/be/inbox/migration.sql.tpl +1 -0
  312. package/templates/be/inbox/platform/event-bus/event-bus.contracts.ts.tpl +42 -0
  313. package/templates/be/inbox/platform/event-bus/event-bus.decorators.ts.tpl +9 -0
  314. package/templates/be/inbox/platform/event-bus/event-bus.module-definition.ts.tpl +6 -0
  315. package/templates/be/inbox/platform/event-bus/event-bus.module.ts.tpl +19 -0
  316. package/templates/be/inbox/platform/event-bus/event-bus.options.ts.tpl +2 -0
  317. package/templates/be/inbox/platform/event-bus/event-bus.port.ts.tpl +16 -0
  318. package/templates/be/inbox/platform/event-bus/event-bus.service.spec.ts.tpl +45 -0
  319. package/templates/be/inbox/platform/event-bus/event-bus.service.ts.tpl +15 -0
  320. package/templates/be/inbox/platform/event-bus/index.ts.tpl +5 -0
  321. package/templates/be/inbox/platform/event-bus/persistence/event-bus.sql.ts.tpl +5 -0
  322. package/templates/be/inbox/platform/inbox/inbox.decorators.ts.tpl +9 -0
  323. package/templates/be/inbox/platform/inbox/inbox.module-definition.ts.tpl +6 -0
  324. package/templates/be/inbox/platform/inbox/inbox.module.ts.tpl +19 -0
  325. package/templates/be/inbox/platform/inbox/inbox.options.ts.tpl +2 -0
  326. package/templates/be/inbox/platform/inbox/inbox.port.ts.tpl +10 -0
  327. package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +41 -0
  328. package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +19 -0
  329. package/templates/be/inbox/platform/inbox/index.ts.tpl +3 -0
  330. package/templates/be/inbox/platform/inbox/persistence/inbox.sql.ts.tpl +7 -0
  331. package/templates/be/inbox/platform/migration.sql.tpl +48 -0
  332. package/templates/be/inbox/service.spec.ts.tpl +42 -9
  333. package/templates/be/inbox/service.ts.tpl +23 -8
  334. package/templates/be/inbox/webhook-door.spec.ts.tpl +1 -1
  335. package/templates/be/inbox/webhook-door.ts.tpl +2 -2
  336. package/templates/be/patterns/api/feature.module-definition.ts.tpl +6 -0
  337. package/templates/be/patterns/api/feature.module.ts.tpl +2 -1
  338. package/templates/be/patterns/api/feature.options.ts.tpl +2 -0
  339. package/templates/be/patterns/api/graphql.module-definition.ts.tpl +6 -0
  340. package/templates/be/patterns/api/graphql.module.ts.tpl +3 -2
  341. package/templates/be/patterns/api/graphql.options.ts.tpl +2 -0
  342. package/templates/be/patterns/cli/group.module-definition.ts.tpl +6 -0
  343. package/templates/be/patterns/cli/group.module.ts.tpl +2 -1
  344. package/templates/be/patterns/cli/group.options.ts.tpl +2 -0
  345. package/templates/be/patterns/event-bus/platform/errors/event-bus.error.ts.tpl +0 -3
  346. package/templates/be/patterns/event-bus/platform/messages/event-bus.messages.ts.tpl +0 -2
  347. package/templates/be/patterns/jobs/job.module-definition.ts.tpl +6 -0
  348. package/templates/be/patterns/jobs/job.module.ts.tpl +2 -1
  349. package/templates/be/patterns/jobs/job.options.ts.tpl +2 -0
  350. package/templates/be/patterns/jobs/job.queue.module-definition.ts.tpl +6 -0
  351. package/templates/be/patterns/jobs/job.queue.module.ts.tpl +6 -3
  352. package/templates/be/patterns/jobs/job.queue.options.ts.tpl +2 -0
  353. package/templates/be/patterns/jobs/platform/fenced.processor.spec.ts.tpl +3 -1
  354. package/templates/be/patterns/outbox/platform/outbox-relay.policy.spec.ts.tpl +25 -9
  355. package/templates/be/patterns/queues/platform/queue.config.ts.tpl +1 -1
  356. package/templates/be/patterns/queues/platform/queue.index.ts.tpl +1 -1
  357. package/templates/be/patterns/queues/platform/queue.module-definition.ts.tpl +2 -2
  358. package/templates/be/patterns/queues/platform/queue.module.ts.tpl +11 -0
  359. package/templates/be/patterns/queues/platform/queue.options.ts.tpl +10 -0
  360. package/templates/be/patterns/queues/queue.module-definition.ts.tpl +6 -0
  361. package/templates/be/patterns/queues/queue.module.ts.tpl +2 -1
  362. package/templates/be/patterns/queues/queue.options.ts.tpl +2 -0
  363. package/templates/be/patterns/reactors/message.module-definition.ts.tpl +6 -0
  364. package/templates/be/patterns/reactors/message.module.ts.tpl +6 -3
  365. package/templates/be/patterns/reactors/message.options.ts.tpl +2 -0
  366. package/templates/be/patterns/reactors/reactor.module-definition.ts.tpl +6 -0
  367. package/templates/be/patterns/reactors/reactor.module.ts.tpl +2 -1
  368. package/templates/be/patterns/reactors/reactor.options.ts.tpl +2 -0
  369. package/templates/be/patterns/realtime/channel.gateway.spec.ts.tpl +188 -0
  370. package/templates/be/patterns/realtime/channel.gateway.ts.tpl +22 -0
  371. package/templates/be/patterns/realtime/channel.graphql.module-definition.ts.tpl +6 -0
  372. package/templates/be/patterns/realtime/channel.graphql.module.ts.tpl +2 -1
  373. package/templates/be/patterns/realtime/channel.graphql.options.ts.tpl +2 -0
  374. package/templates/be/patterns/realtime/channel.websocket.module-definition.ts.tpl +6 -0
  375. package/templates/be/patterns/realtime/channel.websocket.module.ts.tpl +110 -0
  376. package/templates/be/patterns/realtime/channel.websocket.options.ts.tpl +7 -0
  377. package/templates/be/patterns/saga/saga.message.module-definition.ts.tpl +6 -0
  378. package/templates/be/patterns/saga/saga.message.module.ts.tpl +6 -3
  379. package/templates/be/patterns/saga/saga.message.options.ts.tpl +2 -0
  380. package/templates/be/patterns/saga/saga.module-definition.ts.tpl +6 -0
  381. package/templates/be/patterns/saga/saga.module.ts.tpl +2 -1
  382. package/templates/be/patterns/saga/saga.options.ts.tpl +2 -0
  383. package/templates/be/patterns/webhooks/webhook.http.module-definition.ts.tpl +6 -0
  384. package/templates/be/patterns/webhooks/webhook.http.module.ts.tpl +2 -1
  385. package/templates/be/patterns/webhooks/webhook.http.options.ts.tpl +2 -0
  386. package/templates/be/platform/graphql/localize-errors.mapper.spec.ts +80 -26
  387. package/templates/be/platform/graphql/localize-errors.mapper.ts +2 -5
  388. package/templates/be/skeleton/apps/__app__/src/app.module.ts +11 -9
  389. package/templates/be/skeleton/apps/__app__/src/compose/api.ts +4 -0
  390. package/templates/be/skeleton/apps/__app__/src/compose/domain.ts +11 -0
  391. package/templates/be/skeleton/apps/__app__/src/compose/platform.ts +2 -0
  392. package/templates/be/skeleton/apps/__app__/src/compose/system.ts +5 -0
  393. package/templates/be/skeleton/apps/__app__/src/compose/webhooks.ts +4 -0
  394. package/templates/be/skeleton/apps/__app__/src/main.ts +15 -1
  395. package/templates/be/skeleton/apps/cli/src/app.module.ts +3 -13
  396. package/templates/be/skeleton/apps/cli/src/cli.options.ts +6 -14
  397. package/templates/be/skeleton/apps/cli/src/compose/cli.ts +5 -0
  398. package/templates/be/skeleton/apps/cli/src/compose/platform.ts +24 -0
  399. package/templates/be/skeleton/src/features/api/system-health/application/check-readiness.contracts.ts +2 -0
  400. package/templates/be/skeleton/src/features/api/system-health/application/check-readiness.handler.ts +22 -0
  401. package/templates/be/skeleton/src/features/api/system-health/application/check-readiness.query.ts +11 -0
  402. package/templates/be/skeleton/src/features/api/system-health/system-health.module-definition.ts +6 -0
  403. package/templates/be/skeleton/src/features/api/system-health/system-health.module.ts +5 -3
  404. package/templates/be/skeleton/src/features/api/system-health/system-health.options.ts +2 -0
  405. package/templates/be/skeleton/src/features/api/system-health/transport/http/ready.controller.ts +19 -0
  406. package/templates/be/skeleton/src/features/api/system-health/transport/http/system-health-http.module-definition.ts +6 -0
  407. package/templates/be/skeleton/src/features/api/system-health/transport/http/system-health-http.module.ts +8 -3
  408. package/templates/be/skeleton/src/features/api/system-health/transport/http/system-health-http.options.ts +2 -0
  409. package/templates/be/skeleton/src/features/cli/cli.module-definition.ts +6 -0
  410. package/templates/be/skeleton/src/features/cli/cli.module.ts +3 -2
  411. package/templates/be/skeleton/src/features/cli/cli.options.ts +2 -0
  412. package/templates/be/skeleton/src/features/cli/migrate/migrate.module-definition.ts +6 -0
  413. package/templates/be/skeleton/src/features/cli/migrate/migrate.module.ts +2 -1
  414. package/templates/be/skeleton/src/features/cli/migrate/migrate.options.ts +2 -0
  415. package/templates/be/skeleton/src/features/cli/seed/seed.module-definition.ts +6 -0
  416. package/templates/be/skeleton/src/features/cli/seed/seed.module.ts +2 -1
  417. package/templates/be/skeleton/src/features/cli/seed/seed.options.ts +2 -0
  418. package/templates/be/skeleton/src/features/cli/seed/subs/run.cli.spec.ts +5 -6
  419. package/templates/be/skeleton/src/modules/domain/identity/admission.policy.spec.ts +56 -4
  420. package/templates/be/skeleton/src/modules/domain/identity/admission.policy.ts +36 -5
  421. package/templates/be/skeleton/src/modules/domain/identity/auth.guard.spec.ts +39 -8
  422. package/templates/be/skeleton/src/modules/domain/identity/auth.guard.ts +14 -8
  423. package/templates/be/skeleton/src/modules/domain/identity/credential.contracts.ts +26 -0
  424. package/templates/be/skeleton/src/modules/domain/identity/credential.guard.spec.ts +119 -0
  425. package/templates/be/skeleton/src/modules/domain/identity/credential.guard.ts +43 -0
  426. package/templates/be/skeleton/src/modules/domain/identity/identity.contracts.ts +16 -0
  427. package/templates/be/skeleton/src/modules/domain/identity/identity.decorators.ts +18 -1
  428. package/templates/be/skeleton/src/modules/domain/identity/identity.module.ts +22 -4
  429. package/templates/be/skeleton/src/modules/domain/identity/identity.options.ts +8 -2
  430. package/templates/be/skeleton/src/modules/domain/identity/index.ts +1 -0
  431. package/templates/be/skeleton/src/modules/platform/database/connection-source.client.spec.ts +11 -4
  432. package/templates/be/skeleton/src/modules/platform/database/database-probe.service.spec.ts +32 -0
  433. package/templates/be/skeleton/src/modules/platform/database/database-probe.service.ts +14 -0
  434. package/templates/be/skeleton/src/modules/platform/database/database.module.ts +7 -1
  435. package/templates/be/skeleton/src/modules/platform/database/index.ts +2 -0
  436. package/templates/be/skeleton/src/modules/platform/database/primary.decorators.ts +8 -0
  437. package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.spec.ts +25 -6
  438. package/templates/be/skeleton/src/modules/platform/errors/errors.filter.spec.ts +16 -6
  439. package/templates/be/skeleton/src/modules/platform/http-security/edge-security.service.spec.ts +130 -0
  440. package/templates/be/skeleton/src/modules/platform/http-security/edge-security.service.ts +58 -0
  441. package/templates/be/skeleton/src/modules/platform/http-security/errors/http-security.error.ts +3 -0
  442. package/templates/be/skeleton/src/modules/platform/http-security/execution-request.mapper.spec.ts +16 -4
  443. package/templates/be/skeleton/src/modules/platform/http-security/http-security.config.ts +16 -10
  444. package/templates/be/skeleton/src/modules/platform/http-security/http-security.module.ts +3 -1
  445. package/templates/be/skeleton/src/modules/platform/http-security/http-security.options.ts +2 -0
  446. package/templates/be/skeleton/src/modules/platform/http-security/index.ts +5 -0
  447. package/templates/be/skeleton/src/modules/platform/http-security/messages/http-security.messages.ts +12 -5
  448. package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.spec.ts +13 -8
  449. package/templates/be/skeleton/src/modules/platform/http-security/readiness.contracts.ts +5 -0
  450. package/templates/be/skeleton/src/modules/platform/http-security/readiness.decorators.ts +7 -0
  451. package/templates/be/skeleton/src/modules/platform/http-security/readiness.service.spec.ts +86 -0
  452. package/templates/be/skeleton/src/modules/platform/http-security/readiness.service.ts +39 -0
  453. package/templates/be/skeleton/src/modules/platform/http-security/webhook-signature.service.spec.ts +139 -131
  454. package/templates/be/skeleton/src/modules/platform/i18n/index.ts +1 -1
  455. package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.spec.ts +91 -67
  456. package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.ts +5 -4
  457. package/templates/be/skeleton/src/modules/platform/logging/logging.log-events.ts +0 -2
  458. package/templates/be/skeleton/src/modules/platform/primitives/outcome.mapper.spec.ts +20 -6
  459. package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.spec.ts +16 -4
  460. package/templates/be/skeleton/src/modules/platform/retry/index.ts +3 -0
  461. package/templates/be/skeleton/src/modules/platform/retry/retry.decorators.ts +9 -0
  462. package/templates/be/skeleton/src/modules/platform/retry/retry.module-definition.ts +7 -0
  463. package/templates/be/skeleton/src/modules/platform/retry/retry.module.ts +19 -0
  464. package/templates/be/skeleton/src/modules/platform/retry/retry.options.ts +2 -0
  465. package/templates/be/skeleton/src/modules/platform/retry/retry.port.ts +11 -0
  466. package/templates/be/skeleton/src/modules/platform/retry/system-timer.service.spec.ts +36 -0
  467. package/templates/be/skeleton/src/modules/platform/retry/system-timer.service.ts +10 -0
  468. package/templates/be/skeleton/src/tests/contract/http-security/http-security.contract-spec.ts +54 -0
  469. package/templates/be/skeleton-lite/apps/api/src/app.module.ts +14 -18
  470. package/templates/be/skeleton-lite/apps/api/src/compose/api.ts +4 -0
  471. package/templates/be/skeleton-lite/apps/api/src/compose/domain.ts +10 -0
  472. package/templates/be/skeleton-lite/apps/api/src/compose/integrations.ts +16 -0
  473. package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +2 -0
  474. package/templates/be/skeleton-lite/apps/api/src/compose/system.ts +5 -0
  475. package/templates/be/skeleton-lite/apps/api/src/compose/webhooks.ts +4 -0
  476. package/templates/be/skeleton-lite/apps/api/src/main.ts +2 -3
  477. package/templates/be/skeleton-lite/apps/cli/src/compose/platform.ts +12 -0
  478. package/templates/be/skeleton-lite/src/modules/domain/identity/admission.policy.spec.ts +71 -5
  479. package/templates/be/skeleton-lite/src/modules/domain/identity/admission.policy.ts +29 -2
  480. package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.spec.ts +68 -15
  481. package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.ts +14 -9
  482. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.contracts.ts +9 -2
  483. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +16 -1
  484. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.module.ts +19 -4
  485. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.options.ts +5 -1
  486. package/templates/be/skeleton-lite/src/modules/domain/identity/index.ts +2 -1
  487. package/templates/be/skeleton-lite/src/modules/integrations/supabase/errors/supabase.error.ts +0 -3
  488. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.client.spec.ts +17 -5
  489. package/templates/be/skeleton-lite/src/modules/platform/database/database.module.ts +24 -13
  490. package/templates/be/skeleton-lite/src/modules/platform/database/database.port.ts +5 -0
  491. package/templates/be/skeleton-lite/src/modules/platform/database/errors/database.error.ts +16 -0
  492. package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +3 -0
  493. package/templates/be/skeleton-lite/src/modules/platform/http-security/http-security.module.ts +5 -2
  494. package/templates/be/skeleton-lite/src/modules/platform/http-security/index.ts +4 -1
  495. package/templates/be/skeleton-lite/src/modules/platform/i18n/index.ts +1 -1
  496. package/templates/be/skeleton-lite/src/modules/platform/logging/logging.log-events.ts +7 -0
  497. package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +29 -0
  498. package/templates/be/skeleton-lite/src/tests/fixtures/builders/cli.builder.ts +16 -0
  499. package/templates/be/table/api/feature.module-definition.ts.tpl +6 -0
  500. package/templates/be/table/api/feature.module.ts.tpl +2 -1
  501. package/templates/be/table/api/feature.options.ts.tpl +2 -0
  502. package/templates/be/table/api/graphql.module-definition.ts.tpl +6 -0
  503. package/templates/be/table/api/graphql.module.ts.tpl +3 -2
  504. package/templates/be/table/api/graphql.options.ts.tpl +2 -0
  505. package/templates/be/table/migration.sql.tpl +1 -0
  506. package/templates/be/upgrade-full/apps/cli/src/app.module.ts +4 -6
  507. package/templates/be/upgrade-full/apps/cli/src/cli.env.ts +7 -0
  508. package/templates/be/upgrade-full/apps/cli/src/compose/cli.ts +5 -0
  509. package/templates/be/upgrade-full/apps/cli/src/compose/platform.ts +9 -0
  510. package/templates/be/upgrade-full/apps/cli/src/main.ts +3 -0
  511. package/templates/be/upgrade-full/src/features/cli/cli.module.ts +3 -2
  512. package/templates/fe/auth/supabase-client.ts.tpl +34 -0
  513. package/templates/fe/skeleton/apps/__app__/src/app/auth/callback/route.ts +11 -0
  514. package/templates/fe/skeleton/apps/__app__/src/components/blocks/SignInForm/index.tsx +6 -1
  515. package/templates/fe/skeleton/apps/__app__/src/components/leaves/Icon/index.tsx +4 -0
  516. package/templates/fe/skeleton/apps/__app__/src/features/overlays/AppGuideOverlay/component.tsx +31 -0
  517. package/templates/fe/skeleton/apps/__app__/src/features/overlays/AppGuideOverlay/index.tsx +24 -0
  518. package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSession.ts +6 -1
  519. package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSignOut.ts +3 -4
  520. package/templates/fe/skeleton/apps/__app__/src/hooks/zustand/appGuide/index.ts +1 -0
  521. package/templates/fe/skeleton/apps/__app__/src/hooks/zustand/appGuide/store.ts +15 -0
  522. package/templates/fe/skeleton/apps/__app__/src/modules/auth/pkce-client.ts +18 -0
  523. package/templates/fe/skeleton/apps/__app__/src/modules/auth/server.ts +19 -0
  524. package/templates/fe/skeleton/apps/app/src/features/pages/AppHomePage/index.tsx +7 -1
  525. package/templates/fe/skeleton/apps/app/src/modules/i18n/messages/en.json +57 -0
  526. package/templates/fe/skeleton/apps/app/src/modules/i18n/messages/vi.json +6 -0
  527. package/templates/fe/skeleton/apps/landing/src/modules/i18n/messages/en.json +31 -0
  528. package/templates/fe/skeleton/packages/__project__-i18n/src/routing.ts +1 -1
  529. package/templates/fe/skeleton/packages/__project__-ui/src/index.ts +1 -0
  530. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/client.ts +1 -34
  531. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppHomePage/index.tsx +7 -1
  532. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/en.json +57 -0
  533. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/vi.json +30 -24
  534. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/routing.ts +1 -1
  535. package/templates/fe/transport/graphql/clients/clients/auth.ts +1 -1
  536. package/templates/fe/transport/graphql/clients/links/locale.ts +2 -2
  537. package/upgrade/check.mjs +3 -1
  538. package/upgrade/index.mjs +54 -41
package/stack/task.mjs ADDED
@@ -0,0 +1,42 @@
1
+ // task.mjs - `starci app stack task <name>`: runs ONE declared one-off command (deploy.tasks.<name>.command) inside the running task of
2
+ // deploy.tasks.<name>.service on the manager, through the ssh runner, and relays its stdout. Which command runs is the app's declaration, never a
3
+ // flag: the only argument is the task name. Without --execute a dry-run line says what would run. Output is relayed as the command prints it; this
4
+ // module never reads or prints environment values.
5
+ import { firstLine, lines as splitLines } from './context.mjs';
6
+ import { failure, refusal } from './errors.mjs';
7
+ import { remoteDocker, withRunner } from './remote.mjs';
8
+
9
+ const TASK_NAME = /^[a-z][a-z0-9-]{0,40}$/u;
10
+
11
+ function undeclared(config, why) {
12
+ const names = Object.keys(config.tasks ?? {});
13
+ const listing = names.length > 0 ? `declared tasks: ${names.join(', ')}` : 'the environment declares no deploy.tasks';
14
+ return refusal('STACK_TASK_UNDECLARED', `${why}; ${listing}`);
15
+ }
16
+
17
+ function runningTask(run, service) {
18
+ const result = remoteDocker(run, ['ps', '-q', '--filter', `label=com.docker.swarm.service.name=${service}`, '--filter', 'status=running']);
19
+ const task = result.status === 0 ? splitLines(result.stdout)[0] : undefined;
20
+ if (task) return task;
21
+ const reason = result.status === 0 ? 'none is running' : firstLine(result.stderr);
22
+ throw failure('STACK_TASK_NO_TASK', `no running task of service ${service} on the manager (${reason})`);
23
+ }
24
+
25
+ /** task: runs the declared one-off command `name`. */
26
+ export async function runTask(ctx, config, { name, args = [], execute = false, sshCmd = null } = {}, deps = {}) {
27
+ if (name === undefined || !TASK_NAME.test(String(name)) || !Object.hasOwn(config.tasks ?? {}, name)) {
28
+ throw undeclared(config, name === undefined ? 'task takes the name of a declared task' : `no task named ${String(name).slice(0, 41)} is declared`);
29
+ }
30
+ if (args.length > 0) throw refusal('STACK_USAGE', 'task takes only the task name; the command is the app\'s declaration');
31
+ const declared = config.tasks[name];
32
+ if (!execute) {
33
+ return { ok: true, lines: [`dry run: would run task ${name} (${declared.command.join(' ')}) in the running ${declared.service} task (add --execute)`], data: { name, status: null } };
34
+ }
35
+ return withRunner(ctx, config, { sshCmd }, deps, (run, notes) => {
36
+ const task = runningTask(run, `${config.stack}_${declared.service}`);
37
+ const result = remoteDocker(run, ['exec', task, ...declared.command]);
38
+ const lines = [...notes, ...splitLines(result.stdout)];
39
+ if (result.status !== 0) lines.push(`FAIL task ${name} exited ${result.status}: ${firstLine(result.stderr)}`);
40
+ return { ok: result.status === 0, lines, data: { name, status: result.status } };
41
+ });
42
+ }
@@ -0,0 +1,99 @@
1
+ // verify.mjs - functional verification after convergence: the declared HTTP journeys (config.verify.http) and the declared checks inside a
2
+ // running task (config.verify.exec). Probes are bounded and read-only; a POST body comes only from the declared config and nothing here
3
+ // mutates business data. HTTP goes through ctx.fetch, docker through ctx.spawn, waits through ctx.sleep and time through ctx.now.
4
+ import { isDeepStrictEqual } from 'node:util';
5
+ import { eachInOrder, repeatInOrder } from '../runtime/scripts/lib/in-order.mjs';
6
+ import { lines as splitLines, tool } from './context.mjs';
7
+ import { edgeAttachmentsOf, verifyEdgeAttachment } from './edge-alias.mjs';
8
+
9
+ const RETRY_MS = 6000;
10
+ const HTTP_TIMEOUT_MS = 8000;
11
+
12
+ const expect = (condition, message) => {
13
+ if (!condition) throw new Error(message);
14
+ };
15
+
16
+ /** Runs one check, retrying every 6 s up to `retrySeconds` when `retry` is set; records { name, ok, detail }. */
17
+ async function runCheck(ctx, results, name, run, { retry = false, retrySeconds = 0 } = {}) {
18
+ const deadline = ctx.now() + (retry ? retrySeconds * 1000 : 0);
19
+ const outcome = await repeatInOrder(async () => {
20
+ try {
21
+ return { ok: true, detail: await run() };
22
+ } catch (error) {
23
+ if (ctx.now() >= deadline) return { ok: false, detail: String(error.message) };
24
+ await ctx.sleep(RETRY_MS);
25
+ return undefined;
26
+ }
27
+ });
28
+ results.push({ name, ...outcome });
29
+ }
30
+
31
+ function checkJson(text, expected) {
32
+ let body;
33
+ try { body = JSON.parse(text); } catch { throw new Error('body is not JSON'); }
34
+ for (const [key, value] of Object.entries(expected)) expect(isDeepStrictEqual(body?.[key], value), `body.${key} is not the declared value`);
35
+ }
36
+
37
+ /** One declared HTTP journey; throws a message when the answer is not the declared one. */
38
+ async function probeHttp(ctx, check) {
39
+ const init = { method: check.method, signal: AbortSignal.timeout(HTTP_TIMEOUT_MS) };
40
+ if (check.body !== null) Object.assign(init, { body: check.body, headers: { 'content-type': 'application/json' } });
41
+ const response = await ctx.fetch(check.url, init);
42
+ expect(response.status === check.status, `HTTP ${response.status}`);
43
+ const text = check.json === null && check.contains === null ? '' : await response.text();
44
+ if (check.json !== null) checkJson(text, check.json);
45
+ if (check.contains !== null) expect(text.includes(check.contains), `no ${check.contains} in the answer`);
46
+ return String(response.status);
47
+ }
48
+
49
+ /** `docker exec` of a declared command in the running container; exit 0 and, when declared, stdout matching the regex. */
50
+ function probeExec(ctx, container, check) {
51
+ const run = tool(ctx, 'docker', ['exec', container, ...check.command]);
52
+ expect(run.status === 0, `exit ${run.status}`);
53
+ if (check.stdout === null) return 'ok';
54
+ expect(new RegExp(check.stdout, 'u').test(run.stdout), `stdout does not match ${check.stdout}`);
55
+ return run.stdout.trim();
56
+ }
57
+
58
+ /** The running-task check of one service, then its declared commands (skipped when no task runs). */
59
+ async function verifyService(ctx, config, results, service) {
60
+ const found = tool(ctx, 'docker', ['ps', '-q', '--filter', `label=com.docker.swarm.service.name=${config.stack}_${service}`, '--filter', 'status=running']);
61
+ const container = splitLines(found.stdout)[0];
62
+ await runCheck(ctx, results, `a ${service} task is running on this manager`, () => {
63
+ expect(container, `no running container of ${config.stack}_${service} on the daemon`);
64
+ return container.slice(0, 12);
65
+ });
66
+ if (!container) return;
67
+ await eachInOrder(config.verify.exec.filter((entry) => entry.service === service), (check) => runCheck(ctx, results, check.name, () => probeExec(ctx, container, check)));
68
+ }
69
+
70
+ /** Every edge-attached service carries exactly its declared aliases (never the implicit short name) on the shared external network. */
71
+ async function verifyEdge(ctx, config, results, file) {
72
+ let attachments;
73
+ try {
74
+ attachments = edgeAttachmentsOf(file);
75
+ } catch (error) {
76
+ results.push({ name: 'edge aliases: the stack file is readable', ok: false, detail: String(error.message) });
77
+ return;
78
+ }
79
+ await eachInOrder(attachments, (item) => runCheck(ctx, results, `edge aliases of ${item.service} on ${item.network} are exactly the declared ones`, () => verifyEdgeAttachment(ctx, config, item)));
80
+ }
81
+
82
+ const verifyExec = (ctx, config, results) => eachInOrder([...new Set(config.verify.exec.map((check) => check.service))], (service) => verifyService(ctx, config, results, service));
83
+
84
+ /**
85
+ * Runs the declared verification. Returns { ok, lines, data: { checks: [{ name, ok }] } }; the lines read `PASS|FAIL name detail`.
86
+ * `skipPublic` skips the HTTP journeys, `skipInternal` the checks inside a task. `stackFile` (default: the stack source) is read for the services
87
+ * attached to an external network: each must carry exactly its declared aliases, never the implicit short name.
88
+ */
89
+ export async function verifyStack(ctx, config, { retrySeconds = 180, skipPublic = false, skipInternal = false, stackFile = config.stackFile } = {}) {
90
+ const results = [];
91
+ if (!skipPublic) await eachInOrder(config.verify.http, (check) => runCheck(ctx, results, check.name, () => probeHttp(ctx, check), { retry: check.retry, retrySeconds }));
92
+ if (!skipInternal && stackFile) await verifyEdge(ctx, config, results, stackFile);
93
+ if (!skipInternal) await verifyExec(ctx, config, results);
94
+ return {
95
+ ok: results.every((entry) => entry.ok),
96
+ lines: results.map((entry) => `${entry.ok ? 'PASS' : 'FAIL'} ${entry.name} ${entry.detail}`),
97
+ data: { checks: results.map(({ name, ok }) => ({ name, ok })) },
98
+ };
99
+ }
@@ -0,0 +1,13 @@
1
+ // One command vocabulary for managed CI rendering and the runtime coverage gate.
2
+ const exampleCommand = (source, command) => source?.runtimeExample
3
+ ? 'node node_modules/@starci/hfs/runtime/scripts/hfs/example-secrets.mjs --run . -- ' + command
4
+ : command;
5
+
6
+ export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info') {
7
+ return {
8
+ ciLint: exampleCommand(source, 'npm run lint'),
9
+ ciLintSonar: exampleCommand(source, 'npm run lint -- --sonar reports/lint.sonar.json'),
10
+ ciCoverageProof: exampleCommand(source, 'npx starci app check --coverage-from ' + lcovReport),
11
+ ciDatabaseLint: exampleCommand(source, 'npm run db:lint'),
12
+ };
13
+ }
package/sync/format.mjs CHANGED
@@ -13,7 +13,7 @@ const IGNORE_FILE = '.prettierignore';
13
13
  const LOCKFILES = new Set(['package-lock.json']);
14
14
 
15
15
  /** The repository's own prettier, or a SyncError when it is not installed. */
16
- export function loadPrettier(repoRoot) {
16
+ function loadPrettier(repoRoot) {
17
17
  const require = createRequire(path.join(repoRoot, 'package.json'));
18
18
  try {
19
19
  return require('prettier');
package/sync/hygiene.mjs CHANGED
@@ -43,7 +43,7 @@ function ignoredAmong(cwd, files) {
43
43
  }
44
44
 
45
45
  /** Violations among `files` (repository-relative, '/'-separated): [{ file, code, message }]. `ignored` is a Set of ignored paths. */
46
- export function hygieneFindings(files, ignored) {
46
+ function hygieneFindings(files, ignored) {
47
47
  const findings = [];
48
48
  for (const file of files) {
49
49
  if (file.startsWith(WORK) && ignored.has(file)) {
package/sync/index.mjs CHANGED
@@ -4,7 +4,8 @@
4
4
  // kind app, CI, the hooks, the formatter and Sonar configuration and .starciwork; its sides be/ and fe/ hold each side's tool
5
5
  // configuration. The managed set is read from the slot manifest (knowledge/hfs/slots.yaml): every slot that names `managedBy`
6
6
  // lists, in its `path`, the whole files sync renders, and `managedBy` names the template directory they are rendered from:
7
- // templates/<scope or common>/<managedBy>/<file path with each leading dot dropped>, the scope being app for a root slot and the
7
+ // templates/<scope or common>/<managedBy>/<file path with each leading dot dropped>, with dotted skeleton paths tried when no
8
+ // undotted template exists. The scope is app for a root slot and the
8
9
  // side (be, fe) for a side slot, whose files land under that side's folder. Two targets are not whole files of a managedBy slot
9
10
  // and are listed in this module: the marked block of the root .gitignore, and .starciwork/.gitignore, which lives inside the
10
11
  // .starciwork directory slot. Every file is rendered with the app's hfs.json (both sides and their apps) and, for the Sonar
@@ -26,16 +27,19 @@ import { feAppPackageName } from '../runtime/scripts/hfs/rules/monorepo.mjs';
26
27
  import { DEFAULT_PORT, NODE_IMAGE, dockerfilePath } from '../runtime/scripts/hfs/rules/docker.mjs';
27
28
  import { isFile } from '../runtime/scripts/lib/fs-kind.mjs';
28
29
  import { readDeclaredSonarKey } from './sonar-key.mjs';
30
+ import { readDeclaredStack, stackWorkflowVariables } from './stack-images.mjs';
29
31
  import { byCodeUnit } from '../report/order.mjs';
32
+ import { isRuntimeExampleApp } from '../runtime/scripts/lib/runtime-example.mjs';
33
+ import { ciWorkflowCommands } from './ci-commands.mjs';
30
34
 
31
35
  export const TEMPLATES_DIR = path.join(import.meta.dirname, '..', 'templates');
32
36
  const NODE_MAJOR = 22;
33
37
  const APP_BINARY = 'starci app';
34
38
  /** The scopes a target is rendered for: the app root, then each side. */
35
39
  export const SCOPES = Object.freeze([APP_SCOPE, ...SIDES]);
36
- export const BLOCK_BEGIN = '# >>> starci app sync (managed block; do not edit) >>>';
37
- export const BLOCK_END = '# <<< starci app sync <<<';
38
- const HEADER = scope => `# Generated by ${APP_BINARY} sync (${scope === APP_SCOPE ? 'app root' : `${scope} side`}). Do not edit: run "npx ${APP_BINARY} sync --write".`;
40
+ const BLOCK_BEGIN = '# >>> starci app sync (managed block; do not edit) >>>';
41
+ const BLOCK_END = '# <<< starci app sync <<<';
42
+ const HEADER = scope => `# Generated by ${APP_BINARY} sync (${scope === APP_SCOPE ? 'app root' : scope + ' side'}). Do not edit: run "npx ${APP_BINARY} sync --write".`;
39
43
 
40
44
  export class SyncError extends Error {
41
45
  constructor(code, message) {
@@ -48,7 +52,8 @@ export class SyncError extends Error {
48
52
  // package.json. A template that can hold a comment opens with {{header}}; a file whose exact content is specified (JSON, the
49
53
  // eslint one-liner) has no header.
50
54
  const MODE_OF_GROUP = Object.freeze({ 'package-scripts': 'scripts', 'package-scripts-lite': 'scripts' });
51
- const LITERAL_FILE = /^[^*?<>{}[\]]+[^/*?<>{}[\]]$/;
55
+ const GLOB_CHARACTERS = new Set('*?<>{}[]');
56
+ const literalFile = file => file.length >= 2 && !file.endsWith('/') && ![...file].some(character => GLOB_CHARACTERS.has(character));
52
57
 
53
58
  /** The targets no managedBy slot lists as a whole file (see the header of this module); both belong to the app root. */
54
59
  const UNLISTED_TARGETS = Object.freeze([
@@ -59,32 +64,44 @@ const UNLISTED_TARGETS = Object.freeze([
59
64
  const templateName = (scope, group, file) => `${scope}/${group}/${file.split('/').map(segment => segment.replace(/^\./, '')).join('/')}`;
60
65
  const onSide = (scope, file) => (scope === APP_SCOPE ? file : `${scope}/${file}`);
61
66
 
67
+ function managedTemplate(scope, group, file, hasTemplate) {
68
+ const directories = [scope, 'common'];
69
+ const undotted = directories.map(dir => templateName(dir, group, file)).find(name => hasTemplate(name));
70
+ if (undotted || !['skeleton', 'skeleton-lite'].includes(group)) return undotted;
71
+ return directories.map(dir => `${dir}/${group}/${file}`).find(name => hasTemplate(name));
72
+ }
73
+
74
+ function slotTargets(slot, scope, edition, manifest, hasTemplate) {
75
+ if (!slot.profiles.includes(scope) || !slotInEdition(manifest, slot, edition)) return [];
76
+ if (edition === 'lite' && litePresenceOf(slot, scope) === 'forbidden') return [];
77
+ const group = managedGroupOf(slot, edition);
78
+ if (group === undefined) return [];
79
+ const effectivePath = edition === 'lite' ? (slot.lite?.path ?? slot.path) : slot.path;
80
+ return braceVariants(effectivePath).map(file => {
81
+ const managedKey = edition === 'lite' && slot.liteManagedBy !== undefined ? 'liteManagedBy' : 'managedBy';
82
+ if (!literalFile(file)) throw new SyncError('HFS_SYNC_MANIFEST_MANAGED', `slot ${slot.id} names ${managedKey} but its path ${effectivePath} is not a list of literal files (${file})`);
83
+ const template = managedTemplate(scope, group, file, hasTemplate);
84
+ if (!template) throw new SyncError('HFS_SYNC_TEMPLATE_MISSING', `slot ${slot.id} manages ${file} for ${scope}, but templates/{${scope},common}/${group} has no template for it`);
85
+ return { path: file, template, mode: MODE_OF_GROUP[group] ?? 'file', slot: slot.id };
86
+ });
87
+ }
88
+
89
+ function unlistedTargets(scope, edition) {
90
+ if (scope !== APP_SCOPE) return [];
91
+ return UNLISTED_TARGETS.map(target => {
92
+ if (edition !== 'lite') return { ...target };
93
+ return { ...target, template: target.path === '.gitignore' ? 'app/gitignore-lite' : 'app/starciwork.gitignore-lite' };
94
+ });
95
+ }
96
+
62
97
  /**
63
98
  * Every target of `scope` (app, be or fe), its path relative to that scope's folder: the files the manifest's managedBy slots
64
99
  * list, then (app root) the unlisted ones. A slot that names managedBy lists literal files only, and each has a template under
65
100
  * its scope or under common.
66
101
  */
67
102
  export function targetsOf(scope, { edition = 'full', manifest = loadSlotManifest(), hasTemplate = name => fs.existsSync(path.join(TEMPLATES_DIR, name)) } = {}) {
68
- const listed = [];
69
- for (const slot of manifest.slots) {
70
- if (!slot.profiles.includes(scope) || !slotInEdition(manifest, slot, edition)) continue;
71
- if (edition === 'lite' && litePresenceOf(slot, scope) === 'forbidden') continue;
72
- const group = managedGroupOf(slot, edition);
73
- if (group === undefined) continue;
74
- const effectivePath = edition === 'lite' ? (slot.lite?.path ?? slot.path) : slot.path;
75
- for (const file of braceVariants(effectivePath)) {
76
- if (!LITERAL_FILE.test(file)) throw new SyncError('HFS_SYNC_MANIFEST_MANAGED', `slot ${slot.id} names ${edition === 'lite' && slot.liteManagedBy !== undefined ? 'liteManagedBy' : 'managedBy'} but its path ${effectivePath} is not a list of literal files (${file})`);
77
- const template = [scope, 'common'].map(dir => templateName(dir, group, file)).find(hasTemplate);
78
- if (!template) throw new SyncError('HFS_SYNC_TEMPLATE_MISSING', `slot ${slot.id} manages ${file} for ${scope}, but templates/{${scope},common}/${group} has no template for it`);
79
- listed.push({ path: file, template, mode: MODE_OF_GROUP[group] ?? 'file', slot: slot.id });
80
- }
81
- }
82
- const unlisted = scope === APP_SCOPE
83
- ? UNLISTED_TARGETS.map(target => edition === 'lite'
84
- ? { ...target, template: target.path === '.gitignore' ? 'app/gitignore-lite' : 'app/starciwork.gitignore-lite' }
85
- : { ...target })
86
- : [];
87
- return [...listed, ...unlisted];
103
+ const listed = manifest.slots.flatMap(slot => slotTargets(slot, scope, edition, manifest, hasTemplate));
104
+ return [...listed, ...unlistedTargets(scope, edition)];
88
105
  }
89
106
 
90
107
  const lf = text => text.replace(/\r\n/g, '\n');
@@ -112,14 +129,20 @@ function readBundled(name) {
112
129
  * so a JavaScript template stays valid source before rendering. An unknown name is an error, never an empty string.
113
130
  */
114
131
  export function render(text, vars, readTemplate = readBundled) {
115
- const withPartials = text.replace(/^(?:\/\/ )?\{\{> ([\w./-]+)\}\}\n/gm, (_, name) => render(readTemplate(name), vars, readTemplate).replace(/\n*$/, '\n'));
132
+ const withPartials = text.replace(/^(?:\/\/ )?\{\{> ([\w./-]+)\}\}\n/gm, (_, name) => trailingNewline(render(readTemplate(name), vars, readTemplate)));
116
133
  return withPartials.replace(/\{\{([A-Za-z]\w*)\}\}/g, (_, key) => {
117
134
  if (!Object.hasOwn(vars, key)) throw new SyncError('HFS_SYNC_TEMPLATE_VARIABLE', `template names {{${key}}}, which sync does not provide`);
118
135
  return vars[key];
119
136
  });
120
137
  }
121
138
 
122
- /** What the app's sync reads from the jest preset it installs for its be side: the Sonar exclusions ({ sonarExclusions }); both editions install it (a lite back end keeps full's unit run, owner ruling 2026-10-12). */
139
+ function trailingNewline(text) {
140
+ let end = text.length;
141
+ while (end > 0 && text[end - 1] === '\n') end -= 1;
142
+ return text.slice(0, end) + '\n';
143
+ }
144
+
145
+ /** What the app's sync reads from the jest preset it installs for its be side: the Sonar exclusions ({ sonarExclusions }); both editions install it (a lite back end keeps full's unit run, ruling:R212/case-1, amended by ruling:R212/case-4). */
123
146
  export async function loadPresets(root) {
124
147
  const name = '@starci/jest-preset';
125
148
  const require = createRequire(path.join(root, 'package.json'));
@@ -155,8 +178,9 @@ export function appSource(root) {
155
178
  }
156
179
  };
157
180
  for (const dir of ['apps', 'src']) walk(dir);
158
- return { files: files.sort(byCodeUnit), read: file => fs.readFileSync(path.join(root, 'be', file), 'utf8'),
159
- workPresent: fs.existsSync(path.join(root, '.starciwork')), hasImage: file => isFile(path.join(root, file), { followLinks: false }) };
181
+ return { runtimeExample: isRuntimeExampleApp(root), files: files.sort(byCodeUnit), read: file => fs.readFileSync(path.join(root, 'be', file), 'utf8'),
182
+ workPresent: fs.existsSync(path.join(root, '.starciwork')), hasImage: file => isFile(path.join(root, file), { followLinks: false }),
183
+ stack: readDeclaredStack(root) };
160
184
  }
161
185
 
162
186
  /**
@@ -239,6 +263,13 @@ export function imageMatrix(app, source) {
239
263
  return imageEntries(app, source).map(image => entry(image.side, image.entry)).join('\n');
240
264
  }
241
265
 
266
+ /** The Dockerfile of the app a deploy image names (be first, then fe); an app hfs.json does not declare cannot be built by the managed workflow. */
267
+ function stackDockerfile(app, name) {
268
+ const side = ['be', 'fe'].find(which => app.sides[which].apps.some(entry => entry.name === name));
269
+ if (!side) throw new SyncError('HFS_SYNC_STACK_IMAGE', `deploy.images names the app ${name}, which hfs.json does not declare under be or fe`);
270
+ return dockerfilePath(side, name);
271
+ }
272
+
242
273
  const IMAGE_TEMPLATE = Object.freeze({ api: 'be/image/api', worker: 'be/image/worker', cli: 'be/image/cli', next: 'fe/image/next' });
243
274
 
244
275
  /**
@@ -263,14 +294,18 @@ export function imageFiles(app, source) {
263
294
 
264
295
  /** Every value a template of `scope` can name, derived from hfs.json and the presets. */
265
296
  export function variables(app, scope, presets, sonarKey, manifest = loadSlotManifest(), source = { files: [], read: () => '' }) {
297
+ const stack = source.stack ?? { images: [], passEnv: [] };
266
298
  const fe = app.sides.fe;
267
299
  const packages = opensPackages(fe);
268
300
  const lite = app.edition === 'lite';
269
301
  const providers = (app.sides.be.connections ?? []).map(connection => connection.provider);
270
302
  return {
271
303
  header: HEADER(scope),
304
+ project: app.project,
272
305
  appBinary: APP_BINARY,
273
306
  appScripts: appScripts(app, source),
307
+ // A full upgrade keeps the app's declared Supabase connection and its local database commands.
308
+ supabaseScripts: providers.includes('supabase') ? render(readBundled('app/package-scripts/supabase.json'), { appBinary: APP_BINARY }) : '',
274
309
  // turbo builds every fe app workspace; ^build (turbo.json) builds the packages each app imports first, and caches both.
275
310
  buildFe: 'npm run codegen --silent && turbo run build --filter=./fe/apps/*',
276
311
  // The be side with its tsc; every fe workspace with its own typecheck script through turbo (the packages are built first: ^build).
@@ -281,7 +316,8 @@ export function variables(app, scope, presets, sonarKey, manifest = loadSlotMani
281
316
  sonarExclusions: [presets?.sonarExclusions, '**/.next/**', '**/node_modules/**', '**/src/messages/**', ...(lite ? ['supabase/types/**'] : [])].filter(Boolean).join(','),
282
317
  sonarSources: ['be/apps', 'be/src', 'fe/apps', ...(packages ? ['fe/packages'] : [])].join(','),
283
318
  lcovReport: LCOV_REPORT,
284
- // The coverage scope is the same in both editions: lite measures exactly what full measures (owner ruling 2026-10-12).
319
+ ...ciWorkflowCommands(source, LCOV_REPORT),
320
+ // The coverage scope is the same in both editions: lite measures exactly what full measures (ruling:R212/case-1, amended by ruling:R212/case-4).
285
321
  jestCoverage: scope === 'be' ? jestCoverageSource(jestCoverage(manifest, providers)) : '',
286
322
  coverageExclusions: scope === APP_SCOPE ? sonarCoverageExclusions(manifest, providers).join(',') : '',
287
323
  codecovPaths: scope === APP_SCOPE ? codecovPaths(manifest, providers).map(glob => ` - ${JSON.stringify(glob)}`).join('\n') : '',
@@ -290,11 +326,13 @@ export function variables(app, scope, presets, sonarKey, manifest = loadSlotMani
290
326
  styleGlob: STYLE_GLOB,
291
327
  imageMatrix: imageMatrix(app, source),
292
328
  imageProject: app.project,
329
+ ...stackWorkflowVariables(stack, app.project, name => stackDockerfile(app, name)),
293
330
  };
294
331
  }
295
332
 
296
333
  // The scripts of a package.json as one comparable text: a line per script, sorted by name (order is not drift).
297
- const scriptsText = scripts => `${Object.keys(scripts).sort(byCodeUnit).map(name => `${name}: ${typeof scripts[name] === 'string' ? scripts[name] : JSON.stringify(scripts[name])}`).join('\n')}\n`;
334
+ const scriptsText = scripts => Object.keys(scripts).sort(byCodeUnit)
335
+ .map(name => `${name}: ${typeof scripts[name] === 'string' ? scripts[name] : JSON.stringify(scripts[name])}`).join('\n') + '\n';
298
336
 
299
337
  function parseScripts(text) {
300
338
  try {
@@ -326,7 +364,7 @@ export function renderTargets(hfs, presets, { sonarKey, readTemplate = readBundl
326
364
  const content = scriptsText(scripts);
327
365
  return { path: where, scope, mode: 'scripts', content, hash: hashOf(content), scripts };
328
366
  }
329
- const managed = target.mode === 'block' ? `${BLOCK_BEGIN}\n${body.replace(/\n*$/, '\n')}${BLOCK_END}\n` : body;
367
+ const managed = target.mode === 'block' ? `${BLOCK_BEGIN}\n${trailingNewline(body)}${BLOCK_END}\n` : body;
330
368
  return { path: where, scope, mode: target.mode, content: managed, hash: hashOf(managed) };
331
369
  });
332
370
  });
@@ -368,6 +406,21 @@ export function checkTargets(root, targets) {
368
406
  }
369
407
 
370
408
  /** Rewrite the missing or drifted targets; returns the paths written. */
409
+ function writtenContent(target, existing) {
410
+ if (target.mode === 'scripts') {
411
+ let pkg = {};
412
+ if (existing) {
413
+ try { pkg = JSON.parse(existing); } catch { throw new SyncError('HFS_SYNC_HFS_INVALID', `${target.path} is not valid JSON; fix it by hand before sync can set its scripts`); }
414
+ }
415
+ return `${JSON.stringify({ ...pkg, scripts: target.scripts }, null, 2)}\n`;
416
+ }
417
+ if (target.mode === 'block' && existing) {
418
+ const current = managedPart('block', existing);
419
+ return current === null ? `${target.content}\n${existing}` : existing.replace(current, () => target.content);
420
+ }
421
+ return target.content;
422
+ }
423
+
371
424
  export function writeTargets(root, targets) {
372
425
  const written = [];
373
426
  for (const result of checkTargets(root, targets)) {
@@ -375,17 +428,7 @@ export function writeTargets(root, targets) {
375
428
  const target = targets.find(candidate => candidate.path === result.path);
376
429
  const file = path.join(root, target.path);
377
430
  const existing = fs.existsSync(file) ? lf(fs.readFileSync(file, 'utf8')) : '';
378
- let next = target.content;
379
- if (target.mode === 'scripts') {
380
- let pkg = {};
381
- if (existing) {
382
- try { pkg = JSON.parse(existing); } catch { throw new SyncError('HFS_SYNC_HFS_INVALID', `${target.path} is not valid JSON; fix it by hand before sync can set its scripts`); }
383
- }
384
- next = `${JSON.stringify({ ...pkg, scripts: target.scripts }, null, 2)}\n`;
385
- } else if (target.mode === 'block' && existing) {
386
- const current = managedPart('block', existing);
387
- next = current === null ? `${target.content}\n${existing}` : existing.replace(current, () => target.content);
388
- }
431
+ const next = writtenContent(target, existing);
389
432
  fs.mkdirSync(path.dirname(file), { recursive: true });
390
433
  fs.writeFileSync(file, next);
391
434
  written.push(target.path);
@@ -411,19 +454,40 @@ export function loadHfs(root, manifest = loadSlotManifest()) {
411
454
  export async function renderRepo(root, { presets, parseYaml, manifest = loadSlotManifest() } = {}) {
412
455
  const hfs = loadHfs(root, manifest);
413
456
  const sonarKey = await readDeclaredSonarKey(root, { parseYaml, fail: message => { throw new SyncError('HFS_SYNC_SONAR_KEY', message); } });
414
- return { hfs, targets: renderTargets(hfs, presets ?? await loadPresets(root), { sonarKey, manifest, source: appSource(root) }) };
457
+ return { hfs, targets: renderTargets(hfs, presets ?? await loadPresets(root), { sonarKey, manifest, source: { ...appSource(root), stack: readDeclaredStack(root, { parseYaml }) } }) };
415
458
  }
416
459
 
417
460
  /** `starci app sync --check | --write [--cwd <dir>]` at the app root; returns 0 clean, 1 drift, 2 usage. */
418
- export async function runSync(argv, { cwd = process.cwd(), out = line => process.stdout.write(`${line}\n`), presets, parseYaml } = {}) {
461
+ function syncOptions(argv) {
419
462
  const check = argv.includes('--check'), write = argv.includes('--write');
420
463
  const cwdAt = argv.indexOf('--cwd');
421
464
  const known = new Set(['--check', '--write', '--cwd']);
422
465
  const unknown = argv.find((arg, index) => !known.has(arg) && index !== cwdAt + 1);
423
466
  if ([check, write].filter(Boolean).length !== 1 || unknown !== undefined || (cwdAt >= 0 && argv[cwdAt + 1] === undefined)) {
467
+ return null;
468
+ }
469
+ return { write, cwdAt };
470
+ }
471
+
472
+ function reportSyncCheck(root, targets, out) {
473
+ const results = checkTargets(root, targets);
474
+ const bad = results.filter(item => item.status !== 'ok');
475
+ for (const result of bad) {
476
+ const found = result.actualHash ? `, found ${result.actualHash.slice(0, 12)}` : '';
477
+ const where = result.difference ? ` (line ${result.difference.line}: expected ${JSON.stringify(result.difference.expected)}, found ${JSON.stringify(result.difference.actual)})` : '';
478
+ out(`HFS_SYNC_DRIFT ${result.path}: ${result.status}, expected sha256 ${result.expectedHash.slice(0, 12)}${found}${where}`);
479
+ }
480
+ out(`starci app sync --check: ${results.length - bad.length} of ${results.length} in sync${bad.length ? '; run "npx starci app sync --write"' : ''}`);
481
+ return bad.length ? 1 : 0;
482
+ }
483
+
484
+ export async function runSync(argv, { cwd = process.cwd(), out = line => process.stdout.write(`${line}\n`), presets, parseYaml } = {}) {
485
+ const options = syncOptions(argv);
486
+ if (!options) {
424
487
  out('usage: starci app sync (--check | --write) [--cwd <dir>]');
425
488
  return 2;
426
489
  }
490
+ const { write, cwdAt } = options;
427
491
  const root = cwdAt >= 0 ? path.resolve(cwd, argv[cwdAt + 1]) : cwd;
428
492
  try {
429
493
  const { targets } = await renderRepo(root, { presets, parseYaml });
@@ -433,15 +497,7 @@ export async function runSync(argv, { cwd = process.cwd(), out = line => process
433
497
  out(`starci app sync: ${written.length} written, ${targets.length - written.length} already in sync`);
434
498
  return 0;
435
499
  }
436
- const results = checkTargets(root, targets);
437
- const bad = results.filter(item => item.status !== 'ok');
438
- for (const result of bad) {
439
- const found = result.actualHash ? `, found ${result.actualHash.slice(0, 12)}` : '';
440
- const where = result.difference ? ` (line ${result.difference.line}: expected ${JSON.stringify(result.difference.expected)}, found ${JSON.stringify(result.difference.actual)})` : '';
441
- out(`HFS_SYNC_DRIFT ${result.path}: ${result.status}, expected sha256 ${result.expectedHash.slice(0, 12)}${found}${where}`);
442
- }
443
- out(`starci app sync --check: ${results.length - bad.length} of ${results.length} in sync${bad.length ? '; run "npx starci app sync --write"' : ''}`);
444
- return bad.length ? 1 : 0;
500
+ return reportSyncCheck(root, targets, out);
445
501
  } catch (error) {
446
502
  if (!(error instanceof SyncError)) throw error;
447
503
  out(error.message);
@@ -0,0 +1,127 @@
1
+ // The Swarm deploy images an app declares, as the managed stack-deploy.yml workflow needs them.
2
+ //
3
+ // The workflow builds one image per `deploy.images` entry of .starcistacks/application-stacks.yaml and hands the pinned references to
4
+ // `starci app stack deploy`. Sync renders that list into the workflow, so it reads the declaration here: the union over the declared
5
+ // environments of `environments.<env>.deploy.images` (service -> { app, buildArgs }). Only names and the presence of build arguments are
6
+ // read; the real validation of the block is stack-deploy-config.mjs (`starci app check`, rule R294). Everything this module returns is
7
+ // pure text assembled from names that match the strict patterns below, so no free text reaches the rendered workflow.
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { parseYaml as bundledParseYaml } from '../runtime/engine/yaml.mjs';
11
+ import { DECLARATION } from './sonar-key.mjs';
12
+ import { byCodeUnit } from '../report/order.mjs';
13
+
14
+ const NAME = /^[a-z][a-z0-9-]*$/u;
15
+ const KEY = /^[A-Z][A-Z0-9_]*$/u;
16
+ const INDENT = ' '.repeat(6);
17
+ const CONTINUE = '\\';
18
+ const NO_IMAGES_MESSAGE = 'no image is declared: declare environments.<env>.deploy (with images) in .starcistacks/application-stacks.yaml before running this workflow';
19
+
20
+ const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
21
+ const hasBuildArgs = args => record(args) && (Object.keys(record(args.values) ? args.values : {}).length > 0 || record(args.sealed));
22
+
23
+ function environmentImages(environment) {
24
+ const images = environment?.deploy?.images;
25
+ if (!record(images)) return [];
26
+ return Object.entries(images)
27
+ .filter(([service, entry]) => NAME.test(service) && record(entry) && NAME.test(String(entry.app)))
28
+ .map(([service, entry]) => ({ service, app: entry.app, buildArgs: hasBuildArgs(entry.buildArgs) }));
29
+ }
30
+
31
+ /**
32
+ * The images of a parsed declaration, [{ service, app, buildArgs }] sorted by service: the union over its environments (the first
33
+ * environment that names a service decides its app; a later one only adds build arguments). A declaration with no deploy block has none.
34
+ */
35
+ function declaredStackImages(declaration) {
36
+ const byService = new Map();
37
+ for (const environment of Object.values(record(declaration?.environments) ? declaration.environments : {})) {
38
+ for (const image of environmentImages(environment)) {
39
+ const known = byService.get(image.service);
40
+ byService.set(image.service, known ? { ...known, buildArgs: known.buildArgs || image.buildArgs } : image);
41
+ }
42
+ }
43
+ return [...byService.values()].sort((a, b) => byCodeUnit(a.service, b.service));
44
+ }
45
+
46
+ /** The host variables (names only) the declared environments interpolate into `docker stack config`: the union of `deploy.passEnv`. */
47
+ function declaredPassEnv(declaration) {
48
+ const names = Object.values(record(declaration?.environments) ? declaration.environments : {})
49
+ .flatMap(environment => (Array.isArray(environment?.deploy?.passEnv) ? environment.deploy.passEnv : []))
50
+ .filter(name => typeof name === 'string' && KEY.test(name));
51
+ return [...new Set(names)].sort(byCodeUnit);
52
+ }
53
+
54
+ /**
55
+ * What the managed workflow renders from the declaration of the app at `root`: { images, passEnv } (both empty when the app has no
56
+ * declaration, no deploy block or a declaration that does not parse; `starci app check` judges those). `parseYaml(text)` is injected.
57
+ */
58
+ export function readDeclaredStack(root, { parseYaml = bundledParseYaml } = {}) {
59
+ const file = path.join(root, DECLARATION);
60
+ if (!fs.existsSync(file)) return { images: [], passEnv: [] };
61
+ let declaration;
62
+ try {
63
+ declaration = parseYaml(fs.readFileSync(file, 'utf8'));
64
+ } catch {
65
+ return { images: [], passEnv: [] };
66
+ }
67
+ return { images: declaredStackImages(declaration), passEnv: declaredPassEnv(declaration) };
68
+ }
69
+
70
+ const envName = service => `IMAGE_${service.toUpperCase().replaceAll('-', '_')}`;
71
+ /** Lines placed at the end of a template line: each one starts with a newline, so nothing declared leaves nothing, not even a blank line. */
72
+ const block = (items, indent = INDENT) => items.map(item => `\n${indent}${item}`).join('');
73
+
74
+ function buildArgsStep(image) {
75
+ return [
76
+ `- name: public build values of ${image.service}`,
77
+ ` id: args-${image.service}`,
78
+ ' env:',
79
+ ' SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}',
80
+ ` run: npx --no-install starci app stack build-args --env "$STACK_ENVIRONMENT" --app ${image.app}`,
81
+ ];
82
+ }
83
+
84
+ function buildPushStep(image, dockerfile) {
85
+ return [
86
+ `- name: build and push ${image.service}`,
87
+ ` id: build-${image.service}`,
88
+ ' uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2',
89
+ ' with:',
90
+ ' context: .',
91
+ ` file: ${dockerfile}`,
92
+ ' push: true',
93
+ ' provenance: false',
94
+ ` tags: \${{ steps.names.outputs.${image.service} }}:\${{ github.sha }}`,
95
+ ...(image.buildArgs ? [` build-args: \${{ steps.args-${image.service}.outputs.args }}`] : []),
96
+ ` cache-from: type=gha,scope=${image.service}`,
97
+ ` cache-to: type=gha,scope=${image.service},mode=max`,
98
+ ];
99
+ }
100
+
101
+ /**
102
+ * The text blocks the stack-deploy.yml template places at the end of a line (each already indented for its position, every line led by
103
+ * a newline; empty when there is nothing to place):
104
+ * stackGuard the first step of the release job when the app declares no image (it fails with the reason)
105
+ * stackNames the `names` step lines assigning each image repository (ghcr.io/<lowercase owner>/<project>-<app>)
106
+ * stackImageSteps per image: the optional build-args step and the build-and-push step
107
+ * stackImageEnv the env entries of the deploy step: IMAGE_<SERVICE> = repository@digest
108
+ * stackImageArgs the `--image "<service>=$IMAGE_<SERVICE>" \` continuation lines of the deploy command
109
+ * stackPassEnv the env entries of the deploy step for the declared host variables (vars.<NAME>)
110
+ * `dockerfileOf(app)` names the Dockerfile of an app (the sync module knows the layout).
111
+ */
112
+ export function stackWorkflowVariables({ images = [], passEnv = [] }, project, dockerfileOf) {
113
+ const guard = images.length ? '' : block([
114
+ '- name: require a declared image',
115
+ ' shell: bash',
116
+ ` run: 'echo "::error::${NO_IMAGES_MESSAGE}"; exit 1'`,
117
+ ]);
118
+ const names = images.map(image => `echo "${image.service}=ghcr.io/\${owner}/${project}-${image.app}" >> "$GITHUB_OUTPUT"`);
119
+ return {
120
+ stackGuard: guard,
121
+ stackNames: block(names, ' '.repeat(10)),
122
+ stackImageSteps: images.map(image => block([...(image.buildArgs ? buildArgsStep(image) : []), ...buildPushStep(image, dockerfileOf(image.app))])).join(''),
123
+ stackImageEnv: block(images.map(image => `${envName(image.service)}: \${{ steps.names.outputs.${image.service} }}@\${{ steps.build-${image.service}.outputs.digest }}`), ' '.repeat(10)),
124
+ stackImageArgs: block(images.map(image => `--image "${image.service}=$${envName(image.service)}" ${CONTINUE}`), ' '.repeat(12)),
125
+ stackPassEnv: block(passEnv.map(name => `${name}: \${{ vars.${name} }}`), ' '.repeat(10)),
126
+ };
127
+ }
@@ -30,14 +30,18 @@ jobs:
30
30
  node-version: {{nodeMajor}}
31
31
  cache: npm
32
32
  - run: npm ci --ignore-scripts
33
+ - name: production dependency audit
34
+ run: npm audit --omit=dev --audit-level=high
33
35
  - name: lint
34
- run: npm run lint -- --sonar reports/lint.sonar.json
36
+ run: {{ciLintSonar}}
35
37
  - name: format
36
38
  run: npm run format:check
37
39
  - name: typecheck
38
40
  run: npm run typecheck
39
41
  - name: unit
40
42
  run: npm test -- --ci
43
+ - name: coverage proof
44
+ run: {{ciCoverageProof}}
41
45
  - name: coverage upload
42
46
  uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5.5.5
43
47
  if: ${{ !cancelled() && startsWith(github.ref, 'refs/tags/v') }}