@starci/hfs 5.1.0 → 5.3.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.
- package/CHANGELOG.md +54 -0
- package/README.md +11 -2
- package/cache/hfs-family.d.mts +2 -3
- package/cache/hfs-family.mjs +4 -9
- package/cache/secret-records.d.mts +7 -0
- package/cache/secret-records.mjs +12 -0
- package/cache/session.mjs +4 -2
- package/codemod/account-doors.d.mts +36 -0
- package/codemod/account-doors.mjs +128 -0
- package/codemod/main.mjs +31 -1
- package/codemod/module-definition.d.mts +52 -0
- package/codemod/module-definition.mjs +242 -0
- package/codemod/quality-binding.d.mts +33 -0
- package/codemod/quality-binding.mjs +38 -0
- package/codemod/secrets-cache.d.mts +35 -0
- package/codemod/secrets-cache.mjs +70 -0
- package/codemod/sops-rules.d.mts +34 -0
- package/codemod/sops-rules.mjs +69 -0
- package/codemod/theme-tokens.mjs +48 -2
- package/db/postgres.mjs +9 -1
- package/lint/run.d.mts +6 -0
- package/lint/run.mjs +63 -10
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +65 -11
- package/runtime/knowledge/hfs/fe-stack.yaml +9 -9
- package/runtime/knowledge/hfs/fork-images.yaml +7 -0
- package/runtime/knowledge/hfs/rules.yaml +67 -15
- package/runtime/knowledge/hfs/slots.yaml +174 -30
- package/runtime/modules/kernel/failure-codes.yaml +67 -4
- package/runtime/modules/schemas/application-stacks.schema.yaml +6 -0
- package/runtime/scripts/hfs/architecture/config.d.mts +4 -0
- package/runtime/scripts/hfs/architecture/config.mjs +25 -1
- package/runtime/scripts/hfs/architecture/identity.mjs +18 -83
- package/runtime/scripts/hfs/architecture/module-per-transport.d.mts +1 -1
- package/runtime/scripts/hfs/architecture/module-per-transport.mjs +6 -1
- package/runtime/scripts/hfs/architecture/root-packages.d.mts +26 -0
- package/runtime/scripts/hfs/architecture/root-packages.mjs +78 -0
- package/runtime/scripts/hfs/architecture/transport-owner.d.mts +1 -2
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +3 -6
- package/runtime/scripts/hfs/architecture/type-context.d.mts +4 -1
- package/runtime/scripts/hfs/architecture/type-context.mjs +30 -0
- package/runtime/scripts/hfs/architecture/typescript.d.mts +7 -1
- package/runtime/scripts/hfs/architecture/typescript.mjs +21 -1
- package/runtime/scripts/hfs/explicit-surface-rule.mjs +1 -1
- package/runtime/scripts/hfs/manifest-shape.d.mts +1 -0
- package/runtime/scripts/hfs/manifest-shape.mjs +3 -1
- package/runtime/scripts/hfs/rules/a15-findings.mjs +5 -0
- package/runtime/scripts/hfs/rules/database-access.mjs +39 -1
- package/runtime/scripts/hfs/rules/monorepo.d.mts +4 -0
- package/runtime/scripts/hfs/rules/monorepo.mjs +10 -5
- package/runtime/scripts/hfs/rules/secrets.mjs +11 -0
- package/runtime/scripts/hfs/rules/stacks-local.d.mts +7 -0
- package/runtime/scripts/hfs/rules/stacks-local.mjs +71 -0
- package/runtime/scripts/hfs/rules/stacks-runtime.mjs +6 -1
- package/runtime/scripts/hfs/rules/stacks-service-images.d.mts +12 -0
- package/runtime/scripts/hfs/rules/stacks-service-images.mjs +120 -0
- package/runtime/scripts/hfs/secret-dev-init.d.mts +11 -0
- package/runtime/scripts/hfs/secret-dev-init.mjs +79 -0
- package/runtime/scripts/hfs/secret-errors.d.mts +3 -0
- package/runtime/scripts/hfs/secret-errors.mjs +8 -0
- package/runtime/scripts/hfs/secret-init.d.mts +6 -0
- package/runtime/scripts/hfs/secret-init.mjs +23 -0
- package/runtime/scripts/hfs/secret-sops.mjs +9 -1
- package/runtime/scripts/hfs/secret-standard.d.mts +20 -0
- package/runtime/scripts/hfs/secret-standard.mjs +106 -0
- package/runtime/scripts/hfs/secret.mjs +15 -8
- package/runtime/scripts/hfs/slots.mjs +1 -1
- package/runtime/scripts/hfs/stack-local-config.d.mts +101 -0
- package/runtime/scripts/hfs/stack-local-config.mjs +320 -0
- package/runtime/scripts/hfs/stacks-gate.mjs +1 -1
- package/runtime/scripts/hfs/view.d.mts +23 -0
- package/runtime/scripts/hfs/view.mjs +68 -0
- package/runtime/scripts/lib/declaration-surface.d.mts +4 -0
- package/runtime/scripts/lib/declaration-surface.mjs +55 -1
- package/runtime/scripts/lib/secret-records.d.mts +13 -0
- package/runtime/scripts/lib/secret-records.mjs +83 -0
- package/runtime/scripts/lib/swarm-stack.d.mts +2 -1
- package/runtime/scripts/lib/swarm-stack.mjs +2 -2
- package/scaffold/add-inbox-lite.mjs +2 -2
- package/scaffold/add-module.d.mts +16 -0
- package/scaffold/add-module.mjs +232 -0
- package/scaffold/add-queue-lite.mjs +50 -3
- package/scaffold/add.mjs +5 -0
- package/scaffold/app.d.mts +2 -2
- package/scaffold/app.mjs +16 -18
- package/scaffold/lite-exports.mjs +1 -15
- package/scaffold/module-registry.d.mts +19 -0
- package/scaffold/module-registry.mjs +84 -0
- package/scripts/sync-bundles.d.mts +8 -0
- package/src/main.mjs +23 -2
- package/stack/cli.mjs +6 -1
- package/stack/context.d.mts +15 -0
- package/stack/context.mjs +1 -0
- package/stack/images/openclaw-runtime/Dockerfile +18 -0
- package/stack/images/openclaw-runtime/entrypoint.sh +23 -0
- package/stack/images/openclaw-runtime/openclaw-init.mjs +101 -0
- package/stack/images/openclaw-runtime/openclaw-relay.mjs +25 -0
- package/stack/local-cli.d.mts +25 -0
- package/stack/local-cli.mjs +89 -0
- package/stack/local-config.d.mts +17 -0
- package/stack/local-config.mjs +37 -0
- package/stack/local-custody.d.mts +24 -0
- package/stack/local-custody.mjs +109 -0
- package/stack/local-doctor.d.mts +6 -0
- package/stack/local-doctor.mjs +49 -0
- package/stack/local-down.d.mts +8 -0
- package/stack/local-down.mjs +20 -0
- package/stack/local-host.d.mts +42 -0
- package/stack/local-host.mjs +163 -0
- package/stack/local-logs.d.mts +8 -0
- package/stack/local-logs.mjs +32 -0
- package/stack/local-ports.d.mts +7 -0
- package/stack/local-ports.mjs +39 -0
- package/stack/local-prepare.d.mts +22 -0
- package/stack/local-prepare.mjs +62 -0
- package/stack/local-run.d.mts +71 -0
- package/stack/local-run.mjs +200 -0
- package/stack/local-state.d.mts +35 -0
- package/stack/local-state.mjs +29 -0
- package/stack/local-status.d.mts +6 -0
- package/stack/local-status.mjs +36 -0
- package/stack/local-up.d.mts +11 -0
- package/stack/local-up.mjs +169 -0
- package/stack/local-verify.d.mts +6 -0
- package/stack/local-verify.mjs +21 -0
- package/stack/runtime-layers.d.mts +21 -0
- package/stack/runtime-layers.mjs +67 -0
- package/sync/hygiene.mjs +3 -1
- package/sync/index.d.mts +15 -12
- package/sync/index.mjs +114 -28
- package/templates/app/auth-doors.sql.partial +115 -0
- package/templates/app/ci-workflows/github/workflows/ci.yml +1 -1
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +1 -1
- package/templates/app/module/jest.config.js.tpl +8 -0
- package/templates/app/module/src/index.ts.tpl +2 -0
- package/templates/app/module/src/manifest/declaration.spec.ts.tpl +11 -0
- package/templates/app/module/src/manifest/declaration.ts.tpl +13 -0
- package/templates/app/module/src/manifest/index.ts.tpl +1 -0
- package/templates/app/module/src/public/index.ts.tpl +2 -0
- package/templates/app/module/src/template/__name__.template.spec.ts.tpl +13 -0
- package/templates/app/module/src/template/__name__.template.ts.tpl +41 -0
- package/templates/app/module/src/template/index.ts.tpl +1 -0
- package/templates/app/module/src/workbench/components/__pascal__WorkbenchView.tsx.tpl +16 -0
- package/templates/app/module/src/workbench/index.ts.tpl +2 -0
- package/templates/app/package-scripts/core.json +1 -1
- package/templates/app/package-scripts/package.json +1 -1
- package/templates/app/package-scripts-lite/package.json +1 -1
- package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +85 -70
- package/templates/app/skeleton-lite/.starcistacks/dev/README.md +24 -12
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/compose/compose.yaml +43 -0
- package/templates/app/skeleton-lite/.starcistacks/dev/runtime/env/KEYS.md +21 -26
- package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +6 -0
- package/templates/app/skeleton-lite/.starcistacks/production/runtime/env/KEYS.md +6 -1
- package/templates/app/skeleton-lite/db/migrations/__timestamp___baseline.sql +76 -13
- package/templates/be/auth-lite/src/features/api/auth/application/auth.contracts.ts +44 -0
- package/templates/be/auth-lite/src/features/api/auth/application/confirm.command.ts +11 -0
- package/templates/be/auth-lite/src/features/api/auth/application/confirm.handler.ts +22 -0
- package/templates/be/auth-lite/src/features/api/auth/application/forgot.command.ts +11 -0
- package/templates/be/auth-lite/src/features/api/auth/application/forgot.handler.ts +22 -0
- package/templates/be/auth-lite/src/features/api/auth/application/get-jwks.handler.ts +23 -0
- package/templates/be/auth-lite/src/features/api/auth/application/get-jwks.query.ts +12 -0
- package/templates/be/auth-lite/src/features/api/auth/application/google-begin.command.ts +11 -0
- package/templates/be/auth-lite/src/features/api/auth/application/google-begin.handler.ts +22 -0
- package/templates/be/auth-lite/src/features/api/auth/application/google-complete.command.ts +12 -0
- package/templates/be/auth-lite/src/features/api/auth/application/google-complete.handler.ts +23 -0
- package/templates/be/auth-lite/src/features/api/auth/application/login.command.ts +12 -0
- package/templates/be/auth-lite/src/features/api/auth/application/login.handler.ts +23 -0
- package/templates/be/auth-lite/src/features/api/auth/application/logout.command.ts +11 -0
- package/templates/be/auth-lite/src/features/api/auth/application/logout.handler.ts +22 -0
- package/templates/be/auth-lite/src/features/api/auth/application/refresh.command.ts +12 -0
- package/templates/be/auth-lite/src/features/api/auth/application/refresh.handler.ts +23 -0
- package/templates/be/auth-lite/src/features/api/auth/application/resend.command.ts +11 -0
- package/templates/be/auth-lite/src/features/api/auth/application/resend.handler.ts +22 -0
- package/templates/be/auth-lite/src/features/api/auth/application/reset.command.ts +11 -0
- package/templates/be/auth-lite/src/features/api/auth/application/reset.handler.ts +22 -0
- package/templates/be/auth-lite/src/features/api/auth/application/session.command.ts +12 -0
- package/templates/be/auth-lite/src/features/api/auth/application/session.handler.ts +23 -0
- package/templates/be/auth-lite/src/features/api/auth/application/signup.command.ts +11 -0
- package/templates/be/auth-lite/src/features/api/auth/application/signup.handler.ts +22 -0
- package/templates/be/auth-lite/src/features/api/auth/auth.module-definition.ts +6 -0
- package/templates/be/auth-lite/src/features/api/auth/auth.module.ts +21 -0
- package/templates/be/auth-lite/src/features/api/auth/auth.options.ts +3 -0
- package/templates/be/auth-lite/src/features/api/auth/index.ts +2 -0
- package/templates/be/auth-lite/src/features/api/auth/transport/http/auth-http.module-definition.ts +6 -0
- package/templates/be/auth-lite/src/features/api/auth/transport/http/auth-http.module.ts +9 -0
- package/templates/be/auth-lite/src/features/api/auth/transport/http/auth-http.options.ts +3 -0
- package/templates/be/auth-lite/src/features/api/auth/transport/http/auth.controller.ts +145 -0
- package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/forgot.request.ts +16 -0
- package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/google-start.request.ts +17 -0
- package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/login.request.ts +16 -0
- package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/resend.request.ts +16 -0
- package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/reset.request.ts +18 -0
- package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/signup.request.ts +22 -0
- package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/token.request.ts +11 -0
- package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.spec.ts +46 -0
- package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.ts +19 -0
- package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.spec.ts +209 -0
- package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.ts +84 -0
- package/templates/be/auth-lite/src/modules/domain/identity/identity-email.log-events.ts +6 -0
- package/templates/be/auth-lite/src/modules/domain/identity/identity-email.service.spec.ts +233 -0
- package/templates/be/auth-lite/src/modules/domain/identity/identity-email.service.ts +176 -0
- package/templates/be/auth-lite/src/modules/domain/identity/identity-sign-in.log-events.ts +6 -0
- package/templates/be/auth-lite/src/modules/domain/identity/persistence/account-doors.port.ts +98 -0
- package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/identity-attempt.entity.ts +18 -0
- package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/identity-email-token.entity.ts +34 -0
- package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/identity-refresh-token.entity.ts +34 -0
- package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/identity-session.entity.ts +30 -0
- package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/index.ts +10 -0
- package/templates/be/auth-lite/src/modules/domain/identity/rate-counter.service.spec.ts +86 -0
- package/templates/be/auth-lite/src/modules/domain/identity/rate-counter.service.ts +45 -0
- package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.spec.ts +97 -0
- package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.ts +81 -0
- package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.spec.ts +95 -0
- package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.ts +93 -0
- package/templates/be/auth-lite/src/tests/fixtures/builders/identity.builder.ts +40 -0
- package/templates/be/inbox/module-definition.ts.tpl +2 -9
- package/templates/be/inbox/platform/event-bus/event-bus.module-definition.ts.tpl +2 -11
- package/templates/be/inbox/platform/inbox/inbox.module-definition.ts.tpl +2 -9
- package/templates/be/patterns/api/feature.module-definition.ts.tpl +3 -9
- package/templates/be/patterns/api/graphql.module-definition.ts.tpl +3 -9
- package/templates/be/patterns/cli/group.module-definition.ts.tpl +2 -9
- package/templates/be/patterns/event-bus/platform/event-bus.module-definition.ts.tpl +3 -10
- package/templates/be/patterns/idempotency/platform/inbox/inbox.module-definition.ts.tpl +2 -9
- package/templates/be/patterns/jobs/job.module-definition.ts.tpl +2 -9
- package/templates/be/patterns/jobs/job.queue.module-definition.ts.tpl +3 -9
- package/templates/be/patterns/jobs/platform/jobs.module-definition.ts.tpl +3 -10
- package/templates/be/patterns/projections/projection.module-definition.ts.tpl +2 -9
- package/templates/be/patterns/queues/platform/queue.module-definition.ts.tpl +3 -10
- package/templates/be/patterns/queues/queue.module-definition.ts.tpl +2 -9
- package/templates/be/patterns/reactors/message.module-definition.ts.tpl +3 -9
- package/templates/be/patterns/reactors/reactor.module-definition.ts.tpl +2 -9
- package/templates/be/patterns/realtime/channel.graphql.module-definition.ts.tpl +3 -9
- package/templates/be/patterns/realtime/channel.sse.module-definition.ts.tpl +3 -9
- package/templates/be/patterns/realtime/channel.websocket.module-definition.ts.tpl +3 -9
- package/templates/be/patterns/saga/saga.message.module-definition.ts.tpl +3 -9
- package/templates/be/patterns/saga/saga.module-definition.ts.tpl +2 -9
- package/templates/be/patterns/webhooks/webhook.http.module-definition.ts.tpl +3 -9
- package/templates/be/platform/graphql/graphql.module-definition.ts +2 -9
- package/templates/be/skeleton/src/features/api/system-health/system-health.module-definition.ts +3 -9
- package/templates/be/skeleton/src/features/api/system-health/transport/http/system-health-http.module-definition.ts +3 -9
- package/templates/be/skeleton/src/features/cli/cli.module-definition.ts +2 -9
- package/templates/be/skeleton/src/features/cli/migrate/migrate.module-definition.ts +3 -9
- package/templates/be/skeleton/src/features/cli/seed/seed.module-definition.ts +2 -9
- package/templates/be/skeleton/src/modules/domain/identity/identity.module-definition.ts +2 -9
- package/templates/be/skeleton/src/modules/domain/liveness/liveness.module-definition.ts +2 -9
- package/templates/be/skeleton/src/modules/domain/note/note.module-definition.ts +2 -9
- package/templates/be/skeleton/src/modules/platform/clock/clock.module-definition.ts +2 -9
- package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.contracts.ts +2 -0
- package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.module-definition.ts +2 -9
- package/templates/be/skeleton/src/modules/platform/database/database.module-definition.ts +3 -13
- package/templates/be/skeleton/src/modules/platform/errors/errors.module-definition.ts +4 -11
- package/templates/be/skeleton/src/modules/platform/http/http.module-definition.ts +4 -9
- package/templates/be/skeleton/src/modules/platform/http-security/http-security.module-definition.ts +5 -11
- package/templates/be/skeleton/src/modules/platform/i18n/i18n.module-definition.ts +4 -11
- package/templates/be/skeleton/src/modules/platform/ids/ids.module-definition.ts +2 -9
- package/templates/be/skeleton/src/modules/platform/logging/logging.module-definition.ts +2 -9
- package/templates/be/skeleton/src/modules/platform/retry/retry.module-definition.ts +2 -9
- package/templates/be/skeleton-lite/apps/api/src/api.env.ts +4 -0
- package/templates/be/skeleton-lite/apps/api/src/api.options.ts +3 -0
- package/templates/be/skeleton-lite/apps/api/src/app.module.ts +6 -5
- package/templates/be/skeleton-lite/apps/api/src/compose/api.ts +3 -2
- package/templates/be/skeleton-lite/apps/api/src/compose/integrations.ts +12 -5
- package/templates/be/skeleton-lite/src/modules/domain/identity/errors/identity.error.ts +40 -0
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +22 -1
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.module.ts +50 -2
- package/templates/be/skeleton-lite/src/modules/domain/identity/index.ts +3 -0
- package/templates/be/skeleton-lite/src/modules/domain/identity/messages/identity-mail.messages.ts +65 -0
- package/templates/be/skeleton-lite/src/modules/domain/identity/messages/identity.messages.ts +28 -0
- package/templates/be/skeleton-lite/src/modules/integrations/mail/index.ts +6 -0
- package/templates/be/skeleton-lite/src/modules/integrations/mail/mail.config.ts +33 -0
- package/templates/be/skeleton-lite/src/modules/integrations/mail/mail.decorators.ts +14 -0
- package/templates/be/skeleton-lite/src/modules/integrations/mail/mail.module-definition.ts +12 -0
- package/templates/be/skeleton-lite/src/modules/integrations/mail/mail.module.ts +27 -0
- package/templates/be/skeleton-lite/src/modules/integrations/mail/mail.options.ts +22 -0
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/index.ts +3 -3
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.config.ts +3 -1
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.decorators.ts +8 -4
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.module-definition.ts +5 -0
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.module.ts +29 -0
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.options.ts +6 -31
- package/templates/be/skeleton-lite/src/modules/integrations/passport/errors/passport.error.ts +18 -9
- package/templates/be/skeleton-lite/src/modules/integrations/passport/index.ts +35 -11
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.config.spec.ts +44 -8
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.config.ts +40 -18
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.decorators.ts +18 -6
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.kit.spec.ts +73 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.kit.ts +26 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.options.ts +24 -53
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.registration.ts +31 -0
- package/templates/be/skeleton-lite/src/modules/platform/cqrs/index.ts +1 -1
- package/templates/be/skeleton-lite/src/modules/platform/database/entities.ts +2 -1
- package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -0
- package/templates/be/skeleton-lite/src/modules/platform/http-security/index.ts +1 -1
- package/templates/be/skeleton-lite/src/modules/platform/http-security/rate-limit.guard.spec.ts +11 -0
- package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +22 -4
- package/templates/be/table/api/feature.module-definition.ts.tpl +3 -9
- package/templates/be/table/api/graphql.module-definition.ts.tpl +3 -9
- package/templates/be/table/module-definition.ts.tpl +2 -9
- package/templates/be/test-world/e2e/serves-once-migrated.e2e-spec.ts.tpl +9 -9
- package/templates/be/test-world/fixtures/migrations.sql.ts.tpl +4 -4
- package/templates/fe/auth-lite/apps/web/src/modules/auth/client.ts +26 -0
- package/templates/fe/auth-lite/apps/web/src/modules/auth/email-doors.ts +16 -0
- package/templates/fe/auth-lite/apps/web/src/modules/auth/index.ts +5 -0
- package/templates/fe/common/site-shell.tsx.partial +3 -3
- package/templates/fe/skeleton/apps/__app__/src/components/providers/AppProviders/index.tsx +33 -10
- package/templates/fe/skeleton/apps/__app__/src/hooks/auth/index.ts +1 -0
- package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useViewerId.ts +11 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/toast/toast.tsx +15 -12
- package/templates/fe/skeleton/apps/__app__/src/modules/viewer-cache/index.ts +5 -0
- package/templates/fe/skeleton/apps/__app__/src/redux/slices/session.ts +1 -1
- package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +30 -67
- package/templates/fe/skeleton/packages/__project__-i18n/src/proxy.ts +6 -21
- package/templates/fe/skeleton/packages/__project__-i18n/src/routing.ts +10 -16
- package/templates/fe/skeleton/packages/__project__-ui/src/composites/NotFoundNotice/index.tsx +4 -7
- package/templates/fe/skeleton/packages/__project__-ui/src/frames/SiteShell/index.tsx +2 -3
- package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/auth/confirm/page.tsx +9 -0
- package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/auth/forgot/page.tsx +9 -0
- package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/auth/resend/page.tsx +9 -0
- package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/auth/reset/page.tsx +9 -0
- package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/auth/signup/page.tsx +9 -0
- package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/layout.tsx +5 -9
- package/templates/fe/skeleton-lite/apps/web/src/components/blocks/EmailDoorForm/component.tsx +51 -0
- package/templates/fe/skeleton-lite/apps/web/src/components/blocks/EmailDoorForm/index.tsx +84 -0
- package/templates/fe/skeleton-lite/apps/web/src/components/composites/NotFoundNotice/index.tsx +4 -7
- package/templates/fe/skeleton-lite/apps/web/src/components/frames/SiteShell/index.tsx +1 -2
- package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppConfirmPage/index.tsx +25 -0
- package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppForgotPage/index.tsx +21 -0
- package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppHomePage/component.tsx +11 -3
- package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppHomePage/index.tsx +4 -1
- package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppResendPage/index.tsx +21 -0
- package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppResetPage/index.tsx +25 -0
- package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppSignupPage/index.tsx +21 -0
- package/templates/fe/skeleton-lite/apps/web/src/hooks/emailAuth/emailAuth.shared.ts +12 -0
- package/templates/fe/skeleton-lite/apps/web/src/hooks/emailAuth/index.ts +2 -0
- package/templates/fe/skeleton-lite/apps/web/src/hooks/emailAuth/useEmailDoor.ts +32 -0
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/index.ts +2 -2
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/en.json +50 -1
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/vi.json +50 -1
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/request.ts +7 -30
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/routing.ts +13 -14
- package/templates/fe/skeleton-lite/apps/web/src/modules/routes/index.ts +5 -0
- package/templates/fe/skeleton-lite/apps/web/src/proxy.ts +4 -17
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/Caddyfile +0 -16
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/owner-actions.md +0 -11
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/recovery/restore-proof.md +0 -7
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/releases/current.yaml +0 -6
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/releases/images.lock.yaml +0 -8
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/releases/previous.yaml +0 -6
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +0 -186
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.client.spec.ts +0 -22
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.client.ts +0 -25
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.cookie.spec.ts +0 -104
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.cookie.ts +0 -34
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.password.spec.ts +0 -28
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.password.ts +0 -11
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.tokens.spec.ts +0 -88
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.tokens.ts +0 -69
- package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/google.strategy.ts +0 -41
- package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/local.strategy.ts +0 -40
- package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/oauth-state.store.ts +0 -63
- package/templates/fe/skeleton/packages/__project__-ui/src/frames/SiteShell/classNames.ts +0 -4
- package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/auth-door.ts +0 -30
- package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/client.ts +0 -50
- package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/token-store.ts +0 -31
- package/templates/fe/skeleton-lite/apps/web/src/components/frames/SiteShell/classNames.ts +0 -4
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// local-host.mjs - the host prerequisites of a dev environment, one row each: what is needed, whether this machine has it, and the exact
|
|
2
|
+
// install step the owner runs when it does not (winget on Windows, brew on macOS, apt on Linux). Read-only: it probes tools through the spawn
|
|
3
|
+
// seam, reads sizes and the app's declared ports, and installs nothing (rules.md rules 5 and 8; the owner runs the printed steps).
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { firstLine } from './context.mjs';
|
|
8
|
+
import { bindFree, holderOf } from './local-ports.mjs';
|
|
9
|
+
import { readRecord } from './local-state.mjs';
|
|
10
|
+
const INSTALL = {
|
|
11
|
+
node: { win32: 'winget install OpenJS.NodeJS.LTS', darwin: 'brew install node', linux: 'sudo apt install nodejs npm, or nvm install <version>' },
|
|
12
|
+
npm: { win32: 'winget install OpenJS.NodeJS.LTS (npm ships with Node)', darwin: 'brew install node (npm ships with Node)', linux: 'sudo apt install npm' },
|
|
13
|
+
git: { win32: 'winget install Git.Git', darwin: 'brew install git', linux: 'sudo apt install git' },
|
|
14
|
+
docker: { win32: 'winget install Docker.DockerDesktop, then start Docker Desktop and wait for the engine', darwin: 'brew install --cask docker, then open Docker', linux: 'sudo apt install docker.io, then sudo systemctl enable --now docker and add your user to the docker group' },
|
|
15
|
+
compose: { win32: 'update Docker Desktop (it ships Compose v2): winget upgrade Docker.DockerDesktop', darwin: 'brew upgrade --cask docker', linux: 'sudo apt install docker-compose-v2' },
|
|
16
|
+
wsl: { win32: 'wsl --install (an administrator terminal), then restart', darwin: 'not needed', linux: 'not needed' },
|
|
17
|
+
sops: { win32: 'winget install Mozilla.SOPS', darwin: 'brew install sops', linux: 'download the sops release for your CPU from https://github.com/getsops/sops/releases and put it on PATH' },
|
|
18
|
+
age: { win32: 'winget install FiloSottile.age', darwin: 'brew install age', linux: 'sudo apt install age' },
|
|
19
|
+
};
|
|
20
|
+
const platformOf = (ctx) => (ctx.platform === 'win32' || ctx.platform === 'darwin' ? ctx.platform : 'linux');
|
|
21
|
+
const NPM_SHIMS = new Set(['npm', 'npx']);
|
|
22
|
+
/**
|
|
23
|
+
* How to start `command` on this platform without a shell. npm and npx are `.cmd` shims on Windows, which cannot be spawned directly: they run as
|
|
24
|
+
* `node <npm-cli.js>` from the Node install beside this node when it is there, else through `cmd.exe /d /s /c`.
|
|
25
|
+
*/
|
|
26
|
+
export function launch(platform, command, args, exists = fs.existsSync) {
|
|
27
|
+
if (platform !== 'win32' || !NPM_SHIMS.has(command))
|
|
28
|
+
return [command, [...args]];
|
|
29
|
+
const cli = path.join(path.dirname(process.execPath), 'node_modules', 'npm', 'bin', `${command}-cli.js`);
|
|
30
|
+
return exists(cli) ? [process.execPath, [cli, ...args]] : ['cmd.exe', ['/d', '/s', '/c', command, ...args]];
|
|
31
|
+
}
|
|
32
|
+
/** Runs a host tool without a shell (see launch). */
|
|
33
|
+
export function hostTool(ctx, command, args, options = {}) {
|
|
34
|
+
const [file, argv] = launch(ctx.platform, command, args);
|
|
35
|
+
return ctx.spawn(file, argv, options);
|
|
36
|
+
}
|
|
37
|
+
const present = (run) => run.error === undefined && run.status === 0;
|
|
38
|
+
const versionOf = (text) => /(\d+)\.(\d+)(?:\.(\d+))?/u.exec(text)?.[0] ?? null;
|
|
39
|
+
const wantedOf = (text) => /\d+(?:\.\d+){0,2}/u.exec(text)?.[0] ?? null;
|
|
40
|
+
const atLeast = (have, need) => {
|
|
41
|
+
const [a = 0, b = 0, c = 0] = have.split('.').map(Number);
|
|
42
|
+
const [x = 0, y = 0, z = 0] = need.split('.').map(Number);
|
|
43
|
+
if (a !== x)
|
|
44
|
+
return a > x;
|
|
45
|
+
return b !== y ? b > y : c >= z;
|
|
46
|
+
};
|
|
47
|
+
/** The Node version the app asks for: `.nvmrc`, else the first version of `engines.node`; null when it asks for none. */
|
|
48
|
+
export function wantedNode(repoRoot) {
|
|
49
|
+
try {
|
|
50
|
+
return wantedOf(fs.readFileSync(path.join(repoRoot, '.nvmrc'), 'utf8'));
|
|
51
|
+
}
|
|
52
|
+
catch { /* no .nvmrc */ }
|
|
53
|
+
try {
|
|
54
|
+
return wantedOf(String(JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')).engines?.node ?? ''));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const diskFree = (dir) => {
|
|
61
|
+
let at = dir;
|
|
62
|
+
while (!fs.existsSync(at) && path.dirname(at) !== at)
|
|
63
|
+
at = path.dirname(at);
|
|
64
|
+
try {
|
|
65
|
+
const stat = fs.statfsSync(at);
|
|
66
|
+
return Math.floor((stat.bavail * stat.bsize) / 1024 ** 3);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
function tool(ctx, id, row) {
|
|
73
|
+
const run = hostTool(ctx, row.command, row.args);
|
|
74
|
+
if (!present(run))
|
|
75
|
+
return { id, ok: false, detail: `${row.label} not found on PATH`, fix: INSTALL[id]?.[platformOf(ctx)] ?? null };
|
|
76
|
+
return { id, ok: true, detail: `${row.label} ${versionOf(`${run.stdout} ${run.stderr}`) ?? firstLine(run.stdout)}`, fix: null };
|
|
77
|
+
}
|
|
78
|
+
function docker(ctx) {
|
|
79
|
+
const platform = platformOf(ctx);
|
|
80
|
+
const cli = hostTool(ctx, 'docker', ['--version']);
|
|
81
|
+
if (!present(cli))
|
|
82
|
+
return [{ id: 'docker', ok: false, detail: 'docker not found on PATH', fix: INSTALL.docker?.[platform] ?? null }, { id: 'compose', ok: false, detail: 'needs docker first', fix: INSTALL.compose?.[platform] ?? null }];
|
|
83
|
+
const info = ctx.spawn('docker', ['info', '--format', '{{.ServerVersion}} {{.OSType}}']);
|
|
84
|
+
const [server, kind] = firstLine(info.stdout).split(' ');
|
|
85
|
+
const rows = [];
|
|
86
|
+
if (!present(info) || !server)
|
|
87
|
+
rows.push({ id: 'docker', ok: false, detail: 'the docker engine does not answer', fix: platform === 'linux' ? 'sudo systemctl start docker' : 'start Docker Desktop and wait until it reports the engine running' });
|
|
88
|
+
else if (kind !== 'linux')
|
|
89
|
+
rows.push({ id: 'docker', ok: false, detail: `docker serves ${kind} containers`, fix: 'switch Docker Desktop to Linux containers' });
|
|
90
|
+
else
|
|
91
|
+
rows.push({ id: 'docker', ok: true, detail: `docker engine ${server} (linux containers)`, fix: null });
|
|
92
|
+
const compose = ctx.spawn('docker', ['compose', 'version', '--short']);
|
|
93
|
+
const have = versionOf(compose.stdout);
|
|
94
|
+
if (!present(compose) || !have)
|
|
95
|
+
rows.push({ id: 'compose', ok: false, detail: 'docker compose v2 not found', fix: INSTALL.compose?.[platform] ?? null });
|
|
96
|
+
else if (!atLeast(have, '2.20'))
|
|
97
|
+
rows.push({ id: 'compose', ok: false, detail: `docker compose ${have} found, 2.20 or newer wanted`, fix: INSTALL.compose?.[platform] ?? null });
|
|
98
|
+
else
|
|
99
|
+
rows.push({ id: 'compose', ok: true, detail: `docker compose ${have}`, fix: null });
|
|
100
|
+
return rows;
|
|
101
|
+
}
|
|
102
|
+
/** True when the environment's secrets folder holds a sealed member. */
|
|
103
|
+
export const hasSealedMembers = (secretsDir) => {
|
|
104
|
+
try {
|
|
105
|
+
return fs.readdirSync(secretsDir).some((name) => /\.enc(\.yaml)?$/u.test(name));
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
/** One row per declared port: free, or held by this environment's own record, or held by someone else (named). */
|
|
112
|
+
export async function portRows(ctx, local, stateDir, deps = {}) {
|
|
113
|
+
const free = deps.portFree ?? bindFree;
|
|
114
|
+
const own = new Set(Object.values(readRecord(stateDir).ports));
|
|
115
|
+
const rows = [];
|
|
116
|
+
for (const [name, port] of Object.entries(local.ports)) {
|
|
117
|
+
if (await free(port))
|
|
118
|
+
rows.push({ id: `port ${name}`, ok: true, detail: `${port} is free`, fix: null });
|
|
119
|
+
else if (own.has(port))
|
|
120
|
+
rows.push({ id: `port ${name}`, ok: true, detail: `${port} is held by this environment`, fix: null });
|
|
121
|
+
else if (holderOf(ctx, port).startsWith(`container ${local.project}-`))
|
|
122
|
+
rows.push({ id: `port ${name}`, ok: true, detail: `${port} is held by this environment's container`, fix: null });
|
|
123
|
+
else
|
|
124
|
+
rows.push({ id: `port ${name}`, ok: false, detail: `${port} is held by ${holderOf(ctx, port)}`, fix: `stop that holder, or change ports.${name} in .starcistacks/application-stacks.yaml` });
|
|
125
|
+
}
|
|
126
|
+
return rows;
|
|
127
|
+
}
|
|
128
|
+
function nodeRow(ctx, repoRoot) {
|
|
129
|
+
const want = wantedNode(repoRoot);
|
|
130
|
+
const node = ctx.spawn('node', ['--version']);
|
|
131
|
+
const have = versionOf(node.stdout);
|
|
132
|
+
if (present(node) && have && (!want || atLeast(have, want)))
|
|
133
|
+
return { id: 'node', ok: true, detail: `node ${have}${want ? ` (the app wants ${want} or newer)` : ''}`, fix: null };
|
|
134
|
+
return { id: 'node', ok: false, detail: present(node) ? `node ${have} found, ${want} or newer wanted by .nvmrc or engines.node` : 'node not found on PATH', fix: INSTALL.node?.[platformOf(ctx)] ?? null };
|
|
135
|
+
}
|
|
136
|
+
function resourceRows(local, stateDir, deps) {
|
|
137
|
+
const disk = (deps.diskFreeGb ?? diskFree)(stateDir);
|
|
138
|
+
const memory = (deps.memoryMb ?? (() => ({ free: Math.floor(os.freemem() / 1024 ** 2), total: Math.floor(os.totalmem() / 1024 ** 2) })))();
|
|
139
|
+
return [
|
|
140
|
+
disk === null || disk >= local.resources.diskGb
|
|
141
|
+
? { id: 'disk', ok: true, detail: disk === null ? 'free disk not readable here' : `${disk} GB free (${local.resources.diskGb} GB wanted)`, fix: null }
|
|
142
|
+
: { id: 'disk', ok: false, detail: `${disk} GB free, ${local.resources.diskGb} GB wanted`, fix: 'free disk space, or prune unused images with docker image prune (an owner step)' },
|
|
143
|
+
memory.total >= local.resources.ramMb
|
|
144
|
+
? { id: 'memory', ok: true, detail: `${memory.total} MB RAM (${memory.free} MB free, ${local.resources.ramMb} MB wanted)`, fix: null }
|
|
145
|
+
: { id: 'memory', ok: false, detail: `${memory.total} MB RAM, ${local.resources.ramMb} MB wanted`, fix: 'close other work or use a machine with more memory' },
|
|
146
|
+
];
|
|
147
|
+
}
|
|
148
|
+
/** The whole table for one environment: tools, engine, resources, then the declared ports. */
|
|
149
|
+
export async function hostPrerequisites(ctx, local, where, deps = {}) {
|
|
150
|
+
const rows = [nodeRow(ctx, where.repoRoot), tool(ctx, 'npm', { command: 'npm', args: ['--version'], label: 'npm' }), tool(ctx, 'git', { command: 'git', args: ['--version'], label: 'git' }), ...docker(ctx)];
|
|
151
|
+
if (ctx.platform === 'win32') {
|
|
152
|
+
const wsl = ctx.spawn('wsl.exe', ['--status']);
|
|
153
|
+
rows.push(present(wsl) ? { id: 'wsl', ok: true, detail: 'WSL2 is installed', fix: null } : { id: 'wsl', ok: false, detail: 'WSL2 is not available (Docker Desktop runs Linux containers on it)', fix: INSTALL.wsl?.win32 ?? null });
|
|
154
|
+
}
|
|
155
|
+
if (hasSealedMembers(where.secretsDir))
|
|
156
|
+
rows.push(tool(ctx, 'sops', { command: 'sops', args: ['--version'], label: 'sops' }), tool(ctx, 'age', { command: 'age', args: ['--version'], label: 'age' }));
|
|
157
|
+
rows.push(...resourceRows(local, where.stateDir, deps), ...await portRows(ctx, local, where.stateDir, deps));
|
|
158
|
+
return rows;
|
|
159
|
+
}
|
|
160
|
+
/** The table as printed lines: `ok ` and `FAIL ` rows, each failure followed by its owner-run step. */
|
|
161
|
+
export function tableLines(rows) {
|
|
162
|
+
return rows.flatMap((row) => [`${row.ok ? 'ok ' : 'FAIL '}${row.id}: ${row.detail}`, ...(row.ok || !row.fix ? [] : [` owner step: ${row.fix}`])]);
|
|
163
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { StackContext } from './context.mjs';
|
|
2
|
+
import type { ActionResult } from './remote.mjs';
|
|
3
|
+
import type { LocalConfig } from './local-config.mjs';
|
|
4
|
+
/** `logs` for a dev environment. */
|
|
5
|
+
export declare function logsLocal(ctx: StackContext, local: LocalConfig, { service, lines }?: {
|
|
6
|
+
service?: string | undefined;
|
|
7
|
+
lines?: number;
|
|
8
|
+
}): Promise<ActionResult>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// local-logs.mjs - `starci app stack logs --env <dev env> --service <name> [--lines N]` (PROJECT-STACKS-6): one named process or compose service,
|
|
2
|
+
// bounded in lines, redacted; never the environment or the credentials.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { lines as splitLines } from './context.mjs';
|
|
5
|
+
import { refusal } from './errors.mjs';
|
|
6
|
+
import { compose, redact, stateOf } from './local-run.mjs';
|
|
7
|
+
import { logFile } from './local-state.mjs';
|
|
8
|
+
const DEFAULT_LINES = 200;
|
|
9
|
+
const MAX_LINES = 2000;
|
|
10
|
+
/** `logs` for a dev environment. */
|
|
11
|
+
export async function logsLocal(ctx, local, { service, lines = DEFAULT_LINES } = {}) {
|
|
12
|
+
if (!service)
|
|
13
|
+
throw refusal('STACK_LOCAL_LOGS_SERVICE', `name one process or service with --service: ${[...local.processes.map((run) => run.name), ...local.services.map((item) => item.name)].join(', ')}`);
|
|
14
|
+
if (!Number.isInteger(lines) || lines < 1 || lines > MAX_LINES)
|
|
15
|
+
throw refusal('STACK_LOCAL_LOGS_LINES', `--lines is a whole number from 1 to ${MAX_LINES}`);
|
|
16
|
+
let text;
|
|
17
|
+
if (local.processes.some((run) => run.name === service)) {
|
|
18
|
+
try {
|
|
19
|
+
text = fs.readFileSync(logFile(stateOf(ctx, local), service), 'utf8');
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
text = '';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
else if (local.services.some((item) => item.name === service)) {
|
|
26
|
+
text = (await compose(ctx, local, ['logs', '--no-color', '--tail', String(lines), service])).stdout;
|
|
27
|
+
}
|
|
28
|
+
else
|
|
29
|
+
throw refusal('STACK_LOCAL_LOGS_SERVICE', `${service} is neither a process nor a service of ${local.env}`);
|
|
30
|
+
const tail = splitLines(text).slice(-lines).map(redact);
|
|
31
|
+
return { ok: true, lines: tail.length ? tail : [`${service}: no log lines yet`], data: { service, count: tail.length } };
|
|
32
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { StackContext } from './context.mjs';
|
|
2
|
+
/** True when nothing listens on the loopback `port` (a bind attempt, so a wildcard listener counts too). */
|
|
3
|
+
export declare const bindFree: (port: number) => Promise<boolean>;
|
|
4
|
+
/** True when something accepts a TCP connection on the loopback `port`. */
|
|
5
|
+
export declare const tcpOpen: (port: number) => Promise<boolean>;
|
|
6
|
+
/** What holds `port`: a docker container (by published port), else the process (netstat/tasklist on Windows, lsof elsewhere), else "an unknown process". */
|
|
7
|
+
export declare function holderOf(ctx: Pick<StackContext, 'spawn' | 'platform'>, port: number): string;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// local-ports.mjs - free-port checks of a dev environment and the name of whatever holds a port that is not free.
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
import { firstLine, lines as splitLines } from './context.mjs';
|
|
4
|
+
/** True when nothing listens on the loopback `port` (a bind attempt, so a wildcard listener counts too). */
|
|
5
|
+
export const bindFree = (port) => new Promise((resolve) => {
|
|
6
|
+
const server = net.createServer();
|
|
7
|
+
server.once('error', () => resolve(false));
|
|
8
|
+
server.listen(port, '127.0.0.1', () => server.close(() => resolve(true)));
|
|
9
|
+
});
|
|
10
|
+
/** True when something accepts a TCP connection on the loopback `port`. */
|
|
11
|
+
export const tcpOpen = (port) => new Promise((resolve) => {
|
|
12
|
+
const socket = net.connect({ port, host: '127.0.0.1' });
|
|
13
|
+
socket.setTimeout(1000);
|
|
14
|
+
socket.once('connect', () => { socket.destroy(); resolve(true); });
|
|
15
|
+
socket.once('timeout', () => { socket.destroy(); resolve(false); });
|
|
16
|
+
socket.once('error', () => resolve(false));
|
|
17
|
+
});
|
|
18
|
+
/** What holds `port`: a docker container (by published port), else the process (netstat/tasklist on Windows, lsof elsewhere), else "an unknown process". */
|
|
19
|
+
export function holderOf(ctx, port) {
|
|
20
|
+
const container = ctx.spawn('docker', ['ps', '--filter', `publish=${port}`, '--format', '{{.Names}}']);
|
|
21
|
+
const name = firstLine(container.stdout);
|
|
22
|
+
if (container.status === 0 && name)
|
|
23
|
+
return `container ${name}`;
|
|
24
|
+
if (ctx.platform === 'win32') {
|
|
25
|
+
const table = ctx.spawn('netstat', ['-ano', '-p', 'tcp']);
|
|
26
|
+
const row = splitLines(table.stdout).map((line) => line.trim().split(/\s+/u)).find((cells) => cells[3] === 'LISTENING' && cells[1]?.endsWith(`:${port}`));
|
|
27
|
+
const pid = row?.[4];
|
|
28
|
+
if (pid) {
|
|
29
|
+
const task = ctx.spawn('tasklist', ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH']);
|
|
30
|
+
const image = /^"([^"]+)"/u.exec(firstLine(task.stdout))?.[1];
|
|
31
|
+
return image ? `process ${image} (pid ${pid})` : `process pid ${pid}`;
|
|
32
|
+
}
|
|
33
|
+
return 'an unknown process';
|
|
34
|
+
}
|
|
35
|
+
const listing = ctx.spawn('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-Fpc']);
|
|
36
|
+
const pid = /^p(\d+)/mu.exec(listing.stdout)?.[1];
|
|
37
|
+
const command = /^c(.+)$/mu.exec(listing.stdout)?.[1];
|
|
38
|
+
return pid ? `process ${command ?? 'unknown'} (pid ${pid})` : 'an unknown process';
|
|
39
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { StackContext } from './context.mjs';
|
|
2
|
+
import type { ActionResult } from './remote.mjs';
|
|
3
|
+
import type { LocalConfig } from './local-config.mjs';
|
|
4
|
+
import type { Custody } from './local-custody.mjs';
|
|
5
|
+
import type { Prerequisite } from './local-host.mjs';
|
|
6
|
+
import type { LocalDeps } from './local-run.mjs';
|
|
7
|
+
/** The prerequisite table of the environment as rows and printed lines. */
|
|
8
|
+
export declare function preflight(ctx: StackContext, local: LocalConfig, deps: LocalDeps): Promise<{
|
|
9
|
+
rows: Prerequisite[];
|
|
10
|
+
lines: string[];
|
|
11
|
+
ok: boolean;
|
|
12
|
+
}>;
|
|
13
|
+
/** The refusal result of a failed preflight: the table with its owner steps, then the code. */
|
|
14
|
+
export declare const hostRefusal: (lines: string[]) => ActionResult;
|
|
15
|
+
/** Runs the setup commands (install, codegen); a command with `skipIfExists` that finds its marker is skipped. */
|
|
16
|
+
export declare function runSetup(ctx: StackContext, local: LocalConfig, custody: Custody | null): Promise<string[]>;
|
|
17
|
+
/** Builds the images that have a build context and pulls the rest. */
|
|
18
|
+
export declare function prepareImages(ctx: StackContext, local: LocalConfig, custody: Custody | null): Promise<string[]>;
|
|
19
|
+
/** `prepare` for a dev environment. */
|
|
20
|
+
export declare function prepareLocal(ctx: StackContext, local: LocalConfig, { execute }?: {
|
|
21
|
+
execute?: boolean;
|
|
22
|
+
}, deps?: LocalDeps): Promise<ActionResult>;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// local-prepare.mjs - `starci app stack prepare --env <dev env> [--execute]` (PROJECT-STACKS-6): checks the host, opens the dev custody, runs the
|
|
2
|
+
// declared setup commands (install, codegen) and builds or pulls the compose images. A dry run reports the table and the plan and changes nothing.
|
|
3
|
+
// It refuses on a missing prerequisite, a held port, a missing sealed value or an identity this machine lacks, and never generates a replacement.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { StackError } from './errors.mjs';
|
|
7
|
+
import { missingKeys, missingStep, neededKeys, openCustody } from './local-custody.mjs';
|
|
8
|
+
import { hostPrerequisites, tableLines } from './local-host.mjs';
|
|
9
|
+
import { commandDigest, compose, composeValues, runCommand, runEnvironment, stateOf } from './local-run.mjs';
|
|
10
|
+
import { readRecord, writeRecord } from './local-state.mjs';
|
|
11
|
+
/** The prerequisite table of the environment as rows and printed lines. */
|
|
12
|
+
export async function preflight(ctx, local, deps) {
|
|
13
|
+
const rows = await hostPrerequisites(ctx, local, { repoRoot: local.repoRoot, stateDir: stateOf(ctx, local), secretsDir: local.secretsDir }, deps);
|
|
14
|
+
return { rows, lines: tableLines(rows), ok: rows.every((row) => row.ok) };
|
|
15
|
+
}
|
|
16
|
+
/** The refusal result of a failed preflight: the table with its owner steps, then the code. */
|
|
17
|
+
export const hostRefusal = (lines) => ({ ok: false, lines, refused: { code: 'STACK_LOCAL_HOST', message: 'this machine is missing what the environment needs; run the owner steps above, then retry' } });
|
|
18
|
+
/** Runs the setup commands (install, codegen); a command with `skipIfExists` that finds its marker is skipped. */
|
|
19
|
+
export async function runSetup(ctx, local, custody) {
|
|
20
|
+
const out = [];
|
|
21
|
+
const dir = stateOf(ctx, local);
|
|
22
|
+
const record = readRecord(dir);
|
|
23
|
+
for (const run of local.setup) {
|
|
24
|
+
if (run.skipIfExists && fs.existsSync(path.join(local.repoRoot, run.cwd, run.skipIfExists))) {
|
|
25
|
+
out.push(`setup ${run.name}: skipped (${run.skipIfExists} exists)`);
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
await runCommand(ctx, local, run, runEnvironment(ctx, local, run, custody, null));
|
|
29
|
+
record.receipts.setup[run.name] = { at: new Date(ctx.now()).toISOString(), digest: commandDigest(run) };
|
|
30
|
+
out.push(`setup ${run.name}: done`);
|
|
31
|
+
}
|
|
32
|
+
writeRecord(dir, record);
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
/** Builds the images that have a build context and pulls the rest. */
|
|
36
|
+
export async function prepareImages(ctx, local, custody) {
|
|
37
|
+
const values = composeValues(local, custody);
|
|
38
|
+
const build = await compose(ctx, local, ['build'], { values, heavy: true, label: 'compose build' });
|
|
39
|
+
const pull = await compose(ctx, local, ['pull', '--ignore-buildable', '--quiet'], { values, heavy: true, label: 'compose pull' });
|
|
40
|
+
const lines = [build.status === 0 ? 'images: built' : `images: build failed (${(build.stderr.trim().split(/\r?\n/u)[0] ?? '').slice(0, 160)})`];
|
|
41
|
+
lines.push(pull.status === 0 ? 'images: pulled' : `images: pull failed (${(pull.stderr.trim().split(/\r?\n/u)[0] ?? '').slice(0, 160)})`);
|
|
42
|
+
if (build.status !== 0 || pull.status !== 0)
|
|
43
|
+
throw new StackError('STACK_LOCAL_IMAGES', lines.join('; '), 1);
|
|
44
|
+
return lines;
|
|
45
|
+
}
|
|
46
|
+
/** `prepare` for a dev environment. */
|
|
47
|
+
export async function prepareLocal(ctx, local, { execute = false } = {}, deps = {}) {
|
|
48
|
+
const host = await preflight(ctx, local, deps);
|
|
49
|
+
if (!host.ok)
|
|
50
|
+
return hostRefusal(host.lines);
|
|
51
|
+
const lines = [...host.lines];
|
|
52
|
+
const missing = missingKeys(local);
|
|
53
|
+
lines.push(`custody: ${neededKeys(local).length} key(s) read from ${local.env}${missing.length ? `; missing ${missing.join(', ')} (${missingStep(local, missing)})` : ', all sealed'}`);
|
|
54
|
+
if (!execute) {
|
|
55
|
+
lines.push(...local.setup.map((run) => `plan setup ${run.name}: ${run.argv.join(' ')}`), 'plan images: docker compose build and pull', 'dry run: nothing was changed; arm it with --execute');
|
|
56
|
+
return { ok: missing.length === 0, lines, data: { rows: host.rows } };
|
|
57
|
+
}
|
|
58
|
+
const custody = openCustody(ctx, local);
|
|
59
|
+
lines.push(`custody: ${custody.values.size} key file(s) written to the host-local directory`);
|
|
60
|
+
lines.push(...await runSetup(ctx, local, custody), ...await prepareImages(ctx, local, custody));
|
|
61
|
+
return { ok: true, lines, data: { rows: host.rows } };
|
|
62
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { RunResult, StackContext } from './context.mjs';
|
|
2
|
+
import type { LocalConfig } from './local-config.mjs';
|
|
3
|
+
import type { Custody } from './local-custody.mjs';
|
|
4
|
+
import { hostTool } from './local-host.mjs';
|
|
5
|
+
import type { HostDeps } from './local-host.mjs';
|
|
6
|
+
import type { HttpProbe, LocalCommand } from '../runtime/scripts/hfs/stack-local-config.mjs';
|
|
7
|
+
/** The seams a spec replaces besides the context's own. */
|
|
8
|
+
export interface LocalDeps extends HostDeps {
|
|
9
|
+
alive?: ((pid: number) => boolean) | undefined;
|
|
10
|
+
kill?: ((pid: number) => void) | undefined;
|
|
11
|
+
tcpOpen?: ((port: number) => Promise<boolean>) | undefined;
|
|
12
|
+
migrate?: ((urlEnv: string, env: NodeJS.ProcessEnv, cwd: string) => Promise<number>) | undefined;
|
|
13
|
+
}
|
|
14
|
+
export declare const DEFAULT_TIMEOUT_SECONDS = 180;
|
|
15
|
+
export declare const stateOf: (ctx: StackContext, local: LocalConfig) => string;
|
|
16
|
+
/** The data directory of the environment under its state directory (`${data}` in a declared value, `STACK_DATA_DIR` in compose). */
|
|
17
|
+
export declare const dataOf: (ctx: StackContext, local: LocalConfig) => string;
|
|
18
|
+
/** Expands `${port.<name>}` and `${data}` in a declared value. */
|
|
19
|
+
export declare const expand: (ctx: StackContext, local: LocalConfig, value: string) => string;
|
|
20
|
+
/** Creates the declared data directories. */
|
|
21
|
+
export declare function makeDataDirectories(ctx: StackContext, local: LocalConfig): void;
|
|
22
|
+
/** `STACK_PORT_<NAME>` for every declared port: the names compose files interpolate (`127.0.0.1:${STACK_PORT_DB}:5432`). */
|
|
23
|
+
export declare const portEnvironment: (ports: Readonly<Record<string, number>>) => Record<string, string>;
|
|
24
|
+
/** `STACK_GRANT_<NAME>` for every least grant: the private directory a compose service mounts to read only its own keys. */
|
|
25
|
+
export declare function grantEnvironment(ctx: StackContext, local: LocalConfig): Record<string, string>;
|
|
26
|
+
/** The docker compose argv of the environment's project and files. */
|
|
27
|
+
export declare const composeArgs: (local: LocalConfig, args: readonly string[]) => string[];
|
|
28
|
+
/** Runs one docker compose command; `values` are the compose-interpolated custody values, passed in memory only. */
|
|
29
|
+
export declare function compose(ctx: StackContext, local: LocalConfig, args: readonly string[], { values, heavy, label, input }?: {
|
|
30
|
+
values?: Record<string, string>;
|
|
31
|
+
heavy?: boolean;
|
|
32
|
+
label?: string;
|
|
33
|
+
input?: string;
|
|
34
|
+
}): Promise<RunResult>;
|
|
35
|
+
/** The compose-interpolated values of the environment (the keys `composeEnv` maps), from the opened custody. */
|
|
36
|
+
export declare function composeValues(local: LocalConfig, custody: Custody | null): Record<string, string>;
|
|
37
|
+
/** The environment a command or process runs with: ports expanded, custody as `<KEY>_FILE`, the database URL as the environment declares. */
|
|
38
|
+
export declare function runEnvironment(ctx: StackContext, local: LocalConfig, run: LocalCommand, custody: Custody | null, role: 'owner' | 'app' | null): NodeJS.ProcessEnv;
|
|
39
|
+
/** The loopback connection string of the environment's own database role, built in memory from custody. */
|
|
40
|
+
export declare function databaseUrl(local: LocalConfig, custody: Custody, role?: 'owner' | 'app'): string | null;
|
|
41
|
+
/** Runs one setup or seed command to its end, through the governor when its class is heavy. A non-zero exit is a failure naming the command and its first error line. */
|
|
42
|
+
export declare function runCommand(ctx: StackContext, local: LocalConfig, run: LocalCommand, env: NodeJS.ProcessEnv): Promise<void>;
|
|
43
|
+
/** The digest of a command's argv and cwd: the key of its receipt. */
|
|
44
|
+
export declare const commandDigest: (run: LocalCommand) => string;
|
|
45
|
+
/** True when `pid` is alive. */
|
|
46
|
+
export declare const isAlive: (pid: number) => boolean;
|
|
47
|
+
/** Starts a native process detached from this command, its output in the environment's log. Returns the pid. */
|
|
48
|
+
export declare function startProcess(ctx: StackContext, local: LocalConfig, run: LocalCommand, env: NodeJS.ProcessEnv): number;
|
|
49
|
+
/** Ends a native process and its children. */
|
|
50
|
+
export declare function stopProcess(ctx: Pick<StackContext, 'spawn' | 'platform'>, pid: number, deps?: LocalDeps): void;
|
|
51
|
+
/** One HTTP probe against its expanded URL; the verdict and a detail line. */
|
|
52
|
+
export declare function probe(ctx: StackContext, local: LocalConfig, row: HttpProbe): Promise<{
|
|
53
|
+
ok: boolean;
|
|
54
|
+
detail: string;
|
|
55
|
+
}>;
|
|
56
|
+
/** Polls a probe until it passes or the deadline passes. */
|
|
57
|
+
export declare function waitFor(ctx: StackContext, deadline: number, attempt: () => Promise<{
|
|
58
|
+
ok: boolean;
|
|
59
|
+
detail: string;
|
|
60
|
+
}>): Promise<{
|
|
61
|
+
ok: boolean;
|
|
62
|
+
detail: string;
|
|
63
|
+
}>;
|
|
64
|
+
/** The per-service readiness the environment declares: compose health, an open port, or just started. */
|
|
65
|
+
export declare function serviceReady(ctx: StackContext, local: LocalConfig, name: string, deadline: number, deps?: LocalDeps): Promise<{
|
|
66
|
+
ok: boolean;
|
|
67
|
+
detail: string;
|
|
68
|
+
}>;
|
|
69
|
+
/** Masks the value of anything that reads like a credential assignment, so a log tail never carries one. */
|
|
70
|
+
export declare const redact: (line: string) => string;
|
|
71
|
+
export { hostTool };
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// local-run.mjs - what the dev verbs share: the compose command line of an environment, governed heavy runs, native process control,
|
|
2
|
+
// HTTP probes and the redaction of logs. Nothing here prints a value; every command is an argv list run without a shell.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { firstLine } from './context.mjs';
|
|
7
|
+
import { materializedDirectory } from '../runtime/scripts/hfs/secret-files.mjs';
|
|
8
|
+
import { failure, refusal } from './errors.mjs';
|
|
9
|
+
import { fileEnvironment, grantDirectory } from './local-custody.mjs';
|
|
10
|
+
import { hostTool, launch } from './local-host.mjs';
|
|
11
|
+
import { tcpOpen } from './local-ports.mjs';
|
|
12
|
+
import { logFile, stateDirectory } from './local-state.mjs';
|
|
13
|
+
import { expandPorts } from '../runtime/scripts/hfs/stack-local-config.mjs';
|
|
14
|
+
export const DEFAULT_TIMEOUT_SECONDS = 180;
|
|
15
|
+
export const stateOf = (ctx, local) => stateDirectory(ctx.env, local.project, local.env);
|
|
16
|
+
/** The data directory of the environment under its state directory (`${data}` in a declared value, `STACK_DATA_DIR` in compose). */
|
|
17
|
+
export const dataOf = (ctx, local) => path.join(stateOf(ctx, local), 'data');
|
|
18
|
+
/** Expands `${port.<name>}` and `${data}` in a declared value. */
|
|
19
|
+
export const expand = (ctx, local, value) => expandPorts(value, local.ports).replaceAll('${data}', dataOf(ctx, local).replaceAll('\\', '/'));
|
|
20
|
+
/** Creates the declared data directories. */
|
|
21
|
+
export function makeDataDirectories(ctx, local) {
|
|
22
|
+
for (const name of local.dirs)
|
|
23
|
+
fs.mkdirSync(path.join(dataOf(ctx, local), name), { recursive: true });
|
|
24
|
+
}
|
|
25
|
+
/** `STACK_PORT_<NAME>` for every declared port: the names compose files interpolate (`127.0.0.1:${STACK_PORT_DB}:5432`). */
|
|
26
|
+
export const portEnvironment = (ports) => Object.fromEntries(Object.entries(ports).map(([name, port]) => [`STACK_PORT_${name.toUpperCase().replaceAll('-', '_')}`, String(port)]));
|
|
27
|
+
/** `STACK_GRANT_<NAME>` for every least grant: the private directory a compose service mounts to read only its own keys. */
|
|
28
|
+
export function grantEnvironment(ctx, local) {
|
|
29
|
+
if (local.grants.length === 0)
|
|
30
|
+
return {};
|
|
31
|
+
const directory = materializedDirectory(local.repoRoot, local.env, ctx.env, ctx.platform);
|
|
32
|
+
return Object.fromEntries(local.grants.map((grant) => [`STACK_GRANT_${grant.name.toUpperCase().replaceAll('-', '_')}`, grantDirectory(directory, grant.name).replaceAll('\\', '/')]));
|
|
33
|
+
}
|
|
34
|
+
/** The docker compose argv of the environment's project and files. */
|
|
35
|
+
export const composeArgs = (local, args) => ['compose', '-p', local.project, ...local.composeFiles.flatMap((file) => ['-f', file]), ...args];
|
|
36
|
+
/** Runs one docker compose command; `values` are the compose-interpolated custody values, passed in memory only. */
|
|
37
|
+
export async function compose(ctx, local, args, { values = {}, heavy = false, label = `compose ${args[0]}`, input } = {}) {
|
|
38
|
+
const bare = local.composeEnv.filter((item) => !Object.hasOwn(values, item.name)).map((item) => item.name);
|
|
39
|
+
// A command that creates or enters a container needs the real values; the others only interpolate the file, where a placeholder does.
|
|
40
|
+
if (bare.length && ['up', 'exec', 'run', 'create', 'start', 'restart'].includes(String(args[0])))
|
|
41
|
+
throw refusal('STACK_LOCAL_COMPOSE_VALUES', `docker compose ${String(args[0])} needs the custody values of ${bare.join(', ')}`);
|
|
42
|
+
const env = { ...ctx.env, ...portEnvironment(local.ports), ...grantEnvironment(ctx, local), STACK_DATA_DIR: dataOf(ctx, local).replaceAll('\\', '/'), ...Object.fromEntries(bare.map((name) => [name, 'placeholder'])), ...values };
|
|
43
|
+
const argv = composeArgs(local, args);
|
|
44
|
+
if (heavy && ctx.heavy)
|
|
45
|
+
return ctx.heavy({ kind: 'build', label: `${local.project} ${label}`, cwd: local.repoRoot, argv: ['docker', ...argv], env });
|
|
46
|
+
return ctx.spawn('docker', argv, { cwd: local.repoRoot, env, ...(input === undefined ? {} : { input }) });
|
|
47
|
+
}
|
|
48
|
+
/** The compose-interpolated values of the environment (the keys `composeEnv` maps), from the opened custody. */
|
|
49
|
+
export function composeValues(local, custody) {
|
|
50
|
+
const values = {};
|
|
51
|
+
for (const item of local.composeEnv) {
|
|
52
|
+
const value = custody?.values.get(item.key);
|
|
53
|
+
if (value !== undefined)
|
|
54
|
+
values[item.name] = value;
|
|
55
|
+
}
|
|
56
|
+
return values;
|
|
57
|
+
}
|
|
58
|
+
/** The environment a command or process runs with: ports expanded, custody as `<KEY>_FILE`, the database URL as the environment declares. */
|
|
59
|
+
export function runEnvironment(ctx, local, run, custody, role) {
|
|
60
|
+
const env = { ...ctx.env, ...portEnvironment(local.ports) };
|
|
61
|
+
for (const [key, value] of Object.entries(run.env))
|
|
62
|
+
env[key] = expand(ctx, local, value);
|
|
63
|
+
if (custody)
|
|
64
|
+
Object.assign(env, fileEnvironment(custody, run.files));
|
|
65
|
+
const url = custody && role ? databaseUrl(local, custody, role) : null;
|
|
66
|
+
if (local.database && url !== null && custody) {
|
|
67
|
+
if (local.database.urlAs === 'env')
|
|
68
|
+
env[local.database.urlEnv] = url;
|
|
69
|
+
else {
|
|
70
|
+
const file = path.join(custody.directory, local.database.urlEnv);
|
|
71
|
+
fs.writeFileSync(file, url, { mode: 0o600 });
|
|
72
|
+
env[`${local.database.urlEnv}_FILE`] = file;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return env;
|
|
76
|
+
}
|
|
77
|
+
/** The loopback connection string of the environment's own database role, built in memory from custody. */
|
|
78
|
+
export function databaseUrl(local, custody, role = 'owner') {
|
|
79
|
+
const database = local.database;
|
|
80
|
+
if (!database)
|
|
81
|
+
return null;
|
|
82
|
+
const app = role === 'app' && database.appRole !== null && database.appPassword !== null;
|
|
83
|
+
const user = app ? database.appRole : database.role;
|
|
84
|
+
const password = custody.values.get(app ? database.appPassword.key : database.password.key);
|
|
85
|
+
if (!password)
|
|
86
|
+
return null;
|
|
87
|
+
return `postgresql://${user}:${encodeURIComponent(password)}@127.0.0.1:${local.ports[database.port]}/${database.name}`;
|
|
88
|
+
}
|
|
89
|
+
const launcher = (ctx, argv) => launch(ctx.platform, argv[0] ?? '', argv.slice(1));
|
|
90
|
+
/** Runs one setup or seed command to its end, through the governor when its class is heavy. A non-zero exit is a failure naming the command and its first error line. */
|
|
91
|
+
export async function runCommand(ctx, local, run, env) {
|
|
92
|
+
const cwd = path.join(local.repoRoot, run.cwd);
|
|
93
|
+
const [command, args] = launcher(ctx, run.argv.map((part) => expand(ctx, local, part)));
|
|
94
|
+
const result = run.heavy !== 'none' && ctx.heavy
|
|
95
|
+
? await ctx.heavy({ kind: run.heavy, label: `${local.project} ${run.name}`, cwd, argv: [command, ...args], env })
|
|
96
|
+
: ctx.spawn(command, args, { cwd, env });
|
|
97
|
+
if (result.error || result.status !== 0)
|
|
98
|
+
throw failure('STACK_LOCAL_COMMAND', `${run.name} (${run.argv.join(' ')}) failed: ${firstLine(result.stderr) || firstLine(result.stdout) || result.error?.message || `exit ${result.status}`}`);
|
|
99
|
+
}
|
|
100
|
+
/** The digest of a command's argv and cwd: the key of its receipt. */
|
|
101
|
+
export const commandDigest = (run) => createHash('sha256').update(JSON.stringify([run.argv, run.cwd, run.env])).digest('hex').slice(0, 16);
|
|
102
|
+
/** True when `pid` is alive. */
|
|
103
|
+
export const isAlive = (pid) => {
|
|
104
|
+
try {
|
|
105
|
+
process.kill(pid, 0);
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
return error.code === 'EPERM';
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
/** Starts a native process detached from this command, its output in the environment's log. Returns the pid. */
|
|
113
|
+
export function startProcess(ctx, local, run, env) {
|
|
114
|
+
const dir = stateOf(ctx, local);
|
|
115
|
+
const log = logFile(dir, run.name);
|
|
116
|
+
fs.mkdirSync(path.dirname(log), { recursive: true });
|
|
117
|
+
const descriptor = fs.openSync(log, 'a');
|
|
118
|
+
try {
|
|
119
|
+
const [command, args] = launcher(ctx, run.argv.map((part) => expand(ctx, local, part)));
|
|
120
|
+
const child = ctx.spawn(command, args, { background: true, detached: true, cwd: path.join(local.repoRoot, run.cwd), env, stdio: ['ignore', descriptor, descriptor], windowsHide: true });
|
|
121
|
+
const pid = child.pid;
|
|
122
|
+
if (typeof pid !== 'number')
|
|
123
|
+
throw failure('STACK_LOCAL_COMMAND', `${run.name} did not start: ${child.spawnError?.message ?? 'no process id'}`);
|
|
124
|
+
child.unref?.();
|
|
125
|
+
return pid;
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
fs.closeSync(descriptor);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/** Ends a native process and its children. */
|
|
132
|
+
export function stopProcess(ctx, pid, deps = {}) {
|
|
133
|
+
if (deps.kill) {
|
|
134
|
+
deps.kill(pid);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (ctx.platform === 'win32') {
|
|
138
|
+
ctx.spawn('taskkill', ['/PID', String(pid), '/T', '/F']);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
process.kill(-pid, 'SIGTERM');
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
try {
|
|
146
|
+
process.kill(pid, 'SIGTERM');
|
|
147
|
+
}
|
|
148
|
+
catch { /* already gone */ }
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/** One HTTP probe against its expanded URL; the verdict and a detail line. */
|
|
152
|
+
export async function probe(ctx, local, row) {
|
|
153
|
+
const url = expand(ctx, local, row.url);
|
|
154
|
+
try {
|
|
155
|
+
const response = await ctx.fetch(url, { method: row.method, redirect: 'manual', signal: AbortSignal.timeout(5000) });
|
|
156
|
+
if (response.status !== row.status)
|
|
157
|
+
return { ok: false, detail: `${row.name}: ${url} answered ${response.status}, ${row.status} expected` };
|
|
158
|
+
if (row.contains !== null && !(await response.text()).includes(row.contains))
|
|
159
|
+
return { ok: false, detail: `${row.name}: ${url} answered without the expected text` };
|
|
160
|
+
return { ok: true, detail: `${row.name}: ${url} answered ${response.status}` };
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
return { ok: false, detail: `${row.name}: ${url} does not answer (${error.message})` };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/** Polls a probe until it passes or the deadline passes. */
|
|
167
|
+
export async function waitFor(ctx, deadline, attempt) {
|
|
168
|
+
let last = await attempt();
|
|
169
|
+
while (!last.ok && ctx.now() < deadline) {
|
|
170
|
+
await ctx.sleep(1000);
|
|
171
|
+
last = await attempt();
|
|
172
|
+
}
|
|
173
|
+
return last;
|
|
174
|
+
}
|
|
175
|
+
/** The per-service readiness the environment declares: compose health, an open port, or just started. */
|
|
176
|
+
export async function serviceReady(ctx, local, name, deadline, deps = {}) {
|
|
177
|
+
const service = local.services.find((item) => item.name === name);
|
|
178
|
+
if (!service)
|
|
179
|
+
return { ok: false, detail: `${name} is not a declared service` };
|
|
180
|
+
return waitFor(ctx, deadline, async () => {
|
|
181
|
+
const ids = await compose(ctx, local, ['ps', '-q', name]);
|
|
182
|
+
const id = firstLine(ids.stdout);
|
|
183
|
+
if (!id)
|
|
184
|
+
return { ok: false, detail: `${name}: no container` };
|
|
185
|
+
if (service.wait === 'tcp')
|
|
186
|
+
return (await (deps.tcpOpen ?? tcpOpen)(local.ports[service.port])) ? { ok: true, detail: `${name}: port ${local.ports[service.port]} accepts` } : { ok: false, detail: `${name}: port ${local.ports[service.port]} does not accept yet` };
|
|
187
|
+
const inspected = ctx.spawn('docker', ['inspect', '-f', '{{.State.Status}} {{if .State.Health}}{{.State.Health.Status}}{{else}}nohealth{{end}}', id]);
|
|
188
|
+
const [state, health] = firstLine(inspected.stdout).split(' ');
|
|
189
|
+
if (state !== 'running')
|
|
190
|
+
return { ok: false, detail: `${name}: ${state ?? 'unknown'}` };
|
|
191
|
+
if (service.wait === 'started')
|
|
192
|
+
return { ok: true, detail: `${name}: running` };
|
|
193
|
+
if (health === 'nohealth')
|
|
194
|
+
throw refusal('STACK_LOCAL_NO_HEALTHCHECK', `${name} declares wait: healthy but its compose service has no healthcheck: add one to the compose file, or declare wait: tcp or started`);
|
|
195
|
+
return health === 'healthy' ? { ok: true, detail: `${name}: healthy` } : { ok: false, detail: `${name}: ${health}` };
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
/** Masks the value of anything that reads like a credential assignment, so a log tail never carries one. */
|
|
199
|
+
export const redact = (line) => line.replaceAll(/((?:pass(?:word)?|secret|token|credential|private[_-]?key|api[_-]?key|authorization)[\w-]*["']?\s*[=:]\s*(?:Bearer\s+)?)[^\s"',;]+/giu, '$1[redacted]');
|
|
200
|
+
export { hostTool };
|