@zaaxch/tailframe 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/new.mjs +429 -52
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zaaxch/tailframe",
3
- "version": "2.0.0",
3
+ "version": "2.2.0",
4
4
  "description": "Tailframe architecture toolkit: validates the Tailframe structure, import-boundary, and file-convention contracts. The package version is the contract version.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/new.mjs CHANGED
@@ -63,6 +63,9 @@ if (fs.existsSync(root) && fs.readdirSync(root).length) fail(`Refusing to overwr
63
63
  const title = options.name.split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join(" ");
64
64
  const svc = `${options.name}-svc`;
65
65
  const ui = `${options.name}-ui`;
66
+ const databaseName = options.name.replaceAll("-", "_");
67
+ const mongoApplicationUser = `${options.name}-app`;
68
+ const mongoBackupUser = `${options.name}-backup`;
66
69
  const files = {};
67
70
  const add = (relative, content) => { files[relative] = content.endsWith("\n") ? content : `${content}\n`; };
68
71
  // Generated repositories depend on the published toolkit rather than copying a validator, and they
@@ -77,9 +80,7 @@ const prettierConfig = {
77
80
  endOfLine: "lf",
78
81
  singleQuote: false
79
82
  };
80
-
81
- add(".prettierrc.json", JSON.stringify(prettierConfig, null, "\t"));
82
- add(".gitattributes", `* text=auto eol=lf
83
+ const gitAttributes = `* text=auto eol=lf
83
84
 
84
85
  *.png binary
85
86
  *.jpg binary
@@ -87,7 +88,10 @@ add(".gitattributes", `* text=auto eol=lf
87
88
  *.gif binary
88
89
  *.ico binary
89
90
  *.pdf binary
90
- `);
91
+ `;
92
+
93
+ add(".prettierrc.json", JSON.stringify(prettierConfig, null, "\t"));
94
+ add(".gitattributes", gitAttributes);
91
95
 
92
96
  const repos = [[svc, "Express/TypeScript API and background runtime."]];
93
97
  if (options.ui) repos.push([ui, "Vue/Vite customer interface."]);
@@ -203,7 +207,7 @@ add(`${svc}/package.json`, JSON.stringify({
203
207
  ...(options.worker ? { "dev:worker": "nodemon --legacy-watch --exec ts-node -r tsconfig-paths/register src/worker.ts" } : {}),
204
208
  build: "tsc && tsc-alias", start: "node dist/server.js",
205
209
  ...(options.worker ? { worker: "node dist/worker.js" } : {}),
206
- test: "npm run test:unit && npm run test:integration", "test:unit": "jest --selectProjects unit --runInBand", "test:integration": "jest --selectProjects integration --runInBand", "validate:architecture": "tailframe validate --kind service .", format: "prettier --write src/",
210
+ test: "npm run test:unit && npm run test:integration", "test:unit": "jest --selectProjects unit --runInBand", "test:integration": "jest --selectProjects integration --runInBand", "validate:architecture": "tailframe validate --kind service .", format: "prettier --write src/", "format:check": "prettier --check src/",
207
211
  "test:db:up": "docker compose -f docker-compose.test.yml up -d", "test:db:down": "docker compose -f docker-compose.test.yml down -v",
208
212
  "docker:dev": "docker compose -f docker-compose.dev.yml up",
209
213
  "docker:prod": "docker compose -f docker-compose.yml up -d",
@@ -220,11 +224,25 @@ module.exports = { projects: [
220
224
  { ...base, displayName: "integration", testMatch: ["**/*.integration.test.ts"] }
221
225
  ] };`);
222
226
  add(`${svc}/.prettierrc.json`, JSON.stringify(prettierConfig, null, "\t"));
223
- add(`${svc}/.gitignore`, `node_modules/\ndist/\n.env*\n!.env.example\n*.log\nfirebase-service-account*.json\n`);
227
+ add(`${svc}/.gitattributes`, gitAttributes);
228
+ add(`${svc}/.gitignore`, `node_modules/\ndist/\n.env*\n!.env.example\n!.env.infrastructure.example\n*.log\nfirebase-service-account*.json\n`);
224
229
  add(`${svc}/.dockerignore`, `node_modules\ndist\n.git\n.env*\n!.env.example\n*.log\ndata\nfirebase-service-account*.json\n`);
225
230
  if (options.ui) add(".dockerignore", `**/node_modules\n**/dist\n**/.git\n**/.env*\n**/*.log\n**/data\n**/firebase-service-account*.json\n`);
226
- const dbEnv = "MONGODB_URI=mongodb://localhost:27017/?replicaSet=rs0&directConnection=true\nMONGODB_DB_NAME=" + options.name.replaceAll("-", "_");
231
+ const dbEnv = "MONGODB_URI=mongodb://localhost:27017/?replicaSet=rs0&directConnection=true\nMONGODB_DB_NAME=" + databaseName;
227
232
  add(`${svc}/.env.example`, `NODE_ENV=development\nPORT=3000\nCORS_ORIGIN=https://localhost:5173\n${dbEnv}\n${options.auth === "firebase" ? "FIREBASE_PROJECT_ID=\n" : ""}${options.redis ? "REDIS_URL=redis://localhost:6379\n" : ""}`);
233
+ add(`${svc}/.env.infrastructure.example`, `ECR_IMAGE=<account>.dkr.ecr.<region>.amazonaws.com/${options.name}
234
+ IMAGE_TAG=<service-sha>${options.ui ? "_<ui-sha>" : ""}
235
+ APP_ENV_FILE=/opt/${options.name}/shared/.env.production
236
+ MONGO_DATA_DIR=/opt/${options.name}/shared/mongo/data
237
+ MONGO_KEYFILE=/opt/${options.name}/shared/mongo/config/keyfile
238
+ MONGO_INIT_SCRIPT=/opt/${options.name}/current/deploy/mongo/10-create-users.js
239
+ MONGODB_ROOT_PASSWORD=<uri-safe-random-value>
240
+ MONGODB_APP_PASSWORD=<different-uri-safe-random-value>
241
+ MONGODB_BACKUP_PASSWORD=<different-uri-safe-random-value>
242
+ ${options.auth === "firebase" ? `SECRETS_DIR=/opt/${options.name}/shared/secrets
243
+ ` : ""}${options.redis ? `REDIS_DATA_DIR=/opt/${options.name}/shared/redis/data
244
+ REDIS_PASSWORD=<different-uri-safe-random-value>
245
+ ` : ""}`);
228
246
  add(`${svc}/src/platform/config/env.ts`, `import "@dotenvx/dotenvx/config";
229
247
  export const env = {
230
248
  nodeEnv: process.env.NODE_ENV ?? "development",
@@ -232,7 +250,7 @@ export const env = {
232
250
  corsOrigin: process.env.CORS_ORIGIN ?? "https://localhost:5173",
233
251
  ${options.auth === "firebase" ? `firebaseProjectId: process.env.FIREBASE_PROJECT_ID ?? "",` : ""}
234
252
  mongodbUri: process.env.MONGODB_URI ?? "mongodb://localhost:27017/?replicaSet=rs0&directConnection=true",
235
- mongodbDbName: process.env.MONGODB_DB_NAME ?? "${options.name.replaceAll("-", "_")}",
253
+ mongodbDbName: process.env.MONGODB_DB_NAME ?? "${databaseName}",
236
254
  ${options.redis ? `redisUrl: process.env.REDIS_URL ?? "redis://localhost:6379",` : ""}
237
255
  };`);
238
256
  const databaseSource = `import { MongoClient } from "mongodb";
@@ -442,17 +460,25 @@ This service uses MongoDB. Module-owned MongoDB adapters own collection access,
442
460
  - Keep optional entry-point assembly under \`src/app/<entry-point-kind>\`; root files such as \`src/server.ts\` and \`src/worker.ts\` may only bootstrap that assembly.
443
461
  - Initialize infrastructure first, then call \`registerDependencies(...)\` from every process entry point. The composition root resets the container, explicitly constructs dependencies, and registers class-token instances. Keep tsyringe, injection tokens, decorators, and container resolution out of modules and platform adapters.
444
462
  - Register routes, workers, and jobs explicitly. Shutdown paths are awaitable and close resources without forcing \`process.exit\`.
463
+ - Scheduled work prevents unintended overlap, stops intake before shutdown, awaits the active run, destroys its scheduler or consumer, and only then closes Redis and database clients.
445
464
  ${options.ui ? "- In production, serve the compiled UI from `dist/public` with a non-API SPA fallback. Never return the SPA for `/api` requests." : ""}
446
465
 
466
+ ## Production image
467
+ The ECR build targets \`linux/amd64\`. ${options.ui ? "Its default immutable tag is `<service-sha>_<ui-sha>` and production Compose requires that exact tag." : "Its default immutable tag is `<service-sha>` and production Compose requires that exact tag."} The runtime image contains production dependencies only and runs as the Node user.${options.ui && options.auth === "firebase" ? ` The build requires \`${ui}/.env.production\` and mounts it as a BuildKit secret only while Vite compiles the browser bundle; it is not copied into the final image.` : ""}
468
+
469
+ ## Production deployment
470
+ Only service \`main\` pushes or manual workflow dispatch deploy; UI pushes never trigger deployment. GitHub Actions may replace files under \`/opt/${options.name}/current\` and MUST NOT replace persistent configuration, credentials, keyfiles, or data under \`/opt/${options.name}/shared\`. Keep Node runtime behavior in the shared service \`.env.production\`, Compose interpolation and infrastructure credentials in \`.env.infrastructure\`, and fixed topology wiring in Compose \`environment:\`. The optional UI build-time \`.env.production\` is a separate ephemeral file. Do not run deployment until the host and GitHub production environment have been bootstrapped according to \`docs/production-deployment.md\`.
471
+
447
472
  ## Tests and validation
448
- Run \`npm run validate:architecture\` after changing files or imports. The generated service also includes unit tests and Mongo-backed integration tests. Run \`npm run test:db:up\`, \`npm test\`, and \`npm run test:db:down\` for the full service check. Test use cases, policies, Mongo repository adapters, and HTTP boundaries where behavior lives. Every public operation needs success and error-envelope coverage; authenticated operations need trusted-identity coverage; persisted capabilities need Mongo ownership/query coverage; UI-enabled services must verify that \`/api\` paths never fall through to the SPA. All AWS/ECR and Docker commands, including \`npm run docker:push\`, must run outside the sandbox from the first attempt.
473
+ Run \`npm run validate:architecture\` and \`npm run format:check\` after changing files or imports. The generated service also includes unit tests and Mongo-backed integration tests. Run \`npm run test:db:up\`, \`npm test\`, and \`npm run test:db:down\` for the full service check. Test use cases, policies, Mongo repository adapters, and HTTP boundaries where behavior lives. Every public operation needs success and error-envelope coverage; authenticated operations need trusted-identity coverage; persisted capabilities need Mongo ownership/query coverage; UI-enabled services must verify that \`/api\` paths never fall through to the SPA. All AWS/ECR and Docker commands, including \`npm run docker:push\`, must run outside the sandbox from the first attempt.
449
474
  `);
450
475
 
451
476
  if (options.ui) {
452
477
  const uiDeps = { "@tailwindcss/vite": "^4.1.18", "@vueuse/core": "^14.3.0", axios: "^1.9.0", pinia: "^3.0.1", primevue: "^4.2.5", vue: "^3.5.13", "vue-router": "^4.5.0", ...(options.auth === "firebase" ? { firebase: "^11.0.0" } : {}) };
453
478
  const uiDevDeps = { "@tsconfig/node22": "^22.0.1", "@zaaxch/tailframe": contractVersion, "@types/node": "^22.13.14", "@vitejs/plugin-vue": "^5.2.3", "@vue/test-utils": "^2.4.10", "@vue/tsconfig": "^0.7.0", jsdom: "^29.1.1", "npm-run-all2": "^7.0.2", prettier: "3.5.3", typescript: "~5.8.0", vite: "^6.2.4", vitest: "^4.1.7", "vue-tsc": "^2.2.8" };
454
- add(`${ui}/package.json`, JSON.stringify({ name: ui, version: "0.1.0", private: true, type: "module", scripts: { dev: "vite --host 0.0.0.0", build: "run-p type-check \"build-only {@}\" --", "build-only": "vite build", "type-check": "vue-tsc --build", test: "vitest", "validate:architecture": "tailframe validate --kind ui .", format: "prettier --write src/" }, dependencies: uiDeps, devDependencies: uiDevDeps }, null, "\t"));
479
+ add(`${ui}/package.json`, JSON.stringify({ name: ui, version: "0.1.0", private: true, type: "module", scripts: { dev: "vite --host 0.0.0.0", build: "run-p type-check \"build-only {@}\" --", "build-only": "vite build", "type-check": "vue-tsc --build", test: "vitest run", "test:watch": "vitest", "validate:architecture": "tailframe validate --kind ui .", format: "prettier --write src/", "format:check": "prettier --check src/" }, dependencies: uiDeps, devDependencies: uiDevDeps }, null, "\t"));
455
480
  add(`${ui}/.prettierrc.json`, JSON.stringify(prettierConfig, null, "\t"));
481
+ add(`${ui}/.gitattributes`, gitAttributes);
456
482
  add(`${ui}/tsconfig.json`, JSON.stringify({ files: [], references: [{ path: "./tsconfig.app.json" }, { path: "./tsconfig.node.json" }] }, null, "\t"));
457
483
  add(`${ui}/tsconfig.app.json`, JSON.stringify({ extends: "@vue/tsconfig/tsconfig.dom.json", include: ["env.d.ts", "src/**/*", "src/**/*.vue"], compilerOptions: { composite: true, baseUrl: ".", paths: { "@/*": ["./src/*"] } } }, null, "\t"));
458
484
  add(`${ui}/tsconfig.node.json`, JSON.stringify({ extends: "@tsconfig/node22/tsconfig.json", include: ["vite.config.*"], compilerOptions: { composite: true, types: ["node"] } }, null, "\t"));
@@ -587,13 +613,13 @@ Reuse Vue, PrimeVue, Tailwind, loading, error, and accessibility conventions. Pr
587
613
  - Keep \`src/app/styles.css\` limited to Tailwind imports, theme tokens, and true global base behavior.
588
614
 
589
615
  ## Production packaging
590
- The UI runs through Vite in development but has no production container. The service's multi-stage Dockerfile builds this repository and copies its output into \`dist/public\`; Express serves it with SPA fallback. AWS ECR stores the combined production image.
616
+ The UI runs through Vite in development but has no production container. The service's multi-stage Dockerfile builds this repository and copies its output into \`dist/public\`; Express serves it with SPA fallback. AWS ECR stores the combined \`linux/amd64\` production image under the default immutable \`<service-sha>_<ui-sha>\` tag, and production Compose requires that exact tag.${options.auth === "firebase" ? ` Provide \`${ui}/.env.production\` before a production build; the service build mounts it as a required BuildKit secret only while Vite compiles the browser bundle and does not copy it into the final image.` : ""}
591
617
 
592
618
  ## Product language
593
619
  The product domain is undefined. Do not invent entities, workflows, roles, claims, navigation, or customer-facing promises.
594
620
 
595
621
  ## Validation
596
- Run \`npm run validate:architecture\`, then add focused Vitest coverage and run type-check, build, and tests as applicable.
622
+ Run \`npm run validate:architecture\` and \`npm run format:check\`, then add focused Vitest coverage and run type-check, build, and \`npm test\` as applicable. Use \`npm run test:watch\` only for interactive development.
597
623
  `);
598
624
  }
599
625
 
@@ -604,20 +630,26 @@ RUN npm ci
604
630
  COPY ${svc}/ ./
605
631
  RUN npm run build
606
632
 
633
+ FROM node:22.13-bookworm AS service-production-dependencies
634
+ WORKDIR /app/service
635
+ COPY ${svc}/package*.json ./
636
+ RUN npm ci --omit=dev && npm cache clean --force
637
+
607
638
  FROM node:22.13-bookworm AS ui-build
608
639
  WORKDIR /app/ui
609
640
  COPY ${ui}/package*.json ./
610
641
  RUN npm ci
611
642
  COPY ${ui}/ ./
612
- RUN npm run build
643
+ ${options.auth === "firebase" ? "RUN --mount=type=secret,id=ui_env,target=/app/ui/.env.production,required=true npm run build" : "RUN npm run build"}
613
644
 
614
645
  FROM node:22.13-bookworm-slim
615
646
  WORKDIR /app
616
647
  ENV NODE_ENV=production
617
648
  COPY --from=service-build /app/service/dist ./dist
618
649
  COPY --from=service-build /app/service/package*.json ./
619
- COPY --from=service-build /app/service/node_modules ./node_modules
650
+ COPY --from=service-production-dependencies /app/service/node_modules ./node_modules
620
651
  COPY --from=ui-build /app/ui/dist ./dist/public
652
+ USER node
621
653
  EXPOSE 3000
622
654
  CMD ["node", "dist/server.js"]
623
655
  ` : `FROM node:22.13-bookworm AS build
@@ -627,12 +659,18 @@ RUN npm ci
627
659
  COPY . .
628
660
  RUN npm run build
629
661
 
662
+ FROM node:22.13-bookworm AS production-dependencies
663
+ WORKDIR /app
664
+ COPY package*.json ./
665
+ RUN npm ci --omit=dev && npm cache clean --force
666
+
630
667
  FROM node:22.13-bookworm-slim
631
668
  WORKDIR /app
632
669
  ENV NODE_ENV=production
633
670
  COPY --from=build /app/dist ./dist
634
671
  COPY --from=build /app/package*.json ./
635
- COPY --from=build /app/node_modules ./node_modules
672
+ COPY --from=production-dependencies /app/node_modules ./node_modules
673
+ USER node
636
674
  EXPOSE 3000
637
675
  CMD ["node", "dist/server.js"]
638
676
  `);
@@ -650,92 +688,431 @@ set -euo pipefail
650
688
  : "\${AWS_ACCOUNT_ID:?Set AWS_ACCOUNT_ID}"
651
689
  : "\${AWS_REGION:?Set AWS_REGION}"
652
690
 
691
+ SVC_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")/.." && pwd)"
692
+ BUILD_CONTEXT="${options.ui ? `$(cd "\${SVC_DIR}/.." && pwd)` : `\${SVC_DIR}`}"
693
+ ${options.ui ? `UI_DIR="\${BUILD_CONTEXT}/${ui}"
694
+ SVC_SHA="$(git -C "\${SVC_DIR}" rev-parse --short HEAD)"
695
+ UI_SHA="$(git -C "\${UI_DIR}" rev-parse --short HEAD)"` : `SVC_SHA="$(git -C "\${SVC_DIR}" rev-parse --short HEAD)"`}
696
+
653
697
  ECR_REPOSITORY="\${ECR_REPOSITORY:-${options.name}}"
654
- IMAGE_TAG="\${IMAGE_TAG:-latest}"
698
+ IMAGE_TAG="\${IMAGE_TAG:-${options.ui ? `\${SVC_SHA}_\${UI_SHA}` : `\${SVC_SHA}`}}"
655
699
  REGISTRY="\${AWS_ACCOUNT_ID}.dkr.ecr.\${AWS_REGION}.amazonaws.com"
656
700
  IMAGE="\${REGISTRY}/\${ECR_REPOSITORY}:\${IMAGE_TAG}"
657
- SVC_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")/.." && pwd)"
658
- BUILD_CONTEXT="${options.ui ? `$(cd "\${SVC_DIR}/.." && pwd)` : `\${SVC_DIR}`}"
659
701
  DOCKERFILE="\${SVC_DIR}/Dockerfile"
702
+ ${options.ui && options.auth === "firebase" ? `UI_ENV_FILE="\${UI_ENV_FILE:-\${UI_DIR}/.env.production}"` : ""}
660
703
 
661
704
  aws ecr get-login-password --region "\${AWS_REGION}" |
662
705
  docker login --username AWS --password-stdin "\${REGISTRY}"
663
706
 
664
- docker build --file "\${DOCKERFILE}" --tag "\${IMAGE}" "\${BUILD_CONTEXT}"
707
+ docker build --platform linux/amd64${options.ui && options.auth === "firebase" ? ` --secret "id=ui_env,src=\${UI_ENV_FILE}"` : ""} --file "\${DOCKERFILE}" --tag "\${IMAGE}" "\${BUILD_CONTEXT}"
665
708
  docker push "\${IMAGE}"
666
709
 
667
710
  echo "Pushed \${IMAGE}"
668
711
  `);
669
712
 
713
+ const workflowUiCheckout = options.ui ? `
714
+ - name: Checkout UI dependency
715
+ uses: actions/checkout@v6
716
+ with:
717
+ repository: \${{ github.repository_owner }}/${ui}
718
+ ref: main
719
+ ssh-key: \${{ secrets.UI_REPO_SSH_KEY }}
720
+ path: ${ui}
721
+ ` : "";
722
+ const workflowUiEnvironment = options.ui && options.auth === "firebase" ? `
723
+ - name: Write UI production environment
724
+ env:
725
+ UI_ENV_PRODUCTION: \${{ secrets.UI_ENV_PRODUCTION }}
726
+ run: |
727
+ printf '%s\\n' "$UI_ENV_PRODUCTION" > ${ui}/.env.production
728
+ ` : "";
729
+ const workflowTagResolution = options.ui ? `
730
+ ui_sha="$(git -C ${ui} rev-parse --short HEAD)"
731
+ echo "tag=\${svc_sha}_\${ui_sha}" >> "$GITHUB_OUTPUT"` : `
732
+ echo "tag=\${svc_sha}" >> "$GITHUB_OUTPUT"`;
733
+ add(`${svc}/.github/workflows/deploy.yml`, `name: Build and deploy
734
+
735
+ on:
736
+ push:
737
+ branches:
738
+ - main
739
+ workflow_dispatch:
740
+
741
+ permissions:
742
+ contents: read
743
+ id-token: write
744
+
745
+ concurrency:
746
+ group: ${options.name}-production
747
+ cancel-in-progress: false
748
+
749
+ jobs:
750
+ deploy:
751
+ name: Deploy production
752
+ if: \${{ vars.DEPLOY_HOST != '' && vars.DEPLOY_USER != '' }}
753
+ runs-on: ubuntu-latest
754
+ environment: production
755
+ env:
756
+ AWS_ACCOUNT_ID: \${{ vars.AWS_ACCOUNT_ID }}
757
+ AWS_REGION: \${{ vars.AWS_REGION }}
758
+ ECR_REPOSITORY: \${{ vars.ECR_REPOSITORY || '${options.name}' }}
759
+ DEPLOY_HOST: \${{ vars.DEPLOY_HOST }}
760
+ DEPLOY_USER: \${{ vars.DEPLOY_USER }}
761
+
762
+ steps:
763
+ - name: Checkout service
764
+ uses: actions/checkout@v6
765
+ with:
766
+ path: ${svc}
767
+ ${workflowUiCheckout}${workflowUiEnvironment}
768
+ - name: Resolve immutable image
769
+ id: image
770
+ run: |
771
+ svc_sha="$(git -C ${svc} rev-parse --short HEAD)"${workflowTagResolution}
772
+ echo "registry=\${AWS_ACCOUNT_ID}.dkr.ecr.\${AWS_REGION}.amazonaws.com" >> "$GITHUB_OUTPUT"
773
+ echo "image=\${AWS_ACCOUNT_ID}.dkr.ecr.\${AWS_REGION}.amazonaws.com/\${ECR_REPOSITORY}" >> "$GITHUB_OUTPUT"
774
+
775
+ - name: Configure AWS credentials
776
+ uses: aws-actions/configure-aws-credentials@v6
777
+ with:
778
+ role-to-assume: \${{ vars.AWS_ROLE_ARN }}
779
+ aws-region: \${{ vars.AWS_REGION }}
780
+
781
+ - name: Build and push image
782
+ working-directory: ${svc}
783
+ env:
784
+ IMAGE_TAG: \${{ steps.image.outputs.tag }}
785
+ run: ./scripts/build_and_push.sh
786
+
787
+ - name: Configure deployment SSH
788
+ env:
789
+ DEPLOY_SSH_PRIVATE_KEY: \${{ secrets.DEPLOY_SSH_PRIVATE_KEY }}
790
+ DEPLOY_SSH_KNOWN_HOSTS: \${{ secrets.DEPLOY_SSH_KNOWN_HOSTS }}
791
+ run: |
792
+ install -m 700 -d ~/.ssh
793
+ printf '%s\\n' "$DEPLOY_SSH_PRIVATE_KEY" > ~/.ssh/${options.name}-deploy
794
+ chmod 600 ~/.ssh/${options.name}-deploy
795
+ printf '%s\\n' "$DEPLOY_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
796
+
797
+ - name: Authenticate server with ECR
798
+ run: |
799
+ aws ecr get-login-password --region "$AWS_REGION" |
800
+ ssh -i ~/.ssh/${options.name}-deploy "\${DEPLOY_USER}@\${DEPLOY_HOST}" \\
801
+ "docker login --username AWS --password-stdin '\${{ steps.image.outputs.registry }}'"
802
+
803
+ - name: Install deployment files
804
+ run: |
805
+ ssh -i ~/.ssh/${options.name}-deploy "\${DEPLOY_USER}@\${DEPLOY_HOST}" \\
806
+ "install -m 755 -d /opt/${options.name}/current/deploy/mongo"
807
+ scp -i ~/.ssh/${options.name}-deploy \\
808
+ ${svc}/docker-compose.yml \\
809
+ ${svc}/scripts/deploy_remote.sh \\
810
+ "\${DEPLOY_USER}@\${DEPLOY_HOST}:/opt/${options.name}/current/"
811
+ scp -i ~/.ssh/${options.name}-deploy \\
812
+ ${svc}/deploy/mongo/10-create-users.js \\
813
+ "\${DEPLOY_USER}@\${DEPLOY_HOST}:/opt/${options.name}/current/deploy/mongo/"
814
+ ssh -i ~/.ssh/${options.name}-deploy "\${DEPLOY_USER}@\${DEPLOY_HOST}" \\
815
+ "chmod 755 /opt/${options.name}/current/deploy_remote.sh"
816
+
817
+ - name: Deploy image
818
+ run: |
819
+ ssh -i ~/.ssh/${options.name}-deploy "\${DEPLOY_USER}@\${DEPLOY_HOST}" \\
820
+ "/opt/${options.name}/current/deploy_remote.sh '\${{ steps.image.outputs.tag }}' '\${{ steps.image.outputs.image }}'"
821
+ `);
822
+
823
+ add(`${svc}/scripts/deploy_remote.sh`, `#!/usr/bin/env bash
824
+ set -euo pipefail
825
+
826
+ IMAGE_TAG="\${1:?Usage: deploy_remote.sh IMAGE_TAG ECR_IMAGE}"
827
+ ECR_IMAGE="\${2:?Usage: deploy_remote.sh IMAGE_TAG ECR_IMAGE}"
828
+ DEPLOY_DIR="\${DEPLOY_DIR:-/opt/${options.name}/current}"
829
+ SHARED_DIR="\${SHARED_DIR:-/opt/${options.name}/shared}"
830
+ COMPOSE_FILE="\${DEPLOY_DIR}/docker-compose.yml"
831
+ INFRA_ENV="\${SHARED_DIR}/.env.infrastructure"
832
+
833
+ if [[ ! -f "\${INFRA_ENV}" ]]; then
834
+ echo "Missing provisioned infrastructure environment: \${INFRA_ENV}" >&2
835
+ exit 1
836
+ fi
837
+
838
+ NEXT_ENV="$(mktemp "\${SHARED_DIR}/.env.infrastructure.XXXXXX")"
839
+ cleanup() {
840
+ rm -f "\${NEXT_ENV}"
841
+ }
842
+ trap cleanup EXIT
843
+
844
+ grep -Ev '^(ECR_IMAGE|IMAGE_TAG)=' "\${INFRA_ENV}" > "\${NEXT_ENV}" || true
845
+ printf 'ECR_IMAGE=%s\\nIMAGE_TAG=%s\\n' "\${ECR_IMAGE}" "\${IMAGE_TAG}" >> "\${NEXT_ENV}"
846
+ chmod 600 "\${NEXT_ENV}"
847
+ mv "\${NEXT_ENV}" "\${INFRA_ENV}"
848
+ trap - EXIT
849
+
850
+ docker compose --env-file "\${INFRA_ENV}" --file "\${COMPOSE_FILE}" config --quiet
851
+ docker compose --env-file "\${INFRA_ENV}" --file "\${COMPOSE_FILE}" pull
852
+ docker compose --env-file "\${INFRA_ENV}" --file "\${COMPOSE_FILE}" up -d --wait
853
+ `);
854
+
855
+ add(`${svc}/deploy/mongo/10-create-users.js`, `const applicationPassword = process.env.MONGODB_APP_PASSWORD;
856
+ const backupPassword = process.env.MONGODB_BACKUP_PASSWORD;
857
+
858
+ if (!applicationPassword || !backupPassword) {
859
+ throw new Error("MONGODB_APP_PASSWORD and MONGODB_BACKUP_PASSWORD are required");
860
+ }
861
+
862
+ db.getSiblingDB("${databaseName}").createUser({
863
+ user: "${mongoApplicationUser}",
864
+ pwd: applicationPassword,
865
+ roles: [{ role: "readWrite", db: "${databaseName}" }]
866
+ });
867
+
868
+ db.getSiblingDB("admin").createUser({
869
+ user: "${mongoBackupUser}",
870
+ pwd: backupPassword,
871
+ roles: [{ role: "backup", db: "admin" }]
872
+ });
873
+ `);
874
+
875
+ add(`${svc}/docs/production-deployment.md`, `# Production deployment
876
+
877
+ ## Delivery model
878
+
879
+ GitHub Actions checks out ${svc}${options.ui ? ` and ${ui}` : ""}${options.ui ? " as sibling build inputs" : ""}, builds one
880
+ non-root production image, tags it ${options.ui ? "`<service-sha>_<ui-sha>`" : "`<service-sha>`"}, pushes it to ECR, and installs only deployment artifacts on the host.
881
+ Only service \`main\` pushes and manual dispatch trigger this workflow; UI pushes never deploy.
882
+
883
+ The host separates replaceable artifacts from persistent state:
884
+
885
+ \`\`\`text
886
+ /opt/${options.name}/
887
+ ├── current/
888
+ │ ├── docker-compose.yml
889
+ │ ├── deploy_remote.sh
890
+ │ └── deploy/mongo/10-create-users.js
891
+ └── shared/
892
+ ├── .env.production
893
+ ├── .env.infrastructure
894
+ ${options.auth === "firebase" ? ` ├── secrets/firebase-service-account.json
895
+ ` : ""} ├── mongo/config/keyfile
896
+ ├── mongo/data/
897
+ ${options.redis ? ` └── redis/data/
898
+ ` : ""}\`\`\`
899
+
900
+ Actions may replace \`current/\` and must never replace \`shared/\`. The server never needs a source checkout.
901
+
902
+ ## Configuration ownership
903
+
904
+ - \`.env.production\` contains Node application behavior and provider credentials.
905
+ - \`.env.infrastructure\` contains Compose paths, image identity, and infrastructure passwords. Start from
906
+ \`.env.infrastructure.example\` and replace every placeholder.
907
+ - Compose \`environment:\` owns fixed production wiring: \`NODE_ENV\`, internal database/cache URLs, and mounted
908
+ credential paths.
909
+ ${options.ui && options.auth === "firebase" ? `- The UI repository's build-time \`.env.production\` comes from the GitHub \`UI_ENV_PRODUCTION\` secret and is unrelated to the service runtime file above.
910
+ ` : ""}
911
+ Do not inject \`.env.infrastructure\` into Node. Make both host environment files root-owned and mode \`0600\`.
912
+
913
+ ## One-time host bootstrap
914
+
915
+ 1. Install Docker Engine and Docker Compose.
916
+ 2. Create \`/opt/${options.name}/current/deploy/mongo\`, \`/opt/${options.name}/shared/mongo/config\`, and
917
+ \`/opt/${options.name}/shared/mongo/data\`${options.redis ? `, plus \`/opt/${options.name}/shared/redis/data\`` : ""}.
918
+ 3. Install \`.env.production\` and a completed \`.env.infrastructure\` under \`shared/\`.
919
+ 4. Generate the MongoDB replica key with \`openssl rand -base64 756\`, make it owned by UID/GID \`999:999\`, and
920
+ mode \`0400\`.
921
+ ${options.auth === "firebase" ? `5. Install the Firebase service account at \`shared/secrets/firebase-service-account.json\`, owned by \`root:1000\` and mode \`0640\`.
922
+ 6. ` : "5. "}Give the deployment user Docker access and write access to \`current/\`; keep \`shared/\` and its files protected.
923
+ ${options.auth === "firebase" ? "7" : "6"}. Authorize the dedicated deployment SSH key and record the host key for GitHub Actions.
924
+
925
+ Use a fresh Mongo data directory. Migrating an existing unauthenticated database requires a separately reviewed plan.
926
+
927
+ ## GitHub production environment
928
+
929
+ Configure these variables:
930
+
931
+ - \`AWS_ACCOUNT_ID\`, \`AWS_REGION\`, and \`AWS_ROLE_ARN\` for the repository-restricted OIDC role.
932
+ - \`DEPLOY_HOST\` and \`DEPLOY_USER\`; the deployment job remains skipped until both exist.
933
+ - Optional \`ECR_REPOSITORY\`; the default is \`${options.name}\`.
934
+
935
+ Configure these secrets:
936
+
937
+ ${options.ui ? `- \`UI_REPO_SSH_KEY\`: read-only deploy key for \`${ui}\`.
938
+ ` : ""}${options.ui && options.auth === "firebase" ? `- \`UI_ENV_PRODUCTION\`: Vite/Firebase build-time environment.
939
+ ` : ""}- \`DEPLOY_SSH_PRIVATE_KEY\`: dedicated host deployment key.
940
+ - \`DEPLOY_SSH_KNOWN_HOSTS\`: pinned host key.
941
+
942
+ The ECR repository and AWS OIDC provider/role are provisioned separately. The workflow never stores long-lived AWS
943
+ keys or creates cloud infrastructure.
944
+
945
+ ## Deploy and rollback
946
+
947
+ The remote script requires the provisioned infrastructure file, atomically replaces only \`ECR_IMAGE\` and
948
+ \`IMAGE_TAG\`, validates Compose, pulls, and starts with health waiting. Roll back by selecting a previous immutable
949
+ tag and running the same script. Never use \`docker compose down -v\` in production; image rollback does not restore
950
+ database state.
951
+
952
+ MongoDB and optional Redis expose no host ports and require authentication. Verify API readiness, container health,
953
+ database/cache authentication, file permissions, and off-host backup/restore before treating the deployment as
954
+ production-ready. Product-specific TLS, capacity limits, migrations, and scheduled-work grace periods must be added
955
+ from measured application requirements rather than copied from another product.
956
+ `);
957
+
670
958
  const serviceVolumes = [
671
- ...(options.auth === "firebase" ? [" - ./firebase-service-account.production.json:/app/firebase-service-account.production.json:ro"] : [])
959
+ ...(options.auth === "firebase" ? [" - ${SECRETS_DIR:?Set SECRETS_DIR}/firebase-service-account.json:/app/secrets/firebase-service-account.json:ro"] : [])
672
960
  ];
673
961
  const devVolumes = [
674
962
  " - .:/app",
675
963
  " - /app/node_modules",
676
964
  ...(options.auth === "firebase" ? [" - ./firebase-service-account.development.json:/app/firebase-service-account.development.json:ro"] : [])
677
965
  ];
678
- const dependencies = [
966
+ const productionDependencies = [
967
+ ["mongo-init", "service_completed_successfully"],
968
+ ...(options.redis ? [["redis", "service_healthy"]] : [])
969
+ ];
970
+ const developmentDependencies = [
679
971
  ["mongo-init", "service_completed_successfully"],
680
972
  ...(options.redis ? [["redis", "service_started"]] : [])
681
973
  ];
682
- const dependsBlock = `\n depends_on:\n${dependencies.map(([dependency, condition]) => ` ${dependency}:\n condition: ${condition}`).join("\n")}`;
974
+ const dependsBlock = `\n depends_on:\n${productionDependencies.map(([dependency, condition]) => ` ${dependency}:\n condition: ${condition}`).join("\n")}`;
975
+ const devDependsBlock = `\n depends_on:\n${developmentDependencies.map(([dependency, condition]) => ` ${dependency}:\n condition: ${condition}`).join("\n")}`;
976
+ const productionEnvironment = [
977
+ " NODE_ENV: production",
978
+ ` MONGODB_URI: "mongodb://${mongoApplicationUser}:\${MONGODB_APP_PASSWORD:?Set MONGODB_APP_PASSWORD}@mongodb:27017/${databaseName}?replicaSet=rs0&directConnection=true&authSource=${databaseName}"`,
979
+ ` MONGODB_DB_NAME: "${databaseName}"`,
980
+ ...(options.auth === "firebase" ? [" GOOGLE_APPLICATION_CREDENTIALS: /app/secrets/firebase-service-account.json"] : []),
981
+ ...(options.redis ? [" REDIS_URL: \"redis://:\${REDIS_PASSWORD:?Set REDIS_PASSWORD}@redis:6379\""] : [])
982
+ ];
983
+ const productionEnvironmentBlock = `\n environment:\n${productionEnvironment.join("\n")}`;
683
984
  const databaseEnvironmentBlock = `\n environment:\n MONGODB_URI: "mongodb://mongodb:27017/?replicaSet=rs0&directConnection=true"`;
684
985
  const volumeBlock = serviceVolumes.length ? `\n volumes:\n${serviceVolumes.join("\n")}` : "";
685
986
  const workerService = options.worker ? `\n worker:
686
- image: \${ECR_IMAGE:?Set ECR_IMAGE}:\${IMAGE_TAG:-latest}
987
+ image: \${ECR_IMAGE:?Set ECR_IMAGE}:\${IMAGE_TAG:?Set IMAGE_TAG}
687
988
  env_file:
688
- - .env.production
689
- command: ["node", "dist/worker.js"]${databaseEnvironmentBlock}${volumeBlock}${dependsBlock}
989
+ - \${APP_ENV_FILE:-.env.production}
990
+ command: ["node", "dist/worker.js"]${productionEnvironmentBlock}${volumeBlock}${dependsBlock}
991
+ healthcheck:
992
+ test: ["CMD", "node", "-e", "process.kill(1, 0)"]
993
+ interval: 60s
994
+ timeout: 5s
995
+ retries: 3
996
+ start_period: 30s
997
+ stop_grace_period: 2m
998
+ networks:
999
+ - backend
690
1000
  restart: unless-stopped` : "";
691
1001
  const databaseService = `\n mongodb:
692
1002
  image: mongo:8
693
- command: mongod --replSet rs0 --bind_ip_all
1003
+ command:
1004
+ - mongod
1005
+ - --replSet
1006
+ - rs0
1007
+ - --bind_ip_all
1008
+ - --keyFile
1009
+ - /etc/mongo-keyfile/keyfile
1010
+ environment:
1011
+ MONGO_INITDB_DATABASE: "${databaseName}"
1012
+ MONGO_INITDB_ROOT_USERNAME: "${options.name}-root"
1013
+ MONGO_INITDB_ROOT_PASSWORD: \${MONGODB_ROOT_PASSWORD:?Set MONGODB_ROOT_PASSWORD}
1014
+ MONGODB_APP_PASSWORD: \${MONGODB_APP_PASSWORD:?Set MONGODB_APP_PASSWORD}
1015
+ MONGODB_BACKUP_PASSWORD: \${MONGODB_BACKUP_PASSWORD:?Set MONGODB_BACKUP_PASSWORD}
694
1016
  volumes:
695
- - mongodb-data:/data/db
1017
+ - \${MONGO_DATA_DIR:?Set MONGO_DATA_DIR}:/data/db
1018
+ - \${MONGO_KEYFILE:?Set MONGO_KEYFILE}:/etc/mongo-keyfile/keyfile:ro
1019
+ - \${MONGO_INIT_SCRIPT:?Set MONGO_INIT_SCRIPT}:/docker-entrypoint-initdb.d/10-create-users.js:ro
696
1020
  healthcheck:
697
- test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
698
- interval: 2s
699
- timeout: 2s
1021
+ test:
1022
+ - CMD-SHELL
1023
+ - >-
1024
+ mongosh --quiet --host localhost
1025
+ --username "$\${MONGO_INITDB_ROOT_USERNAME}"
1026
+ --password "$\${MONGO_INITDB_ROOT_PASSWORD}"
1027
+ --authenticationDatabase admin
1028
+ --eval "quit(db.adminCommand({ping:1}).ok ? 0 : 1)"
1029
+ interval: 5s
1030
+ timeout: 5s
700
1031
  retries: 30
1032
+ start_period: 20s
1033
+ networks:
1034
+ - backend
701
1035
  restart: unless-stopped
702
1036
  mongo-init:
703
1037
  image: mongo:8
1038
+ environment:
1039
+ MONGO_INITDB_ROOT_USERNAME: "${options.name}-root"
1040
+ MONGO_INITDB_ROOT_PASSWORD: \${MONGODB_ROOT_PASSWORD:?Set MONGODB_ROOT_PASSWORD}
704
1041
  depends_on:
705
1042
  mongodb:
706
1043
  condition: service_healthy
707
1044
  restart: "no"
708
- entrypoint:
709
- - bash
710
- - -c
711
- - |
712
- mongosh --host mongodb --quiet --eval '
713
- try {
714
- rs.status().ok
715
- } catch (error) {
716
- rs.initiate({ _id: "rs0", members: [{ _id: 0, host: "localhost:27017" }] })
1045
+ entrypoint: ["bash", "-ec"]
1046
+ command:
1047
+ - >-
1048
+ mongosh --quiet --host mongodb:27017
1049
+ --username "$\${MONGO_INITDB_ROOT_USERNAME}"
1050
+ --password "$\${MONGO_INITDB_ROOT_PASSWORD}"
1051
+ --authenticationDatabase admin
1052
+ --eval "try { const status = rs.status(); quit(status.ok === 1 ? 0 : 1); }
1053
+ catch (error) {
1054
+ if (error.code === 94 || error.codeName === 'NotYetInitialized') {
1055
+ const result = rs.initiate({_id:'rs0',members:[{_id:0,host:'mongodb:27017'}]});
1056
+ quit(result.ok === 1 ? 0 : 1);
717
1057
  }
718
- '`;
1058
+ throw error;
1059
+ }"
1060
+ networks:
1061
+ - backend`;
719
1062
  const redisService = options.redis ? `\n redis:
720
1063
  image: redis:7-alpine
1064
+ command:
1065
+ - redis-server
1066
+ - --appendonly
1067
+ - "yes"
1068
+ - --appendfsync
1069
+ - everysec
1070
+ - --maxmemory-policy
1071
+ - noeviction
1072
+ - --requirepass
1073
+ - \${REDIS_PASSWORD:?Set REDIS_PASSWORD}
1074
+ environment:
1075
+ REDIS_PASSWORD: \${REDIS_PASSWORD:?Set REDIS_PASSWORD}
721
1076
  volumes:
722
- - redis-data:/data
1077
+ - \${REDIS_DATA_DIR:?Set REDIS_DATA_DIR}:/data
1078
+ healthcheck:
1079
+ test:
1080
+ - CMD-SHELL
1081
+ - REDISCLI_AUTH="$\${REDIS_PASSWORD}" redis-cli ping | grep -q PONG
1082
+ interval: 10s
1083
+ timeout: 3s
1084
+ retries: 10
1085
+ start_period: 10s
1086
+ networks:
1087
+ - backend
723
1088
  restart: unless-stopped` : "";
724
- const namedVolumes = [
725
- " mongodb-data:",
726
- ...(options.redis ? [" redis-data:"] : [])
727
- ];
728
1089
  add(`${svc}/docker-compose.yml`, `name: ${options.name}-prod
729
1090
  services:
730
1091
  ${options.name}:
731
- image: \${ECR_IMAGE:?Set ECR_IMAGE}:\${IMAGE_TAG:-latest}
1092
+ image: \${ECR_IMAGE:?Set ECR_IMAGE}:\${IMAGE_TAG:?Set IMAGE_TAG}
732
1093
  env_file:
733
- - .env.production
1094
+ - \${APP_ENV_FILE:-.env.production}
734
1095
  ports:
735
- - "3000:3000"${databaseEnvironmentBlock}${volumeBlock}${dependsBlock}
1096
+ - "3000:3000"${productionEnvironmentBlock}${volumeBlock}${dependsBlock}
1097
+ healthcheck:
1098
+ test:
1099
+ - CMD-SHELL
1100
+ - >-
1101
+ node -e
1102
+ "fetch('http://127.0.0.1:3000/api/v1/health.readiness',
1103
+ {method:'POST',headers:{'Content-Type':'application/json','X-Requested-With':'XMLHttpRequest'},body:'{}'})
1104
+ .then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
1105
+ interval: 30s
1106
+ timeout: 10s
1107
+ retries: 3
1108
+ start_period: 30s
1109
+ stop_grace_period: 2m
1110
+ networks:
1111
+ - backend
736
1112
  restart: unless-stopped${workerService}${databaseService}${redisService}
737
- volumes:
738
- ${namedVolumes.join("\n")}
1113
+ networks:
1114
+ backend:
1115
+ driver: bridge
739
1116
  `);
740
1117
 
741
1118
  const devWorker = options.worker ? `\n worker:
@@ -746,7 +1123,7 @@ const devWorker = options.worker ? `\n worker:
746
1123
  - .env.development
747
1124
  command: ["npm", "run", "dev:worker"]${databaseEnvironmentBlock}
748
1125
  volumes:
749
- ${devVolumes.join("\n")}${dependsBlock}` : "";
1126
+ ${devVolumes.join("\n")}${devDependsBlock}` : "";
750
1127
  const devDatabase = `\n mongodb:
751
1128
  image: mongo:8
752
1129
  command: mongod --replSet rs0 --bind_ip_all
@@ -797,7 +1174,7 @@ services:
797
1174
  ports:
798
1175
  - "3000:3000"${databaseEnvironmentBlock}
799
1176
  volumes:
800
- ${devVolumes.join("\n")}${dependsBlock}${devWorker}${devDatabase}${devRedis}
1177
+ ${devVolumes.join("\n")}${devDependsBlock}${devWorker}${devDatabase}${devRedis}
801
1178
  volumes:
802
1179
  ${devNamedVolumes.join("\n")}
803
1180
  `);