@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.
- package/CHANGELOG.md +14 -0
- package/README.md +1 -1
- package/db/migrate.mjs +361 -0
- package/db/post-cutover.mjs +136 -0
- package/emit/contracts.mjs +2 -2
- package/emit/db-types.mjs +12 -3
- package/lint/run.mjs +3 -3
- package/package.json +4 -1
- package/release/gate.mjs +298 -0
- package/release/index.mjs +103 -0
- package/release/push.mjs +110 -0
- package/report/sonar.mjs +1 -1
- package/runtime/config.example.yaml +7 -6
- package/runtime/engine/config.mjs +10 -18
- package/runtime/engine/removed-vocabulary.mjs +0 -2
- package/runtime/knowledge/hfs/canon-pins.yaml +11 -11
- package/runtime/knowledge/hfs/fe-stack.yaml +22 -1
- package/runtime/knowledge/hfs/rules.yaml +2855 -163
- package/runtime/knowledge/hfs/slots.yaml +333 -62
- package/runtime/knowledge/patterns/be/api.yaml +32 -5
- package/runtime/knowledge/patterns/be/cli.yaml +14 -4
- package/runtime/knowledge/patterns/be/event-bus.yaml +3 -3
- package/runtime/knowledge/patterns/be/jobs.yaml +20 -2
- package/runtime/knowledge/patterns/be/projections.yaml +4 -2
- package/runtime/knowledge/patterns/be/queues.yaml +11 -3
- package/runtime/knowledge/patterns/be/reactors.yaml +20 -1
- package/runtime/knowledge/patterns/be/realtime.yaml +25 -3
- package/runtime/knowledge/patterns/be/saga.yaml +20 -3
- package/runtime/knowledge/patterns/be/webhooks.yaml +23 -4
- package/runtime/knowledge/patterns/fe/folder.yaml +1 -0
- package/runtime/knowledge/sonar-gate.yaml +1 -1
- package/runtime/modules/kernel/failure-codes.yaml +3091 -43
- package/runtime/modules/kernel/removed-vocabulary.yaml +30 -0
- package/runtime/modules/models/runtimes.yaml +5 -7
- package/runtime/modules/schemas/application-stacks.schema.yaml +663 -0
- package/runtime/packages/cli/src/catalog.generated.mjs +13769 -0
- package/runtime/packages/cli/src/validate-args.mjs +213 -0
- package/runtime/scripts/api/git/add.mjs +5 -0
- package/runtime/scripts/api/git/init.mjs +7 -0
- package/runtime/scripts/api/git/lib.mjs +4 -1
- package/runtime/scripts/api/git/tag.mjs +5 -0
- package/runtime/scripts/api/node/lib.mjs +20 -0
- package/runtime/scripts/api/node/run-node.mjs +6 -0
- package/runtime/scripts/api/sops/lib.mjs +42 -1
- package/runtime/scripts/api/sops/selected-age-recipient.mjs +7 -0
- package/runtime/scripts/api/supabase/database-dump.mjs +5 -0
- package/runtime/scripts/api/supabase/database-query.mjs +5 -0
- package/runtime/scripts/api/supabase/lib.mjs +44 -0
- package/runtime/scripts/api/supabase/migration-repair.mjs +5 -0
- package/runtime/scripts/hfs/app-wiring.mjs +201 -0
- package/runtime/scripts/hfs/architecture/backend-app-files.mjs +1 -8
- package/runtime/scripts/hfs/architecture/backend.mjs +2 -7
- package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +6 -3
- package/runtime/scripts/hfs/architecture/config.mjs +1 -1
- package/runtime/scripts/hfs/architecture/dead-exports-bindings.mjs +149 -0
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +9 -41
- package/runtime/scripts/hfs/architecture/default-deny.mjs +8 -6
- package/runtime/scripts/hfs/architecture/frontend-world.mjs +1 -1
- package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +153 -0
- package/runtime/scripts/hfs/architecture/hfs.mjs +3 -3
- package/runtime/scripts/hfs/architecture/index.mjs +1 -1
- package/runtime/scripts/hfs/architecture/module-per-transport.mjs +28 -13
- package/runtime/scripts/hfs/architecture/next-data-inference.mjs +176 -0
- package/runtime/scripts/hfs/architecture/next-data-symbols.mjs +26 -0
- package/runtime/scripts/hfs/architecture/next-data.mjs +31 -23
- package/runtime/scripts/hfs/architecture/reachability.mjs +14 -0
- package/runtime/scripts/hfs/architecture/register-once.mjs +2 -2
- package/runtime/scripts/hfs/architecture/source-names.mjs +48 -3
- package/runtime/scripts/hfs/architecture/supabase-ast.mjs +211 -11
- package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -1
- package/runtime/scripts/hfs/architecture/supabase.mjs +59 -21
- package/runtime/scripts/hfs/architecture/surface.mjs +2 -1
- package/runtime/scripts/hfs/architecture/tiers.mjs +4 -4
- package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +8 -2
- package/runtime/scripts/hfs/architecture.mjs +1 -1
- package/runtime/scripts/hfs/check-results.mjs +28 -0
- package/runtime/scripts/hfs/check.mjs +46 -47
- package/runtime/scripts/hfs/coverage-from.mjs +70 -0
- package/runtime/scripts/hfs/coverage-scope.mjs +1 -1
- package/runtime/scripts/hfs/db-baseline-verb.mjs +276 -0
- package/runtime/scripts/hfs/db-plan-verb.mjs +254 -0
- package/runtime/scripts/hfs/db-restamp-verb.mjs +60 -0
- package/runtime/scripts/hfs/declaration-shape.mjs +39 -3
- package/runtime/scripts/hfs/doc-commands.mjs +367 -0
- package/runtime/scripts/hfs/edition-slots.mjs +2 -2
- package/runtime/scripts/hfs/example-secrets.mjs +158 -0
- package/runtime/scripts/hfs/manifest-shape.mjs +69 -9
- package/runtime/scripts/hfs/param-names.mjs +1 -1
- package/runtime/scripts/hfs/refusal-codes.mjs +6 -0
- package/runtime/scripts/hfs/rule-catalog.mjs +158 -11
- package/runtime/scripts/hfs/rule-params-shape.mjs +22 -10
- package/runtime/scripts/hfs/rules/a15-findings.mjs +88 -0
- package/runtime/scripts/hfs/rules/app-env-required.mjs +20 -0
- package/runtime/scripts/hfs/rules/app-root.mjs +2 -2
- package/runtime/scripts/hfs/rules/assets.mjs +79 -0
- package/runtime/scripts/hfs/rules/boot-proof.mjs +16 -0
- package/runtime/scripts/hfs/rules/canon-copy-drift.mjs +59 -0
- package/runtime/scripts/hfs/rules/cli.mjs +1 -1
- package/runtime/scripts/hfs/rules/contract-compat.mjs +1 -1
- package/runtime/scripts/hfs/rules/contract.mjs +67 -6
- package/runtime/scripts/hfs/rules/coverage-below.mjs +230 -0
- package/runtime/scripts/hfs/rules/database-access.mjs +343 -0
- package/runtime/scripts/hfs/rules/database-baseline.mjs +142 -0
- package/runtime/scripts/hfs/rules/database-config.mjs +250 -2
- package/runtime/scripts/hfs/rules/database-constants.mjs +9 -0
- package/runtime/scripts/hfs/rules/database-migrations.mjs +286 -7
- package/runtime/scripts/hfs/rules/database-plpgsql.mjs +119 -9
- package/runtime/scripts/hfs/rules/database-queue-sources.mjs +176 -0
- package/runtime/scripts/hfs/rules/database-queues.mjs +361 -0
- package/runtime/scripts/hfs/rules/database-sql.mjs +91 -9
- package/runtime/scripts/hfs/rules/database.mjs +9 -0
- package/runtime/scripts/hfs/rules/deploy-scan.mjs +192 -0
- package/runtime/scripts/hfs/rules/deploy-workflow.mjs +219 -0
- package/runtime/scripts/hfs/rules/doc-fresh.mjs +286 -0
- package/runtime/scripts/hfs/rules/docker-health.mjs +249 -0
- package/runtime/scripts/hfs/rules/docker.mjs +24 -7
- package/runtime/scripts/hfs/rules/edition.mjs +9 -8
- package/runtime/scripts/hfs/rules/env-parity.mjs +373 -0
- package/runtime/scripts/hfs/rules/env-value-shape.mjs +113 -0
- package/runtime/scripts/hfs/rules/event-bus.mjs +3 -2
- package/runtime/scripts/hfs/rules/event-tables.mjs +100 -0
- package/runtime/scripts/hfs/rules/fe-contract-documents.mjs +115 -15
- package/runtime/scripts/hfs/rules/fe-stack.mjs +57 -0
- package/runtime/scripts/hfs/rules/feature-files.mjs +28 -0
- package/runtime/scripts/hfs/rules/frontend-tree.mjs +54 -35
- package/runtime/scripts/hfs/rules/integration-specs.mjs +3 -2
- package/runtime/scripts/hfs/rules/lexicon.mjs +62 -0
- package/runtime/scripts/hfs/rules/lite-proof-lane.mjs +89 -0
- package/runtime/scripts/hfs/rules/monorepo.mjs +14 -1
- package/runtime/scripts/hfs/rules/pipeline.mjs +8 -1
- package/runtime/scripts/hfs/rules/release-provenance.mjs +211 -0
- package/runtime/scripts/hfs/rules/saga-delivery.mjs +66 -0
- package/runtime/scripts/hfs/rules/saga.mjs +8 -16
- package/runtime/scripts/hfs/rules/script-hygiene.mjs +151 -0
- package/runtime/scripts/hfs/rules/secret-custody-only.mjs +65 -0
- package/runtime/scripts/hfs/rules/secrets.mjs +13 -0
- package/runtime/scripts/hfs/rules/services.mjs +4 -4
- package/runtime/scripts/hfs/rules/stack-tooling.mjs +93 -0
- package/runtime/scripts/hfs/rules/stacks-deploy.mjs +76 -0
- package/runtime/scripts/hfs/rules/stacks-ingress.mjs +87 -0
- package/runtime/scripts/hfs/rules/stacks-lite-backing.mjs +111 -0
- package/runtime/scripts/hfs/rules/stacks-release.mjs +366 -0
- package/runtime/scripts/hfs/rules/stacks-runtime.mjs +41 -1
- package/runtime/scripts/hfs/rules/stacks-swarm.mjs +417 -0
- package/runtime/scripts/hfs/rules/supabase-secrets.mjs +2 -2
- package/runtime/scripts/hfs/rules/supply-chain.mjs +167 -0
- package/runtime/scripts/hfs/rules/unused-symbol-flow.mjs +101 -0
- package/runtime/scripts/hfs/rules/unused-symbol-ledger.mjs +77 -0
- package/runtime/scripts/hfs/rules/unused-symbol-translators.mjs +165 -0
- package/runtime/scripts/hfs/rules/unused-symbol-values.mjs +103 -0
- package/runtime/scripts/hfs/rules/unused-symbol.mjs +320 -0
- package/runtime/scripts/hfs/runtime-rules/source-ast.mjs +185 -0
- package/runtime/scripts/hfs/runtime-rules/workflow-source.mjs +19 -0
- package/runtime/scripts/hfs/sealed-members.mjs +101 -0
- package/runtime/scripts/hfs/secret-bootstrap-crypto.mjs +200 -0
- package/runtime/scripts/hfs/secret-edit.mjs +170 -0
- package/runtime/scripts/hfs/secret-init.mjs +127 -19
- package/runtime/scripts/hfs/secret-sops.mjs +38 -6
- package/runtime/scripts/hfs/secret-store.mjs +56 -54
- package/runtime/scripts/hfs/secret.mjs +34 -5
- package/runtime/scripts/hfs/slot-imports.mjs +18 -5
- package/runtime/scripts/hfs/slots.mjs +3 -2
- package/runtime/scripts/hfs/stack-config-kit.mjs +34 -0
- package/runtime/scripts/hfs/stack-deploy-config.mjs +313 -0
- package/runtime/scripts/hfs/stack-ingress-config.mjs +237 -0
- package/runtime/scripts/hfs/stacks-gate.mjs +414 -0
- package/runtime/scripts/hfs/starcistacks-runtime.mjs +61 -0
- package/runtime/scripts/hfs/typescript-programs.mjs +0 -8
- package/runtime/scripts/lib/command-tokens.mjs +126 -0
- package/runtime/scripts/lib/compose-values.mjs +11 -0
- package/runtime/scripts/lib/deployment-findings.mjs +7 -0
- package/runtime/scripts/lib/env.mjs +2 -2
- package/runtime/scripts/lib/git.mjs +1 -1
- package/runtime/scripts/lib/i18n.mjs +0 -2
- package/runtime/scripts/lib/json-clone.mjs +61 -0
- package/runtime/scripts/lib/language.mjs +1 -1
- package/runtime/scripts/lib/list.mjs +4 -0
- package/runtime/scripts/lib/roots.mjs +2 -4
- package/runtime/scripts/lib/runtime-example.mjs +13 -0
- package/runtime/scripts/lib/secret-patterns.mjs +60 -5
- package/runtime/scripts/lib/sql-ast.mjs +32 -0
- package/runtime/scripts/lib/stack-services.mjs +1 -1
- package/runtime/scripts/lib/swarm-stack.mjs +5 -18
- package/runtime/scripts/lib/trim-end.mjs +8 -0
- package/runtime/scripts/lib/ts-analysis.mjs +5 -2
- package/runtime/scripts/lib/ts-ast.mjs +27 -2
- package/runtime/scripts/lib/workflow-events.mjs +8 -0
- package/scaffold/add-app.mjs +77 -59
- package/scaffold/add-cli-lite.mjs +14 -6
- package/scaffold/add-compose.mjs +79 -0
- package/scaffold/add-database-lite.mjs +14 -0
- package/scaffold/add-fe-overlay.mjs +1 -0
- package/scaffold/add-fe-page.mjs +1 -0
- package/scaffold/add-inbox-lite.mjs +74 -42
- package/scaffold/add-table.mjs +110 -87
- package/scaffold/add.mjs +159 -44
- package/scaffold/app.mjs +124 -39
- package/scaffold/edition-gate.mjs +3 -3
- package/scaffold/lite-exports.mjs +2 -1
- package/scaffold/service.mjs +2 -2
- package/scaffold/supabase-ports.mjs +1 -1
- package/src/main.mjs +229 -116
- package/stack/age-install.mjs +37 -0
- package/stack/backup.mjs +100 -0
- package/stack/build-args.mjs +42 -0
- package/stack/build.mjs +173 -0
- package/stack/cli.mjs +130 -0
- package/stack/config.mjs +57 -0
- package/stack/context.mjs +57 -0
- package/stack/converge.mjs +107 -0
- package/stack/deploy.mjs +301 -0
- package/stack/digest.mjs +16 -0
- package/stack/docker-transport.mjs +53 -0
- package/stack/edge-alias.mjs +118 -0
- package/stack/errors.mjs +21 -0
- package/stack/image-scan.mjs +152 -0
- package/stack/ingress-create.mjs +114 -0
- package/stack/ingress-publish.mjs +273 -0
- package/stack/ingress.mjs +706 -0
- package/stack/jobs.mjs +51 -0
- package/stack/live-restore.mjs +177 -0
- package/stack/prepare.mjs +266 -0
- package/stack/private-file.mjs +22 -0
- package/stack/provision.mjs +90 -0
- package/stack/prune.mjs +46 -0
- package/stack/release-store.mjs +87 -0
- package/stack/remote.mjs +205 -0
- package/stack/render.mjs +149 -0
- package/stack/retire.mjs +47 -0
- package/stack/sealed.mjs +149 -0
- package/stack/task.mjs +42 -0
- package/stack/verify.mjs +99 -0
- package/sync/ci-commands.mjs +13 -0
- package/sync/format.mjs +1 -1
- package/sync/hygiene.mjs +1 -1
- package/sync/index.mjs +110 -54
- package/sync/stack-images.mjs +127 -0
- package/templates/app/ci-workflows/github/workflows/ci.yml +5 -1
- package/templates/app/ci-workflows/github/workflows/images.yml +15 -3
- package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +148 -0
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +7 -3
- package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +38 -6
- package/templates/app/ci-workflows-lite/github/workflows/images.yml +14 -2
- package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +148 -0
- package/templates/app/codegen-graphql.mjs.partial +13 -2
- package/templates/app/codegen-lite/scripts/codegen.mjs +6 -1
- package/templates/app/codegen-prelude.mjs.partial +8 -0
- package/templates/app/gitignore +3 -0
- package/templates/app/gitignore-lite +3 -0
- package/templates/app/package-scripts/package.json +1 -0
- package/templates/app/package-scripts/supabase.json +7 -0
- package/templates/app/package-scripts-lite/package.json +1 -7
- package/templates/app/skeleton/.editorconfig +1 -0
- package/templates/app/skeleton/.gitattributes +7 -1
- package/templates/app/skeleton/.github/dependabot.yml +17 -0
- package/templates/app/skeleton/.nvmrc +1 -1
- package/templates/app/skeleton/.starcistacks/application-stacks.yaml +134 -1
- package/templates/app/skeleton/.starcistacks/dev/README.md +16 -0
- package/templates/app/skeleton/.starcistacks/dev/environment.json +5 -0
- package/templates/app/skeleton/.starcistacks/dev/infra/compose/stack.yaml +71 -0
- package/templates/app/skeleton/.starcistacks/dev/runtime/env/KEYS.md +14 -0
- package/templates/app/skeleton/.starcistacks/production/README.md +37 -0
- package/templates/app/skeleton/.starcistacks/production/environment.json +5 -0
- package/templates/app/skeleton/.starcistacks/production/infra/swarm/Caddyfile +15 -0
- package/templates/app/skeleton/.starcistacks/production/infra/swarm/owner-actions.md +11 -0
- package/templates/app/skeleton/.starcistacks/production/infra/swarm/recovery/restore-proof.md +7 -0
- package/templates/app/skeleton/.starcistacks/production/infra/swarm/releases/current.yaml +6 -0
- package/templates/app/skeleton/.starcistacks/production/infra/swarm/releases/images.lock.yaml +9 -0
- package/templates/app/skeleton/.starcistacks/production/infra/swarm/releases/previous.yaml +6 -0
- package/templates/app/skeleton/.starcistacks/production/infra/swarm/stack.yaml +142 -0
- package/templates/app/skeleton/.starcistacks/production/runtime/env/KEYS.md +14 -0
- package/templates/app/skeleton/README.md +6 -6
- package/templates/app/skeleton/scripts/codegen.mjs +1 -2
- package/templates/app/skeleton-lite/.editorconfig +16 -0
- package/templates/app/skeleton-lite/.gitattributes +8 -0
- package/templates/app/skeleton-lite/.github/dependabot.yml +17 -0
- package/templates/app/skeleton-lite/.nvmrc +1 -0
- package/templates/app/skeleton-lite/.sops.yaml +3 -2
- package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +178 -1
- package/templates/app/skeleton-lite/.starcistacks/dev/README.md +20 -0
- package/templates/app/skeleton-lite/.starcistacks/dev/environment.json +2 -1
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/Caddyfile +16 -0
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/owner-actions.md +11 -0
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/recovery/restore-proof.md +7 -0
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/releases/current.yaml +6 -0
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/releases/images.lock.yaml +8 -0
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/releases/previous.yaml +6 -0
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +120 -0
- package/templates/app/skeleton-lite/.starcistacks/dev/runtime/env/KEYS.md +18 -0
- package/templates/app/skeleton-lite/.starcistacks/production/README.md +38 -0
- package/templates/app/skeleton-lite/.starcistacks/production/environment.json +6 -0
- package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/Caddyfile +11 -0
- package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/owner-actions.md +11 -0
- package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/recovery/restore-proof.md +7 -0
- package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/releases/current.yaml +6 -0
- package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/releases/images.lock.yaml +8 -0
- package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/releases/previous.yaml +6 -0
- package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +121 -0
- package/templates/app/skeleton-lite/.starcistacks/production/runtime/env/KEYS.md +21 -0
- package/templates/app/skeleton-lite/.starciwork/brand/index.yaml +12 -0
- package/templates/app/skeleton-lite/README.md +5 -3
- package/templates/app/skeleton-lite/supabase/config.toml +8 -1
- package/templates/app/skeleton-lite/supabase/migrations/__timestamp___baseline.sql +15 -6
- package/templates/app/upgrade-full/.starcistacks/dev/README.md +13 -7
- package/templates/be/image/worker/Dockerfile +1 -1
- package/templates/be/inbox/database-client.spec.ts.tpl +17 -18
- package/templates/be/inbox/database-client.ts.tpl +12 -11
- package/templates/be/inbox/delivery-recorded.event.ts.tpl +36 -0
- package/templates/be/inbox/event-index.ts.tpl +1 -0
- package/templates/be/inbox/events.json.tpl +10 -0
- package/templates/be/inbox/migration.sql.tpl +1 -0
- package/templates/be/inbox/platform/event-bus/event-bus.contracts.ts.tpl +42 -0
- package/templates/be/inbox/platform/event-bus/event-bus.decorators.ts.tpl +9 -0
- package/templates/be/inbox/platform/event-bus/event-bus.module-definition.ts.tpl +6 -0
- package/templates/be/inbox/platform/event-bus/event-bus.module.ts.tpl +19 -0
- package/templates/be/inbox/platform/event-bus/event-bus.options.ts.tpl +2 -0
- package/templates/be/inbox/platform/event-bus/event-bus.port.ts.tpl +16 -0
- package/templates/be/inbox/platform/event-bus/event-bus.service.spec.ts.tpl +45 -0
- package/templates/be/inbox/platform/event-bus/event-bus.service.ts.tpl +15 -0
- package/templates/be/inbox/platform/event-bus/index.ts.tpl +5 -0
- package/templates/be/inbox/platform/event-bus/persistence/event-bus.sql.ts.tpl +5 -0
- package/templates/be/inbox/platform/inbox/inbox.decorators.ts.tpl +9 -0
- package/templates/be/inbox/platform/inbox/inbox.module-definition.ts.tpl +6 -0
- package/templates/be/inbox/platform/inbox/inbox.module.ts.tpl +19 -0
- package/templates/be/inbox/platform/inbox/inbox.options.ts.tpl +2 -0
- package/templates/be/inbox/platform/inbox/inbox.port.ts.tpl +10 -0
- package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +41 -0
- package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +19 -0
- package/templates/be/inbox/platform/inbox/index.ts.tpl +3 -0
- package/templates/be/inbox/platform/inbox/persistence/inbox.sql.ts.tpl +7 -0
- package/templates/be/inbox/platform/migration.sql.tpl +48 -0
- package/templates/be/inbox/service.spec.ts.tpl +42 -9
- package/templates/be/inbox/service.ts.tpl +23 -8
- package/templates/be/inbox/webhook-door.spec.ts.tpl +1 -1
- package/templates/be/inbox/webhook-door.ts.tpl +2 -2
- package/templates/be/patterns/api/feature.module-definition.ts.tpl +6 -0
- package/templates/be/patterns/api/feature.module.ts.tpl +2 -1
- package/templates/be/patterns/api/feature.options.ts.tpl +2 -0
- package/templates/be/patterns/api/graphql.module-definition.ts.tpl +6 -0
- package/templates/be/patterns/api/graphql.module.ts.tpl +3 -2
- package/templates/be/patterns/api/graphql.options.ts.tpl +2 -0
- package/templates/be/patterns/cli/group.module-definition.ts.tpl +6 -0
- package/templates/be/patterns/cli/group.module.ts.tpl +2 -1
- package/templates/be/patterns/cli/group.options.ts.tpl +2 -0
- package/templates/be/patterns/event-bus/platform/errors/event-bus.error.ts.tpl +0 -3
- package/templates/be/patterns/event-bus/platform/messages/event-bus.messages.ts.tpl +0 -2
- package/templates/be/patterns/jobs/job.module-definition.ts.tpl +6 -0
- package/templates/be/patterns/jobs/job.module.ts.tpl +2 -1
- package/templates/be/patterns/jobs/job.options.ts.tpl +2 -0
- package/templates/be/patterns/jobs/job.queue.module-definition.ts.tpl +6 -0
- package/templates/be/patterns/jobs/job.queue.module.ts.tpl +6 -3
- package/templates/be/patterns/jobs/job.queue.options.ts.tpl +2 -0
- package/templates/be/patterns/jobs/platform/fenced.processor.spec.ts.tpl +3 -1
- package/templates/be/patterns/outbox/platform/outbox-relay.policy.spec.ts.tpl +25 -9
- package/templates/be/patterns/queues/platform/queue.config.ts.tpl +1 -1
- package/templates/be/patterns/queues/platform/queue.index.ts.tpl +1 -1
- package/templates/be/patterns/queues/platform/queue.module-definition.ts.tpl +2 -2
- package/templates/be/patterns/queues/platform/queue.module.ts.tpl +11 -0
- package/templates/be/patterns/queues/platform/queue.options.ts.tpl +10 -0
- package/templates/be/patterns/queues/queue.module-definition.ts.tpl +6 -0
- package/templates/be/patterns/queues/queue.module.ts.tpl +2 -1
- package/templates/be/patterns/queues/queue.options.ts.tpl +2 -0
- package/templates/be/patterns/reactors/message.module-definition.ts.tpl +6 -0
- package/templates/be/patterns/reactors/message.module.ts.tpl +6 -3
- package/templates/be/patterns/reactors/message.options.ts.tpl +2 -0
- package/templates/be/patterns/reactors/reactor.module-definition.ts.tpl +6 -0
- package/templates/be/patterns/reactors/reactor.module.ts.tpl +2 -1
- package/templates/be/patterns/reactors/reactor.options.ts.tpl +2 -0
- package/templates/be/patterns/realtime/channel.gateway.spec.ts.tpl +188 -0
- package/templates/be/patterns/realtime/channel.gateway.ts.tpl +22 -0
- package/templates/be/patterns/realtime/channel.graphql.module-definition.ts.tpl +6 -0
- package/templates/be/patterns/realtime/channel.graphql.module.ts.tpl +2 -1
- package/templates/be/patterns/realtime/channel.graphql.options.ts.tpl +2 -0
- package/templates/be/patterns/realtime/channel.websocket.module-definition.ts.tpl +6 -0
- package/templates/be/patterns/realtime/channel.websocket.module.ts.tpl +110 -0
- package/templates/be/patterns/realtime/channel.websocket.options.ts.tpl +7 -0
- package/templates/be/patterns/saga/saga.message.module-definition.ts.tpl +6 -0
- package/templates/be/patterns/saga/saga.message.module.ts.tpl +6 -3
- package/templates/be/patterns/saga/saga.message.options.ts.tpl +2 -0
- package/templates/be/patterns/saga/saga.module-definition.ts.tpl +6 -0
- package/templates/be/patterns/saga/saga.module.ts.tpl +2 -1
- package/templates/be/patterns/saga/saga.options.ts.tpl +2 -0
- package/templates/be/patterns/webhooks/webhook.http.module-definition.ts.tpl +6 -0
- package/templates/be/patterns/webhooks/webhook.http.module.ts.tpl +2 -1
- package/templates/be/patterns/webhooks/webhook.http.options.ts.tpl +2 -0
- package/templates/be/platform/graphql/localize-errors.mapper.spec.ts +80 -26
- package/templates/be/platform/graphql/localize-errors.mapper.ts +2 -5
- package/templates/be/skeleton/apps/__app__/src/app.module.ts +11 -9
- package/templates/be/skeleton/apps/__app__/src/compose/api.ts +4 -0
- package/templates/be/skeleton/apps/__app__/src/compose/domain.ts +11 -0
- package/templates/be/skeleton/apps/__app__/src/compose/platform.ts +2 -0
- package/templates/be/skeleton/apps/__app__/src/compose/system.ts +5 -0
- package/templates/be/skeleton/apps/__app__/src/compose/webhooks.ts +4 -0
- package/templates/be/skeleton/apps/__app__/src/main.ts +15 -1
- package/templates/be/skeleton/apps/cli/src/app.module.ts +3 -13
- package/templates/be/skeleton/apps/cli/src/cli.options.ts +6 -14
- package/templates/be/skeleton/apps/cli/src/compose/cli.ts +5 -0
- package/templates/be/skeleton/apps/cli/src/compose/platform.ts +24 -0
- package/templates/be/skeleton/src/features/api/system-health/application/check-readiness.contracts.ts +2 -0
- package/templates/be/skeleton/src/features/api/system-health/application/check-readiness.handler.ts +22 -0
- package/templates/be/skeleton/src/features/api/system-health/application/check-readiness.query.ts +11 -0
- package/templates/be/skeleton/src/features/api/system-health/system-health.module-definition.ts +6 -0
- package/templates/be/skeleton/src/features/api/system-health/system-health.module.ts +5 -3
- package/templates/be/skeleton/src/features/api/system-health/system-health.options.ts +2 -0
- package/templates/be/skeleton/src/features/api/system-health/transport/http/ready.controller.ts +19 -0
- package/templates/be/skeleton/src/features/api/system-health/transport/http/system-health-http.module-definition.ts +6 -0
- package/templates/be/skeleton/src/features/api/system-health/transport/http/system-health-http.module.ts +8 -3
- package/templates/be/skeleton/src/features/api/system-health/transport/http/system-health-http.options.ts +2 -0
- package/templates/be/skeleton/src/features/cli/cli.module-definition.ts +6 -0
- package/templates/be/skeleton/src/features/cli/cli.module.ts +3 -2
- package/templates/be/skeleton/src/features/cli/cli.options.ts +2 -0
- package/templates/be/skeleton/src/features/cli/migrate/migrate.module-definition.ts +6 -0
- package/templates/be/skeleton/src/features/cli/migrate/migrate.module.ts +2 -1
- package/templates/be/skeleton/src/features/cli/migrate/migrate.options.ts +2 -0
- package/templates/be/skeleton/src/features/cli/seed/seed.module-definition.ts +6 -0
- package/templates/be/skeleton/src/features/cli/seed/seed.module.ts +2 -1
- package/templates/be/skeleton/src/features/cli/seed/seed.options.ts +2 -0
- package/templates/be/skeleton/src/features/cli/seed/subs/run.cli.spec.ts +5 -6
- package/templates/be/skeleton/src/modules/domain/identity/admission.policy.spec.ts +56 -4
- package/templates/be/skeleton/src/modules/domain/identity/admission.policy.ts +36 -5
- package/templates/be/skeleton/src/modules/domain/identity/auth.guard.spec.ts +39 -8
- package/templates/be/skeleton/src/modules/domain/identity/auth.guard.ts +14 -8
- package/templates/be/skeleton/src/modules/domain/identity/credential.contracts.ts +26 -0
- package/templates/be/skeleton/src/modules/domain/identity/credential.guard.spec.ts +119 -0
- package/templates/be/skeleton/src/modules/domain/identity/credential.guard.ts +43 -0
- package/templates/be/skeleton/src/modules/domain/identity/identity.contracts.ts +16 -0
- package/templates/be/skeleton/src/modules/domain/identity/identity.decorators.ts +18 -1
- package/templates/be/skeleton/src/modules/domain/identity/identity.module.ts +22 -4
- package/templates/be/skeleton/src/modules/domain/identity/identity.options.ts +8 -2
- package/templates/be/skeleton/src/modules/domain/identity/index.ts +1 -0
- package/templates/be/skeleton/src/modules/platform/database/connection-source.client.spec.ts +11 -4
- package/templates/be/skeleton/src/modules/platform/database/database-probe.service.spec.ts +32 -0
- package/templates/be/skeleton/src/modules/platform/database/database-probe.service.ts +14 -0
- package/templates/be/skeleton/src/modules/platform/database/database.module.ts +7 -1
- package/templates/be/skeleton/src/modules/platform/database/index.ts +2 -0
- package/templates/be/skeleton/src/modules/platform/database/primary.decorators.ts +8 -0
- package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.spec.ts +25 -6
- package/templates/be/skeleton/src/modules/platform/errors/errors.filter.spec.ts +16 -6
- package/templates/be/skeleton/src/modules/platform/http-security/edge-security.service.spec.ts +130 -0
- package/templates/be/skeleton/src/modules/platform/http-security/edge-security.service.ts +58 -0
- package/templates/be/skeleton/src/modules/platform/http-security/errors/http-security.error.ts +3 -0
- package/templates/be/skeleton/src/modules/platform/http-security/execution-request.mapper.spec.ts +16 -4
- package/templates/be/skeleton/src/modules/platform/http-security/http-security.config.ts +16 -10
- package/templates/be/skeleton/src/modules/platform/http-security/http-security.module.ts +3 -1
- package/templates/be/skeleton/src/modules/platform/http-security/http-security.options.ts +2 -0
- package/templates/be/skeleton/src/modules/platform/http-security/index.ts +5 -0
- package/templates/be/skeleton/src/modules/platform/http-security/messages/http-security.messages.ts +12 -5
- package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.spec.ts +13 -8
- package/templates/be/skeleton/src/modules/platform/http-security/readiness.contracts.ts +5 -0
- package/templates/be/skeleton/src/modules/platform/http-security/readiness.decorators.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/http-security/readiness.service.spec.ts +86 -0
- package/templates/be/skeleton/src/modules/platform/http-security/readiness.service.ts +39 -0
- package/templates/be/skeleton/src/modules/platform/http-security/webhook-signature.service.spec.ts +139 -131
- package/templates/be/skeleton/src/modules/platform/i18n/index.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.spec.ts +91 -67
- package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.ts +5 -4
- package/templates/be/skeleton/src/modules/platform/logging/logging.log-events.ts +0 -2
- package/templates/be/skeleton/src/modules/platform/primitives/outcome.mapper.spec.ts +20 -6
- package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.spec.ts +16 -4
- package/templates/be/skeleton/src/modules/platform/retry/index.ts +3 -0
- package/templates/be/skeleton/src/modules/platform/retry/retry.decorators.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/retry/retry.module-definition.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/retry/retry.module.ts +19 -0
- package/templates/be/skeleton/src/modules/platform/retry/retry.options.ts +2 -0
- package/templates/be/skeleton/src/modules/platform/retry/retry.port.ts +11 -0
- package/templates/be/skeleton/src/modules/platform/retry/system-timer.service.spec.ts +36 -0
- package/templates/be/skeleton/src/modules/platform/retry/system-timer.service.ts +10 -0
- package/templates/be/skeleton/src/tests/contract/http-security/http-security.contract-spec.ts +54 -0
- package/templates/be/skeleton-lite/apps/api/src/app.module.ts +14 -18
- package/templates/be/skeleton-lite/apps/api/src/compose/api.ts +4 -0
- package/templates/be/skeleton-lite/apps/api/src/compose/domain.ts +10 -0
- package/templates/be/skeleton-lite/apps/api/src/compose/integrations.ts +16 -0
- package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +2 -0
- package/templates/be/skeleton-lite/apps/api/src/compose/system.ts +5 -0
- package/templates/be/skeleton-lite/apps/api/src/compose/webhooks.ts +4 -0
- package/templates/be/skeleton-lite/apps/api/src/main.ts +2 -3
- package/templates/be/skeleton-lite/apps/cli/src/compose/platform.ts +12 -0
- package/templates/be/skeleton-lite/src/modules/domain/identity/admission.policy.spec.ts +71 -5
- package/templates/be/skeleton-lite/src/modules/domain/identity/admission.policy.ts +29 -2
- package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.spec.ts +68 -15
- package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.ts +14 -9
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.contracts.ts +9 -2
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +16 -1
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.module.ts +19 -4
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.options.ts +5 -1
- package/templates/be/skeleton-lite/src/modules/domain/identity/index.ts +2 -1
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/errors/supabase.error.ts +0 -3
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.client.spec.ts +17 -5
- package/templates/be/skeleton-lite/src/modules/platform/database/database.module.ts +24 -13
- package/templates/be/skeleton-lite/src/modules/platform/database/database.port.ts +5 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/errors/database.error.ts +16 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +3 -0
- package/templates/be/skeleton-lite/src/modules/platform/http-security/http-security.module.ts +5 -2
- package/templates/be/skeleton-lite/src/modules/platform/http-security/index.ts +4 -1
- package/templates/be/skeleton-lite/src/modules/platform/i18n/index.ts +1 -1
- package/templates/be/skeleton-lite/src/modules/platform/logging/logging.log-events.ts +7 -0
- package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +29 -0
- package/templates/be/skeleton-lite/src/tests/fixtures/builders/cli.builder.ts +16 -0
- package/templates/be/table/api/feature.module-definition.ts.tpl +6 -0
- package/templates/be/table/api/feature.module.ts.tpl +2 -1
- package/templates/be/table/api/feature.options.ts.tpl +2 -0
- package/templates/be/table/api/graphql.module-definition.ts.tpl +6 -0
- package/templates/be/table/api/graphql.module.ts.tpl +3 -2
- package/templates/be/table/api/graphql.options.ts.tpl +2 -0
- package/templates/be/table/migration.sql.tpl +1 -0
- package/templates/be/upgrade-full/apps/cli/src/app.module.ts +4 -6
- package/templates/be/upgrade-full/apps/cli/src/cli.env.ts +7 -0
- package/templates/be/upgrade-full/apps/cli/src/compose/cli.ts +5 -0
- package/templates/be/upgrade-full/apps/cli/src/compose/platform.ts +9 -0
- package/templates/be/upgrade-full/apps/cli/src/main.ts +3 -0
- package/templates/be/upgrade-full/src/features/cli/cli.module.ts +3 -2
- package/templates/fe/auth/supabase-client.ts.tpl +34 -0
- package/templates/fe/skeleton/apps/__app__/src/app/auth/callback/route.ts +11 -0
- package/templates/fe/skeleton/apps/__app__/src/components/blocks/SignInForm/index.tsx +6 -1
- package/templates/fe/skeleton/apps/__app__/src/components/leaves/Icon/index.tsx +4 -0
- package/templates/fe/skeleton/apps/__app__/src/features/overlays/AppGuideOverlay/component.tsx +31 -0
- package/templates/fe/skeleton/apps/__app__/src/features/overlays/AppGuideOverlay/index.tsx +24 -0
- package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSession.ts +6 -1
- package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSignOut.ts +3 -4
- package/templates/fe/skeleton/apps/__app__/src/hooks/zustand/appGuide/index.ts +1 -0
- package/templates/fe/skeleton/apps/__app__/src/hooks/zustand/appGuide/store.ts +15 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/auth/pkce-client.ts +18 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/auth/server.ts +19 -0
- package/templates/fe/skeleton/apps/app/src/features/pages/AppHomePage/index.tsx +7 -1
- package/templates/fe/skeleton/apps/app/src/modules/i18n/messages/en.json +57 -0
- package/templates/fe/skeleton/apps/app/src/modules/i18n/messages/vi.json +6 -0
- package/templates/fe/skeleton/apps/landing/src/modules/i18n/messages/en.json +31 -0
- package/templates/fe/skeleton/packages/__project__-i18n/src/routing.ts +1 -1
- package/templates/fe/skeleton/packages/__project__-ui/src/index.ts +1 -0
- package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/client.ts +1 -34
- package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppHomePage/index.tsx +7 -1
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/en.json +57 -0
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/vi.json +30 -24
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/routing.ts +1 -1
- package/templates/fe/transport/graphql/clients/clients/auth.ts +1 -1
- package/templates/fe/transport/graphql/clients/links/locale.ts +2 -2
- package/upgrade/check.mjs +3 -1
- 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
|
+
}
|
package/stack/verify.mjs
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
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>,
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
const HEADER = scope => `# Generated by ${APP_BINARY} sync (${scope === APP_SCOPE ? 'app root' :
|
|
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
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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 =>
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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') }}
|