@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
package/stack/jobs.mjs ADDED
@@ -0,0 +1,51 @@
1
+ // jobs.mjs - `starci app stack jobs <action>`: runs the app's declared job CLI (deploy.jobs.command + the action) inside the running task of
2
+ // deploy.jobs.service on the manager, through the ssh runner, and relays its stdout. Job rows hold names and error texts, never payloads: the app
3
+ // CLI decides what is printed, this module only relays it. stats, failed, open and watchdog are read-only; retry and retry-failed are effects
4
+ // that need --execute (without it a dry-run line says what would run).
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 READ_ONLY = new Set(['stats', 'failed', 'open', 'watchdog']);
10
+ const EFFECTS = new Set(['retry', 'retry-failed']);
11
+ const OUTBOX_ID = /^[A-Za-z0-9-]{1,64}$/u;
12
+ const WHOLE_NUMBER = /^[0-9]{1,4}$/u;
13
+
14
+ function validCount(value, label) {
15
+ if (!WHOLE_NUMBER.test(value) || Number(value) < 1 || Number(value) > 720) throw refusal('STACK_JOBS_ARGUMENT', `${label} must be a whole number from 1 to 720`);
16
+ }
17
+
18
+ /** Validates the action and its arguments; every argument reaches a remote command only after this. */
19
+ function validate(action, args) {
20
+ if (!READ_ONLY.has(action) && !EFFECTS.has(action)) throw refusal('STACK_JOBS_ARGUMENT', `jobs takes ${[...READ_ONLY, ...EFFECTS].join(', ')}`);
21
+ const maximum = action === 'stats' || action === 'watchdog' ? 0 : 1;
22
+ if (args.length > maximum) throw refusal('STACK_JOBS_ARGUMENT', `jobs ${action} takes at most ${maximum} argument(s)`);
23
+ if (action === 'retry') {
24
+ if (args.length !== 1 || !OUTBOX_ID.test(args[0])) throw refusal('STACK_JOBS_ARGUMENT', 'jobs retry takes one outbox id (letters, digits, dashes, up to 64)');
25
+ } else if (args.length === 1) validCount(args[0], action === 'retry-failed' ? 'hours' : 'the row count');
26
+ }
27
+
28
+ function runningTask(run, config) {
29
+ const service = `${config.stack}_${config.jobs.service}`;
30
+ const result = remoteDocker(run, ['ps', '-q', '--filter', `label=com.docker.swarm.service.name=${service}`, '--filter', 'status=running']);
31
+ const task = result.status === 0 ? splitLines(result.stdout)[0] : undefined;
32
+ if (task) return task;
33
+ const reason = result.status === 0 ? 'none is running' : firstLine(result.stderr);
34
+ throw failure('STACK_JOBS_NO_TASK', `no running task of service ${service} on the manager (${reason})`);
35
+ }
36
+
37
+ /** jobs: relays one action of the app's job CLI. */
38
+ export async function runJobs(ctx, config, { action = 'stats', args = [], execute = false, sshCmd = null } = {}, deps = {}) {
39
+ if (!config.jobs) throw refusal('STACK_JOBS_UNDECLARED', 'the environment declares no deploy.jobs block');
40
+ validate(action, args);
41
+ if (EFFECTS.has(action) && !execute) {
42
+ return { ok: true, lines: [`dry run: would run jobs ${[action, ...args].join(' ')} in the running ${config.jobs.service} task (add --execute)`], data: { action, status: null } };
43
+ }
44
+ return withRunner(ctx, config, { sshCmd }, deps, (run, notes) => {
45
+ const task = runningTask(run, config);
46
+ const result = remoteDocker(run, ['exec', task, ...config.jobs.command, action, ...args]);
47
+ const lines = [...notes, ...splitLines(result.stdout)];
48
+ if (result.status !== 0) lines.push(`FAIL jobs ${action} exited ${result.status}: ${firstLine(result.stderr)}`);
49
+ return { ok: result.status === 0, lines, data: { action, status: result.status } };
50
+ });
51
+ }
@@ -0,0 +1,177 @@
1
+ // live-restore.mjs - docker `live-restore` is incompatible with swarm mode. `prepare` detects it; with --fix-live-restore, --owner-approved and
2
+ // --execute it backs up /etc/docker/daemon.json, sets live-restore false and restarts dockerd through passwordless sudo.
3
+ // A dockerd restart stops every container (a restart policy does not reliably bring them back: on a real host 25 of 25 stayed down), so the fix
4
+ // 1. snapshots every running container first (name, compose project and service, depends_on, image) and the answer of every public host,
5
+ // 2. after the restart waits for docker, gives the restart policies a short grace, then `docker start`s every snapshotted container that is
6
+ // not running: databases and infrastructure first, applications next, edge proxies last, compose depends_on respected inside a group,
7
+ // 3. re-checks the hosts against the baseline and FAILS loudly, listing every container and host still down.
8
+ // Everything runs ON the manager through the ssh runner.
9
+ import { approvalMissing } from './errors.mjs';
10
+ import { remoteDocker, shellQuote } from './remote.mjs';
11
+
12
+ const DAEMON_JSON = '/etc/docker/daemon.json';
13
+ const WAIT_SECONDS = 180;
14
+ const GRACE_SECONDS = 45;
15
+ const HOST_WAIT_SECONDS = 120;
16
+ const POLL_MS = 3000;
17
+ const INFRA = /(?:postgres|postgis|pgbouncer|mysql|mariadb|mongo|redis|valkey|memcached|kafka|zookeeper|nats|rabbit|minio|elastic|opensearch|qdrant|clickhouse|cassandra|-db\b|_db\b|^db$|database)/iu;
18
+ const EDGE = /(?:traefik|nginx|caddy|haproxy|envoy|edge|proxy)/iu;
19
+ const FORMAT = '{{.Name}}|{{index .Config.Labels "com.docker.compose.project"}}|{{index .Config.Labels "com.docker.compose.service"}}|{{index .Config.Labels "com.docker.compose.depends_on"}}|{{.HostConfig.RestartPolicy.Name}}|{{.Config.Image}}';
20
+
21
+ /** True when dockerd runs with live-restore (docker info reports it). */
22
+ function liveRestoreEnabled(run) {
23
+ return remoteDocker(run, ['info', '--format', '{{.LiveRestoreEnabled}}']).stdout.trim() === 'true';
24
+ }
25
+
26
+ /** Read-only check used by prepare and doctor. */
27
+ export function checkLiveRestore(run, report) {
28
+ if (liveRestoreEnabled(run)) report.bad('docker live-restore is enabled and is incompatible with swarm: run "starci app stack prepare --fix-live-restore --owner-approved --execute"');
29
+ else report.ok('docker live-restore is off');
30
+ }
31
+
32
+ /** depends_on label: "db:service_started:false,cache:service_healthy:true" -> ['db', 'cache']. */
33
+ const dependenciesOf = (label) => label.split(',').map((item) => item.split(':')[0].trim()).filter(Boolean);
34
+
35
+ function parseSnapshot(line) {
36
+ const [name, project = '', service = '', depends = '', restart = '', image = ''] = line.split('|');
37
+ return { name: name.replace(/^\//u, ''), project, service, depends: dependenciesOf(depends), restart, image };
38
+ }
39
+
40
+ /** Every running container with the facts needed to start it again in order. */
41
+ export function snapshotContainers(run) {
42
+ const names = remoteDocker(run, ['ps', '--format', '{{.Names}}']).stdout.split(/\r?\n/u).filter(Boolean);
43
+ return names.map((name) => parseSnapshot(remoteDocker(run, ['inspect', '--format', FORMAT, name]).stdout.trim()));
44
+ }
45
+
46
+ const rankOf = (entry) => {
47
+ if (INFRA.test(`${entry.service} ${entry.name} ${entry.image}`)) return 0;
48
+ return EDGE.test(`${entry.service} ${entry.name} ${entry.image}`) ? 2 : 1;
49
+ };
50
+
51
+ /** Start order: infrastructure, applications, edge; inside a rank, compose dependencies first (stable, cycles keep their input order). */
52
+ export function startOrder(snapshot) {
53
+ const ordered = [];
54
+ const placed = new Set();
55
+ const place = (entry, trail) => {
56
+ if (placed.has(entry.name) || trail.has(entry.name)) return;
57
+ const next = new Set(trail).add(entry.name);
58
+ const same = (other) => other.project === entry.project && entry.depends.includes(other.service) && rankOf(other) === rankOf(entry);
59
+ for (const dependency of snapshot.filter(same)) place(dependency, next);
60
+ placed.add(entry.name);
61
+ ordered.push(entry);
62
+ };
63
+ for (const rank of [0, 1, 2]) for (const entry of snapshot.filter((item) => rankOf(item) === rank)) place(entry, new Set());
64
+ return ordered;
65
+ }
66
+
67
+ function rewrittenDaemonJson(run) {
68
+ const read = run(`sudo -n cat ${shellQuote(DAEMON_JSON)}`);
69
+ if (read.status !== 0) return { error: `cannot read ${DAEMON_JSON}: ${read.stderr.trim().split(/\r?\n/u)[0]}` };
70
+ let parsed;
71
+ try { parsed = JSON.parse(read.stdout.trim() || '{}'); } catch { return { error: `${DAEMON_JSON} is not valid JSON; fix it by hand` }; }
72
+ return { text: `${JSON.stringify({ ...parsed, 'live-restore': false }, null, 2)}\n` };
73
+ }
74
+
75
+ const running = (run, name) => remoteDocker(run, ['inspect', '--format', '{{.State.Running}}', name]).stdout.trim() === 'true';
76
+ const dockerUp = (run) => remoteDocker(run, ['info', '--format', '{{.ServerVersion}}']).status === 0;
77
+ const notRunning = (run, names) => names.filter((name) => !running(run, name));
78
+
79
+ async function waitUntil(ctx, seconds, done) {
80
+ const deadline = ctx.now() + seconds * 1000;
81
+ while (!done() && ctx.now() < deadline) await ctx.sleep(POLL_MS);
82
+ return done();
83
+ }
84
+
85
+ /** The hosts to prove, from the declared ingress: the verify list, else every upstream host (path /). */
86
+ function hostsToProve(config) {
87
+ const ingress = config.ingress;
88
+ if (!ingress) return [];
89
+ const verify = ingress.verify.map((item) => ({ host: item.host, path: item.path }));
90
+ const seen = new Set(verify.map((item) => item.host));
91
+ const plain = ingress.upstreams.flatMap((entry) => entry.hosts).filter((host) => !seen.has(host) && !ingress.drop.includes(host));
92
+ return [...verify, ...[...new Set(plain)].map((host) => ({ host, path: '/' }))];
93
+ }
94
+
95
+ const probeClass = (run, entry) => {
96
+ const target = `https://${entry.host}${entry.path}`;
97
+ const result = run(['curl', '-sS', '-o', '/dev/null', '--max-time', '10', '-w', '%{http_code}', '--resolve', shellQuote(`${entry.host}:443:127.0.0.1`), shellQuote(target)].join(' '));
98
+ const code = Number(result.stdout.trim());
99
+ return code >= 200 && code < 500 ? Math.floor(code / 100) : 0;
100
+ };
101
+
102
+ const baselineOf = (run, hosts) => new Map(hosts.map((entry) => [entry.host, probeClass(run, entry)]));
103
+ const hostsDown = (run, hosts, baseline) => hosts.filter((entry) => baseline.get(entry.host) !== 0 && probeClass(run, entry) !== baseline.get(entry.host));
104
+
105
+ function startMissing(run, report, order) {
106
+ const failed = [];
107
+ for (const entry of order.filter((item) => !running(run, item.name))) {
108
+ const started = remoteDocker(run, ['start', entry.name]);
109
+ if (started.status === 0) report.note(`started ${entry.name}`);
110
+ else {
111
+ failed.push(entry.name);
112
+ report.bad(`cannot start ${entry.name}: ${started.stderr.trim().split(/\r?\n/u)[0]}`);
113
+ }
114
+ }
115
+ return failed;
116
+ }
117
+
118
+ async function recover(ctx, run, report, snapshot, hosts, baseline) {
119
+ const order = startOrder(snapshot);
120
+ const names = order.map((entry) => entry.name);
121
+ await waitUntil(ctx, WAIT_SECONDS, () => dockerUp(run));
122
+ await waitUntil(ctx, GRACE_SECONDS, () => notRunning(run, names).length === 0);
123
+ startMissing(run, report, order);
124
+ await waitUntil(ctx, WAIT_SECONDS, () => notRunning(run, names).length === 0);
125
+ const still = notRunning(run, names);
126
+ const back = names.filter((name) => !still.includes(name));
127
+ report.note(`containers back (${back.length} of ${names.length}): ${back.join(', ') || 'none'}`);
128
+ if (still.length > 0) report.bad(`CONTAINERS STILL DOWN after the restart: ${still.join(', ')}`);
129
+ await waitUntil(ctx, HOST_WAIT_SECONDS, () => hostsDown(run, hosts, baseline).length === 0);
130
+ const down = hostsDown(run, hosts, baseline).map((entry) => entry.host);
131
+ if (down.length > 0) report.bad(`PUBLIC HOSTS STILL DOWN against the pre-restart baseline: ${down.join(', ')}`);
132
+ else if (hosts.length > 0) report.ok(`all ${hosts.length} public host(s) answer as before the restart`);
133
+ return { back, missing: still, hostsDown: down };
134
+ }
135
+
136
+ /**
137
+ * The fix. Needs execute AND ownerApproved (else a dry-run line, or approvalMissing when execute is given without approval).
138
+ * Returns { changed, back, missing, hostsDown, snapshot }.
139
+ */
140
+ export async function fixLiveRestore(ctx, run, report, { execute, ownerApproved, config }) {
141
+ const idle = { changed: false, back: [], missing: [], hostsDown: [], snapshot: [] };
142
+ if (!liveRestoreEnabled(run)) {
143
+ report.note('docker live-restore is already off: nothing to change');
144
+ return idle;
145
+ }
146
+ if (!execute) {
147
+ report.note('dry run: would snapshot the running containers and the public hosts, back up daemon.json, set live-restore false, restart docker, start every container that does not come back (databases, applications, edge) and re-check the hosts (needs --execute and --owner-approved)');
148
+ return idle;
149
+ }
150
+ if (!ownerApproved) throw approvalMissing('STACK_LIVE_RESTORE_UNAPPROVED', 'restarting dockerd restarts every container; pass --owner-approved after the owner said yes');
151
+ const rewritten = rewrittenDaemonJson(run);
152
+ if (rewritten.error) {
153
+ report.bad(rewritten.error);
154
+ return idle;
155
+ }
156
+ const snapshot = snapshotContainers(run);
157
+ const hosts = hostsToProve(config);
158
+ const baseline = baselineOf(run, hosts);
159
+ report.note(`snapshot: ${snapshot.length} running container(s), ${hosts.length} public host(s) in the baseline`);
160
+ const stamp = new Date(ctx.now()).toISOString().replaceAll(/[-:.]/gu, '').slice(0, 15);
161
+ const backup = `${DAEMON_JSON}.bak-${stamp}`;
162
+ const steps = [
163
+ [`sudo -n cp -p ${shellQuote(DAEMON_JSON)} ${shellQuote(backup)}`, undefined],
164
+ [`sudo -n tee ${shellQuote(DAEMON_JSON)} >/dev/null`, rewritten.text],
165
+ ['sudo -n systemctl restart docker', undefined],
166
+ ];
167
+ for (const [command, input] of steps) {
168
+ const done = run(command, input === undefined ? {} : { input });
169
+ if (done.status !== 0) {
170
+ report.bad(`live-restore fix stopped at "${command.split(' ').slice(0, 4).join(' ')}": ${done.stderr.trim().split(/\r?\n/u)[0]} (daemon.json backup: ${backup})`);
171
+ return { ...idle, snapshot };
172
+ }
173
+ }
174
+ report.note(`daemon.json backed up to ${backup}; live-restore set to false; docker restarted`);
175
+ const outcome = await recover(ctx, run, report, snapshot, hosts, baseline);
176
+ return { changed: true, snapshot, ...outcome };
177
+ }
@@ -0,0 +1,266 @@
1
+ // prepare.mjs - `starci app stack prepare` and `doctor`: read-only checks of the Swarm manager, plus (prepare, --execute) the missing external
2
+ // volumes and the ONE-TIME, owner-approved `docker swarm init`. Every docker call runs ON the manager through the ssh runner (remote.mjs), so the
3
+ // same code serves CI and the owner's machine. Lines are `ok text` / `FAIL text`; a failing check makes the result not ok but all checks run.
4
+ import fs from 'node:fs';
5
+ import { firstLine, lines as splitLines } from './context.mjs';
6
+ import { RELEASE_ID } from './digest.mjs';
7
+ import { approvalMissing, refusal } from './errors.mjs';
8
+ import { installAge } from './age-install.mjs';
9
+ import { describeClash, edgeCollisions } from './edge-alias.mjs';
10
+ import { checkLiveRestore, fixLiveRestore } from './live-restore.mjs';
11
+ import { remoteDocker, shellQuote, withRunner } from './remote.mjs';
12
+
13
+ const MIN_FREE_GB = 10;
14
+ const ADDRESS = /^[0-9a-zA-Z.:-]+$/u;
15
+ const SWARM_FORMAT = '{{.Swarm.LocalNodeState}} {{.Swarm.ControlAvailable}}';
16
+ const OK = 'ok ';
17
+ const BAD = 'FAIL ';
18
+
19
+ function newReport() {
20
+ const entries = [];
21
+ const add = (mark) => (text) => { entries.push({ mark, text }); };
22
+ return {
23
+ ok: add(OK),
24
+ bad: add(BAD),
25
+ note: add(''),
26
+ failed: () => entries.some((entry) => entry.mark === BAD),
27
+ lines: () => entries.map((entry) => `${entry.mark}${entry.text}`),
28
+ checks: () => entries.filter((entry) => entry.mark !== '').map((entry) => ({ ok: entry.mark === OK, text: entry.text })),
29
+ };
30
+ }
31
+
32
+ function swarmInfo(run) {
33
+ const result = remoteDocker(run, ['info', '--format', SWARM_FORMAT]);
34
+ const text = result.stdout.trim();
35
+ return { reachable: result.status === 0, active: text === 'active true', state: text.split(' ')[0], manager: text.split(' ')[1] };
36
+ }
37
+
38
+ const dockerText = (run, args) => remoteDocker(run, args).stdout.trim();
39
+
40
+ function checkDaemon(run, report) {
41
+ if (!swarmInfo(run).reachable) {
42
+ report.bad('docker daemon not reachable');
43
+ return false;
44
+ }
45
+ report.ok(`docker daemon reachable (${dockerText(run, ['version', '--format', '{{.Server.Version}}'])})`);
46
+ return true;
47
+ }
48
+
49
+ function checkPlatform(run, report) {
50
+ const platform = dockerText(run, ['info', '--format', '{{.OSType}}/{{.Architecture}}']);
51
+ if (platform === 'linux/x86_64') report.ok('linux/x86_64');
52
+ else report.bad(`unsupported platform ${platform}`);
53
+ }
54
+
55
+ function checkSwarm(run, report) {
56
+ const info = swarmInfo(run);
57
+ if (info.active) report.ok('swarm active, this node is a manager');
58
+ else report.bad(`swarm is '${info.state}' (manager: ${info.manager}): the owner approves "starci app stack prepare --init-swarm --owner-approved --execute" once`);
59
+ }
60
+
61
+ function checkNodes(run, report) {
62
+ const count = splitLines(remoteDocker(run, ['node', 'ls', '-q']).stdout).length;
63
+ if (count <= 1) report.ok('single-manager swarm');
64
+ else report.ok(`swarm has ${count} nodes: review quorum and placement before relying on it`);
65
+ }
66
+
67
+ const volumeExists = (run, volume) => remoteDocker(run, ['volume', 'inspect', volume]).status === 0;
68
+
69
+ function checkVolumes(run, report, config) {
70
+ for (const volume of config.volumes) {
71
+ if (volumeExists(run, volume)) report.ok(`volume ${volume}`);
72
+ else report.bad(`volume ${volume} missing (prepare creates it)`);
73
+ }
74
+ }
75
+
76
+ function checkDisk(run, report) {
77
+ const result = run('target=/var/lib/docker; [ -d "$target" ] || target=/; echo "$target"; df -Pk "$target"');
78
+ const [target, , row = ''] = splitLines(result.stdout);
79
+ const availableKb = Number(row.trim().split(/\s+/u)[3]);
80
+ if (result.status !== 0 || !Number.isFinite(availableKb)) {
81
+ report.bad('disk: cannot read the free space');
82
+ return;
83
+ }
84
+ const gigabytes = Math.floor(availableKb / 1_048_576);
85
+ if (gigabytes >= MIN_FREE_GB) report.ok(`disk: ${gigabytes} GB free under ${target}`);
86
+ else report.bad(`disk: only ${gigabytes} GB free under ${target}`);
87
+ }
88
+
89
+ const releasesBase = (config) => config.remote.releasesBase ?? `/srv/${config.stack}`;
90
+
91
+ function directoryState(run, config) {
92
+ const quoted = shellQuote(releasesBase(config));
93
+ return run(`if [ ! -d ${quoted} ]; then echo missing; elif [ ! -w ${quoted} ]; then echo readonly; else echo writable; fi`).stdout.trim();
94
+ }
95
+
96
+ function checkReleasesDirectory(run, report, config) {
97
+ const base = releasesBase(config);
98
+ const state = directoryState(run, config);
99
+ if (state === 'writable') report.ok(`releases directory ${base} is writable`);
100
+ else if (state === 'readonly') report.bad(`releases directory ${base} is not writable by the deploy user`);
101
+ else report.bad(`releases directory ${base} is missing (create it once, owned by the deploy user)`);
102
+ }
103
+
104
+ /** The checks both verbs share; false when the daemon cannot be reached (nothing else can be judged). */
105
+ function baseChecks(run, report, config) {
106
+ if (!checkDaemon(run, report)) return false;
107
+ checkLiveRestore(run, report);
108
+ checkPlatform(run, report);
109
+ checkSwarm(run, report);
110
+ checkNodes(run, report);
111
+ checkVolumes(run, report, config);
112
+ checkDisk(run, report);
113
+ checkReleasesDirectory(run, report, config);
114
+ return true;
115
+ }
116
+
117
+ function advertiseArguments(ctx) {
118
+ const address = ctx.env.STARCI_ADVERTISE_ADDR;
119
+ if (!address) return [];
120
+ if (!ADDRESS.test(address)) throw refusal('STACK_SWARM_ADVERTISE_ADDR', `STARCI_ADVERTISE_ADDR must match ${ADDRESS}`);
121
+ const listen = ctx.env.STARCI_SWARM_LISTEN_ADDR ?? '127.0.0.1:2377';
122
+ if (!ADDRESS.test(listen)) throw refusal('STACK_SWARM_LISTEN_ADDR', `STARCI_SWARM_LISTEN_ADDR must match ${ADDRESS}`);
123
+ // the control port stays on loopback unless the owner widens it: a single manager needs no inbound swarm port
124
+ return ['--advertise-addr', address, '--listen-addr', listen];
125
+ }
126
+
127
+ function initSwarm(run, report, { execute, advertise }) {
128
+ if (swarmInfo(run).active) {
129
+ report.note('swarm already active: nothing to initialize (never re-initialized)');
130
+ return false;
131
+ }
132
+ if (!execute) {
133
+ report.note('dry run: would run docker swarm init (needs --execute and --owner-approved)');
134
+ return false;
135
+ }
136
+ const result = remoteDocker(run, ['swarm', 'init', ...advertise]);
137
+ if (result.status !== 0) {
138
+ report.bad(`docker swarm init failed: ${firstLine(result.stderr)}`);
139
+ return false;
140
+ }
141
+ report.note('SWARM INITIALIZED. Record the node id; back up /var/lib/docker/swarm (docker swarm unlock-key if autolock is enabled later).');
142
+ return true;
143
+ }
144
+
145
+ function createVolumes(run, report, config, execute) {
146
+ const created = [];
147
+ for (const volume of config.volumes.filter((name) => !volumeExists(run, name))) {
148
+ if (!execute) {
149
+ report.note(`dry run: would create volume ${volume}`);
150
+ continue;
151
+ }
152
+ const result = remoteDocker(run, ['volume', 'create', volume]);
153
+ if (result.status === 0) {
154
+ report.note(`created volume ${volume}`);
155
+ created.push(volume);
156
+ } else report.bad(`cannot create volume ${volume}: ${firstLine(result.stderr)}`);
157
+ }
158
+ return created;
159
+ }
160
+
161
+ /** The backup directories the deploy block declares (volume backups and the ingress store backups), without duplicates. */
162
+ const backupDirectories = (config) => [...new Set([config.backup?.dir, config.ingress?.backup?.dir].filter((dir) => typeof dir === 'string'))];
163
+
164
+ /** Creates one declared backup directory (mode 750, owned by the deploy user): as the user first, with passwordless sudo when the parent is not theirs. */
165
+ function ensureDirectory(run, report, dir) {
166
+ const quoted = shellQuote(dir);
167
+ if (run(`test -d ${quoted}`).status === 0) return;
168
+ const made = run(`install -d -m 750 ${quoted} || sudo -n install -d -o "$(id -un)" -m 750 ${quoted}`);
169
+ if (made.status === 0) report.note(`created ${dir} (mode 750, owned by the deploy user)`);
170
+ else report.bad(`cannot create ${dir}: ${firstLine(made.stderr)} (declare a backup dir under the releases root, or create it once with sudo)`);
171
+ }
172
+
173
+ /** Creates the releases directory and installs age (the backup encryptor) once, with passwordless sudo when the host allows it. */
174
+ function ensureTools(run, report, config) {
175
+ const base = releasesBase(config);
176
+ const quoted = shellQuote(base);
177
+ if (directoryState(run, config) === 'missing') {
178
+ const made = run(`sudo -n mkdir -p ${quoted} && sudo -n chown "$(id -un)" ${quoted}`);
179
+ if (made.status === 0) report.note(`created ${base} owned by the deploy user`);
180
+ else report.bad(`cannot create ${base}: ${firstLine(made.stderr)} (needs passwordless sudo once, or create it by hand)`);
181
+ }
182
+ for (const dir of [`${base}/releases`, ...backupDirectories(config)]) ensureDirectory(run, report, dir);
183
+ installAge(run, report);
184
+ }
185
+
186
+ /** prepare: read-only checks; with execute also the missing volumes and (initSwarm, approved) the one-time swarm init. */
187
+ export async function prepareHost(ctx, config, { execute = false, initSwarm: wantInit = false, fixLiveRestore: wantFix = false, ownerApproved = false, sshCmd = null } = {}, deps = {}) {
188
+ if (wantInit && execute && !ownerApproved && ctx.env.STARCI_SWARM_INIT_APPROVED !== '1') {
189
+ throw approvalMissing('STACK_SWARM_INIT_UNAPPROVED', 'docker swarm init is an owner-approved effect; pass --owner-approved (or set STARCI_SWARM_INIT_APPROVED=1) after the owner said yes');
190
+ }
191
+ const advertise = wantInit ? advertiseArguments(ctx) : [];
192
+ return withRunner(ctx, config, { sshCmd }, deps, async (run, notes) => {
193
+ const report = newReport();
194
+ notes.forEach(report.note);
195
+ const data = { created: [], swarmInitialized: false };
196
+ if (swarmInfo(run).reachable) {
197
+ if (wantFix) data.liveRestore = await fixLiveRestore(ctx, run, report, { execute, ownerApproved, config });
198
+ if (wantInit) data.swarmInitialized = initSwarm(run, report, { execute, advertise });
199
+ data.created = createVolumes(run, report, config, execute);
200
+ if (execute) ensureTools(run, report, config);
201
+ }
202
+ baseChecks(run, report, config);
203
+ return { ok: !report.failed(), lines: report.lines(), data: { ...data, checks: report.checks() } };
204
+ });
205
+ }
206
+
207
+ function checkServices(run, report, config) {
208
+ const result = remoteDocker(run, ['service', 'ls', '--filter', `label=com.docker.stack.namespace=${config.stack}`, '--format', '{{.Name}} {{.Replicas}} {{.Image}}']);
209
+ if (result.status !== 0) {
210
+ report.bad(`cannot list the services of stack ${config.stack}`);
211
+ return 0;
212
+ }
213
+ const services = splitLines(result.stdout);
214
+ report.ok(`services of stack ${config.stack}: ${services.length}`);
215
+ for (const service of services) report.note(` ${service}`);
216
+ return services.length;
217
+ }
218
+
219
+ function managedCount(run, kind, stack) {
220
+ return splitLines(remoteDocker(run, [kind, 'ls', '--filter', `label=${stack}.managed=true`, '-q']).stdout).length;
221
+ }
222
+
223
+ function checkPointers(run, report, config) {
224
+ const base = shellQuote(releasesBase(config));
225
+ const pointers = {};
226
+ for (const pointer of ['current', 'previous']) {
227
+ const value = run(`cd ${base} 2>/dev/null && [ -L ${pointer} ] && basename "$(readlink ${pointer})" || true`).stdout.trim();
228
+ pointers[pointer] = value === '' ? null : value;
229
+ if (value === '') report.ok(`release ${pointer}: none`);
230
+ else if (RELEASE_ID.test(value)) report.ok(`release ${pointer}: ${value}`);
231
+ else report.bad(`release ${pointer} names an unexpected value`);
232
+ }
233
+ return pointers;
234
+ }
235
+
236
+ /** The short service names of the stack source against the names the external (edge) networks already resolve (STACK_DEPLOY_ALIAS_SHARED at deploy). */
237
+ async function checkEdgeNames(run, report, config) {
238
+ if (!config.stackFile) return;
239
+ let found;
240
+ try {
241
+ found = await edgeCollisions(run, config, fs.readFileSync(config.stackFile, 'utf8'));
242
+ } catch (error) {
243
+ report.bad(`edge networks: ${error.message}`);
244
+ return;
245
+ }
246
+ for (const clash of found.clashes) report.bad(`STACK_DEPLOY_ALIAS_SHARED: ${describeClash(clash)}`);
247
+ if (found.clashes.length === 0) report.ok(`edge networks (${found.networks.length}): no short service name is resolved by another tenant`);
248
+ }
249
+
250
+ /** doctor: the prepare checks plus services, managed secrets and configs and the release pointers. Changes nothing. */
251
+ export async function doctorHost(ctx, config, { sshCmd = null } = {}, deps = {}) {
252
+ return withRunner(ctx, config, { sshCmd }, deps, async (run, notes) => {
253
+ const report = newReport();
254
+ notes.forEach(report.note);
255
+ const data = { services: 0, secrets: 0, configs: 0, releases: { current: null, previous: null } };
256
+ if (baseChecks(run, report, config)) {
257
+ data.services = checkServices(run, report, config);
258
+ data.secrets = managedCount(run, 'secret', config.stack);
259
+ data.configs = managedCount(run, 'config', config.stack);
260
+ report.ok(`managed secrets: ${data.secrets}, configs: ${data.configs}`);
261
+ data.releases = checkPointers(run, report, config);
262
+ await checkEdgeNames(run, report, config);
263
+ }
264
+ return { ok: !report.failed(), lines: report.lines(), data: { ...data, checks: report.checks() } };
265
+ });
266
+ }
@@ -0,0 +1,22 @@
1
+ // private-file.mjs - writes a file only its owner may use. On POSIX the mode is enough. Windows OpenSSH refuses a key, a known_hosts or a config
2
+ // file whose ACL still carries inherited entries ("Bad permissions ... UNKNOWN\UNKNOWN"), so on win32 the inheritance is removed with icacls and the
3
+ // current user alone is granted access. `ctx.platform` and `ctx.username` are optional seams for the specs; the defaults are the real ones.
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import { refusal } from './errors.mjs';
7
+ import { firstLine, tool } from './context.mjs';
8
+
9
+ /** On win32: `icacls <file> /inheritance:r /grant:r <user>:<rights>` (R = read, F = full, to let a cleanup overwrite the file). Elsewhere: nothing. */
10
+ export function restrictToUser(ctx, file, rights = 'R') {
11
+ if ((ctx.platform ?? process.platform) !== 'win32') return;
12
+ const user = ctx.username ?? os.userInfo().username;
13
+ const run = tool(ctx, 'icacls', [file, '/inheritance:r', '/grant:r', `${user}:${rights}`]);
14
+ if (run.status !== 0) throw refusal('STACK_REMOTE_ACL', `cannot restrict ${file} to the current user with icacls: ${firstLine(run.stderr) || firstLine(run.stdout) || 'icacls failed'}`);
15
+ }
16
+
17
+ /** Writes `text` to `file` with owner-only access (mode on POSIX, icacls on win32). */
18
+ export function writePrivate(ctx, file, text) {
19
+ fs.writeFileSync(file, text, { mode: 0o600 });
20
+ fs.chmodSync(file, 0o600);
21
+ restrictToUser(ctx, file);
22
+ }
@@ -0,0 +1,90 @@
1
+ // provision.mjs - creates the immutable, versioned Swarm secrets and config objects a release names, on the daemon DOCKER_HOST points at.
2
+ // Each secret value is opened in memory and piped to `docker secret create <runtimeName> -` on stdin: it never reaches argv, a file, the
3
+ // output or an error message. A name that exists is left alone (same value, same name); a name is never reused for another value, and
4
+ // an earlier version is never removed here (prune runs only after the release is accepted).
5
+ import fs from 'node:fs';
6
+ import { firstLine, lines as splitLines, tool } from './context.mjs';
7
+ import { failure, refusal } from './errors.mjs';
8
+ import { isReleaseId } from './digest.mjs';
9
+ import { configBytes, configObjects, loadSealed } from './sealed.mjs';
10
+
11
+ const STALE = 'render the release again';
12
+
13
+ function readRelease(options) {
14
+ if (options.release) return options.release;
15
+ if (!options.releaseFile) throw refusal('STACK_RELEASE_MISSING', 'provision needs a release record (release or releaseFile)');
16
+ try {
17
+ return JSON.parse(fs.readFileSync(options.releaseFile, 'utf8'));
18
+ } catch {
19
+ throw refusal('STACK_RELEASE_INVALID', `${options.releaseFile} is not a readable release record`);
20
+ }
21
+ }
22
+
23
+ function releaseId(release) {
24
+ const id = String(release.releaseDigest).slice(0, 16);
25
+ if (!isReleaseId(id) || !Array.isArray(release.secrets) || !Array.isArray(release.configs)) throw refusal('STACK_RELEASE_INVALID', 'the release record has no valid releaseDigest, secrets and configs');
26
+ return id;
27
+ }
28
+
29
+ function existing(ctx, kind) {
30
+ const run = tool(ctx, 'docker', [kind, 'ls', '--format', '{{.Name}}']);
31
+ if (run.error || run.status !== 0) throw failure('STACK_PROVISION_FAILED', `docker ${kind} ls failed: ${run.error?.message ?? firstLine(run.stderr)}`);
32
+ return new Set(splitLines(run.stdout));
33
+ }
34
+
35
+ function createFailure(run, what, value) {
36
+ const text = run.error?.message ?? firstLine(run.stderr);
37
+ return failure('STACK_PROVISION_FAILED', `docker ${what} failed: ${value === undefined ? text : text.replaceAll(value, '[value]')}`);
38
+ }
39
+
40
+ function create(ctx, kind, labels, name, input) {
41
+ const label = labels.flatMap((item) => ['--label', item]);
42
+ return tool(ctx, 'docker', [kind, 'create', ...label, name, '-'], { input });
43
+ }
44
+
45
+ function plan(ctx, config, release, sealed) {
46
+ const secrets = release.secrets.map((entry) => {
47
+ const secret = sealed.secrets[entry.key];
48
+ if (secret?.runtimeName !== entry.runtimeName) throw refusal('STACK_RELEASE_STALE', `the sealed custody no longer matches the release record for ${entry.key}; ${STALE}`);
49
+ return { key: entry.key, name: entry.runtimeName, value: secret.value };
50
+ });
51
+ const files = new Map(configObjects(config).map((object) => [object.name, object.file]));
52
+ const configs = release.configs.map((entry) => {
53
+ if (!files.has(entry.name)) throw refusal('STACK_RELEASE_STALE', `the config ${entry.name} does not match the committed files; ${STALE}`);
54
+ return { logical: entry.logical, name: entry.name, file: files.get(entry.name) };
55
+ });
56
+ const haveSecrets = existing(ctx, 'secret');
57
+ const haveConfigs = existing(ctx, 'config');
58
+ return {
59
+ secrets: secrets.filter((item) => !haveSecrets.has(item.name)),
60
+ kept: secrets.filter((item) => haveSecrets.has(item.name)).length,
61
+ configs: configs.filter((item) => !haveConfigs.has(item.name)),
62
+ };
63
+ }
64
+
65
+ function createAll(ctx, config, id, todo) {
66
+ const managed = `${config.stack}.managed=true`;
67
+ const tagged = `${config.stack}.release=${id}`;
68
+ for (const item of todo.secrets) {
69
+ const run = create(ctx, 'secret', [managed, `${config.stack}.key=${item.key}`, tagged], item.name, item.value);
70
+ if (run.error || run.status !== 0) throw createFailure(run, `secret create for ${item.key}`, item.value);
71
+ }
72
+ for (const item of todo.configs) {
73
+ const run = create(ctx, 'config', [managed, tagged], item.name, configBytes(item.file));
74
+ if (run.error || run.status !== 0) throw createFailure(run, `config create for ${item.logical}`);
75
+ }
76
+ }
77
+
78
+ /** Creates the missing secrets and configs of a release; without `execute` it only reports what it would create. */
79
+ export async function provisionRelease(ctx, config, options = {}) {
80
+ const release = readRelease(options);
81
+ const id = releaseId(release);
82
+ const todo = plan(ctx, config, release, loadSealed(ctx, config));
83
+ const data = { created: todo.secrets.length, kept: todo.kept, configsCreated: todo.configs.length };
84
+ if (options.execute !== true) {
85
+ const keys = todo.secrets.map((item) => item.key).join(', ');
86
+ return { ok: true, lines: [`dry run: would create ${data.created} secrets (${keys}), ${data.configsCreated} configs; ${data.kept} kept`], data };
87
+ }
88
+ createAll(ctx, config, id, todo);
89
+ return { ok: true, lines: [`provision: secrets created ${data.created}, kept ${data.kept}; configs created ${data.configsCreated}`], data };
90
+ }
@@ -0,0 +1,46 @@
1
+ // prune.mjs - removes the managed Swarm secrets and configs that none of the kept releases names. A name is never reused and an earlier
2
+ // version is never deleted before convergence, so this runs only after a release was accepted. Docker refuses to remove an object a
3
+ // service still uses; such a refusal is reported and left alone (never forced). Without `execute` it only lists what it would remove.
4
+ import fs from 'node:fs';
5
+ import { firstLine, lines as splitLines, tool } from './context.mjs';
6
+ import { failure, refusal } from './errors.mjs';
7
+
8
+ const KINDS = ['secret', 'config'];
9
+
10
+ /** The runtime names a kept release.json holds: secret runtimeNames and config names (names only, never values). */
11
+ function keptNames(files) {
12
+ const keep = new Set();
13
+ for (const file of files) {
14
+ let release;
15
+ try { release = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { throw refusal('STACK_PRUNE_KEEP_INVALID', `${file} is not a readable release.json`); }
16
+ for (const secret of release.secrets ?? []) keep.add(secret.runtimeName);
17
+ for (const config of release.configs ?? []) keep.add(config.name);
18
+ }
19
+ return keep;
20
+ }
21
+
22
+ function managed(ctx, config, kind) {
23
+ const list = tool(ctx, 'docker', [kind, 'ls', '--filter', `label=${config.stack}.managed=true`, '--format', '{{.Name}}']);
24
+ if (list.status !== 0) throw failure('STACK_PRUNE_LIST', `docker ${kind} ls failed: ${firstLine(list.stderr)}`);
25
+ return splitLines(list.stdout);
26
+ }
27
+
28
+ /**
29
+ * Prunes superseded managed secrets and configs. `keepFiles` are the release.json files to keep (at least one).
30
+ * Returns { ok, lines, data: { kept, candidates, removed, inUse } }.
31
+ */
32
+ export async function pruneStack(ctx, config, { keepFiles = [], execute = false } = {}) {
33
+ if (keepFiles.length === 0) throw refusal('STACK_PRUNE_NO_KEEP', 'prune needs --keep <release.json> (at least the accepted release)');
34
+ const keep = keptNames(keepFiles);
35
+ const out = { lines: [], removed: 0, inUse: 0, candidates: 0 };
36
+ for (const kind of KINDS) {
37
+ for (const name of managed(ctx, config, kind).filter((entry) => !keep.has(entry))) {
38
+ out.candidates += 1;
39
+ if (!execute) out.lines.push(`dry run: would remove ${kind} ${name}`);
40
+ else if (tool(ctx, 'docker', [kind, 'rm', name]).status === 0) out.removed += 1;
41
+ else { out.inUse += 1; out.lines.push(`${kind} ${name} is still in use; left alone`); }
42
+ }
43
+ }
44
+ out.lines.push(execute ? `prune: removed ${out.removed} superseded objects, ${out.inUse} still in use` : `dry run: ${out.candidates} superseded objects would be removed`);
45
+ return { ok: true, lines: out.lines, data: { kept: keep.size, candidates: out.candidates, removed: out.removed, inUse: out.inUse } };
46
+ }