@zaaxch/tailframe 3.0.0 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/new.mjs CHANGED
@@ -66,15 +66,17 @@ if (!fs.existsSync(parent) || !fs.statSync(parent).isDirectory()) fail(`Parent d
66
66
  if (fs.existsSync(root) && fs.readdirSync(root).length) fail(`Refusing to overwrite non-empty directory: ${root}`);
67
67
 
68
68
  const title = options.name.split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join(" ");
69
- const svc = `${options.name}-svc`;
70
- const ui = `${options.name}-ui`;
69
+ const svc = "apps/service";
70
+ const ui = "apps/ui";
71
+ const servicePackage = `@${options.name}/service`;
72
+ const uiPackage = `@${options.name}/ui`;
71
73
  const databaseName = options.name.replaceAll("-", "_");
72
74
  const postgres = options.db === "postgres";
73
75
  const mongoApplicationUser = `${options.name}-app`;
74
76
  const mongoBackupUser = `${options.name}-backup`;
75
77
  const files = {};
76
78
  const add = (relative, content) => { files[relative] = content.endsWith("\n") ? content : `${content}\n`; };
77
- // Generated repositories depend on the published toolkit rather than copying a validator, and they
79
+ // Generated products depend on the published toolkit rather than copying a validator, and they
78
80
  // pin the exact contract version they were generated against.
79
81
  const contractVersion = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
80
82
  const serviceProfiles = [
@@ -84,6 +86,11 @@ const serviceProfiles = [
84
86
  ...(options.worker ? ["worker"] : []),
85
87
  ...(options.ui ? ["ui-host"] : [])
86
88
  ];
89
+ const uiProfiles = [...(options.auth === "firebase" ? ["firebase"] : []), "notifications"];
90
+ const apps = [
91
+ { kind: "service", path: svc, profiles: serviceProfiles },
92
+ ...(options.ui ? [{ kind: "ui", path: ui, profiles: uiProfiles }] : [])
93
+ ];
87
94
  const prettierConfig = {
88
95
  useTabs: true,
89
96
  tabWidth: 4,
@@ -105,92 +112,104 @@ const gitAttributes = `* text=auto eol=lf
105
112
 
106
113
  add(".prettierrc.json", JSON.stringify(prettierConfig, null, "\t"));
107
114
  add(".gitattributes", gitAttributes);
115
+ add("package.json", JSON.stringify({
116
+ name: options.name,
117
+ version: "0.1.0",
118
+ private: true,
119
+ packageManager: "pnpm@11.22.0",
120
+ scripts: {
121
+ dev: "pnpm -r --parallel --stream --if-present dev",
122
+ build: "pnpm -r --if-present build",
123
+ test: "pnpm -r --if-present test",
124
+ "type-check": "pnpm -r --if-present type-check",
125
+ "format:check": "pnpm -r --if-present format:check",
126
+ "validate:architecture": "tailframe validate .",
127
+ "sync:architecture": "tailframe sync --check ."
128
+ },
129
+ devDependencies: { "@zaaxch/tailframe": contractVersion }
130
+ }, null, "\t"));
131
+ add("pnpm-workspace.yaml", `packages:
132
+ - "apps/*"
133
+ injectWorkspacePackages: true
134
+ minimumReleaseAgeExclude:
135
+ - "@zaaxch/tailframe@${contractVersion}"
136
+ allowBuilds:
137
+ "@firebase/util": true
138
+ esbuild: true
139
+ protobufjs: true
140
+ unrs-resolver: true
141
+ `);
142
+ add("tailframe.json", configSource({ apps, contractVersion }));
108
143
 
109
144
  const repos = [[svc, "Express/TypeScript API and background runtime."]];
110
145
  if (options.ui) repos.push([ui, "Vue/Vite customer interface."]);
111
146
 
112
- add("AGENTS.md", `# ${title} repository guidance
147
+ add("AGENTS.md", `# ${title} product guidance
113
148
 
114
149
  ## Sources of truth
115
150
 
116
151
  - Treat implementation and tests as the source of truth for current behavior.
117
- - Product documentation may establish intent but is not proof of implemented behavior.
118
- - Do not claim functionality works merely because a type, route, UI control, configuration key, placeholder, or planning entry exists.
152
+ - Treat \`tailframe.json\` and the root pnpm workspace as the product architecture and dependency boundary.
153
+ - Read the nearest application \`AGENTS.md\` before changing files under \`apps/\`.
119
154
 
120
155
  ## Product boundary
121
156
 
122
- The product domain is not yet defined. Do not invent domain entities, workflows, claims, roles, or integrations. Add product-specific guidance only after an explicit decision or implementation establishes it.
157
+ The product domain is not yet defined. Do not invent domain entities, workflows, claims, roles, or integrations.
123
158
 
124
159
  ## Repository map
125
160
 
126
- This project root contains separate repositories. Do not assume that a convention from one repository applies to another.
161
+ - \`apps/service/\`  Express/TypeScript API and background runtime.
162
+ ${options.ui ? "- \`apps/ui/\`  Vue/Vite customer interface.\n" : ""}- The product root owns Git history, the pnpm lockfile, Tailframe metadata, orchestration, CI, and deployment.
127
163
 
128
- ${repos.map(([name, description]) => `- \`${name}/\` — ${description} Read \`${name}/AGENTS.md\` before editing it.`).join("\n")}
164
+ ## Cross-application changes
129
165
 
130
- ## Cross-repository changes
131
-
132
- 1. Read each applicable child \`AGENTS.md\`.
133
- 2. Inspect the nearest working implementation in each repository.
134
- 3. Keep shared contracts aligned across all affected clients and services.
135
- 4. Validate each repository with its own scripts.
136
- 5. Do not claim end-to-end behavior from changes or static checks in only one repository.
166
+ 1. Read the root and applicable application guidance.
167
+ 2. Keep shared wire contracts aligned across service and UI.
168
+ 3. Run focused checks while iterating and product-root checks before handoff.
169
+ 4. Do not claim end-to-end behavior from changes or static checks in only one application.
137
170
 
138
171
  ## Module workflow
139
172
 
140
- - Use the local \`new-module\` skill whenever creating or extending a domain module, public operation, persisted entity, or client capability module.
141
- - This applies when work affects one repository, multiple repositories, or one phase of a feature whose backend and clients will be implemented separately.
142
- - Do not wait for a task to become cross-repository before using the skill.
143
- - Create architecture files with \`tailframe generate\` in the affected repository; the skill orchestrates the work, the CLI creates the files.
173
+ - Use the local \`new-module\` skill whenever creating or extending a domain module, public operation, persisted entity, or client capability.
174
+ - Create architecture files with \`tailframe generate --app service|ui\` from the product root.
144
175
 
145
176
  ## Code-change guidelines
146
177
 
147
- - Keep changes small and focused and avoid unrelated refactoring.
148
- - Modify existing structures when they fit the requirement.
149
- - Preserve unrelated worktree changes.
150
- - Confirm every committed change belongs to the requested scope.
178
+ - Keep changes focused and preserve unrelated worktree changes.
179
+ - Do not create nested repositories or package-manager boundaries under \`apps/\`.
151
180
 
152
181
  ## GitHub workflow
153
182
 
154
183
  - Use \`gh\` for GitHub interactions when it supports the action.
155
- - Inspect existing issues and pull requests before proposing duplicates.
156
184
  - Never mutate GitHub state without explicit authorization.
157
- - Verify external mutations with a read-only follow-up.
158
185
 
159
186
  ## GitHub authentication and sandboxing
160
187
 
161
- - All \`gh\`, AWS/ECR, and Docker commands must run outside the sandbox. Request approval before the first attempt; do not try them inside the sandbox first.
162
- - Docker commands include \`docker\`, \`docker compose\`, \`docker exec\`, and package scripts whose purpose is to invoke Docker.
163
- - AWS/ECR commands include \`aws ecr\` authentication and package scripts that build or push ECR images.
164
- - Never print, log, commit, or include GitHub tokens in command output.
165
- - Keep mutation approvals separate from read-only GitHub access.
166
-
167
- ## Starting work from a GitHub issue
168
-
169
- Resolve and read the issue and comments, load applicable guidance, inspect worktree state and related work, determine scope and validation, preserve unrelated changes, and use an isolated branch for implementation. Starting implementation does not authorize changing issue metadata. Report the issue, repositories, branch, scope, checks, preserved changes, and blockers.
188
+ - All \`gh\`, AWS/ECR, and Docker commands must run outside the sandbox. Request approval before the first attempt.
189
+ - Never print, log, commit, or include credentials or tokens in command output.
170
190
  `);
171
-
172
191
  add(".agents/skills/new-module/SKILL.md", `---
173
192
  name: new-module
174
- description: Create or extend a domain-colocated module in one repository or across the optional service and UI repositories using explicit use cases, separate trusted request context, module-owned repository ports and persistence adapters, HTTP validation, and focused tests. Use for backend capabilities, HTTP operations, persisted entities, queries, commands, jobs, workers, frontend capability modules, or phased work where a client is implemented later.
193
+ description: Create or extend a domain-colocated module in the service and optional UI applications in one product repository using explicit use cases, separate trusted request context, module-owned repository ports and persistence adapters, HTTP validation, and focused tests. Use for backend capabilities, HTTP operations, persisted entities, queries, commands, jobs, workers, frontend capability modules, or phased work where a client is implemented later.
175
194
  ---
176
195
 
177
196
  # New module
178
197
 
179
198
  Use this workflow for any new domain capability or operation. Add only the layers earned by actual behavior.
180
199
 
181
- 1. Identify the module, requested operations, entry points, and repositories in scope. Classify every intended file with the applicable AGENTS.md placement table before writing. Do not assume full CRUD.
200
+ 1. Identify the module, requested operations, entry points, and applications in scope. Classify every intended file with the applicable AGENTS.md placement table before writing. Do not assume full CRUD.
182
201
  2. Read the root and every applicable child \`AGENTS.md\`.
183
202
  3. Inspect the nearest working module and tests.
184
- 4. Create architecture files only with the tailframe CLI: \`tailframe generate\` creates service modules, use cases, http files, ports, adapters, identifiers, integrations, UI modules, and the closed UI shell catalog on canonical paths with canonical names. Service module generation also registers the new use case and mounts or extends its single route factory through deterministic composition-file edits. Hand-creating architecture files or editing generated shell-catalog implementations is a conformance violation; inspect the generated diff and complete only the remaining behavior-specific checklist.
185
- 5. Colocate product capability behavior under \`src/modules/<module>\` in both the service and UI. Never substitute \`features\` or global \`apis\`, \`views\`, \`services\`, \`stores\`, or \`types\` directories. UI \`app/stores\` is closed to generated \`auth.store.ts\`, \`notification.store.ts\`, and \`theme.store.ts\`; \`app/public\` is closed to generated \`ThemeToggle.vue\`. Never create \`src/modules/theme\`, \`src/modules/auth\`, or \`src/modules/notification\`. Product records, current-user domain records, selections, filters, and workflows stay in their owning module. Start backend modules with \`use-cases/\`, \`http/\`, and tests; add \`domain/\` or \`persistence/\` only when required. Add only the UI module directories earned by the capability.
203
+ 4. Create architecture files only with the tailframe CLI: \`tailframe generate --app <service|ui>\` creates canonical service and UI architecture files. Hand-creating architecture files or editing generated shell-catalog implementations is a conformance violation; inspect the generated diff and complete only the behavior-specific checklist.
204
+ 5. Colocate product capability behavior under \`src/modules/<module>\` in both the service and UI. Never substitute \`features\` or global \`apis\`, \`views\`, \`services\`, \`stores\`, or \`types\` directories. UI \`app/stores\` is closed to generated \`auth.store.ts\`, \`notification.store.ts\`, and \`theme.store.ts\`; \`app/public\` is closed to generated \`ThemeToggle.vue\`. Never create \`src/modules/theme\`, \`src/modules/auth\`, or \`src/modules/notification\`. Product records, current-user domain records, selections, filters, and workflows stay in their owning module; when the service exposes a user capability, its current-user record belongs to a UI \`user\` module. Start backend modules with \`use-cases/\`, \`http/\`, and tests; add \`domain/\` or \`persistence/\` only when required. Add only the UI module directories earned by the capability.
186
205
  6. Define use cases as \`execute(context, input)\`. Keep trusted \`RequestContext\` separate from client input.
187
206
  7. Put ownership, RBAC, entitlements, and domain policies in use-case or domain code, not client bodies or route metadata.
188
207
  8. Define repository contracts in the owning module and implement them in persistence adapters.
189
- 9. For persisted capabilities, prefer branded, domain-owned string IDs at domain and repository-port boundaries; generate the module's identifier file with \`tailframe generate identifiers <module> <NameId...>\`. Keep MongoDB \`ObjectId\` conversion inside MongoDB persistence adapters, using persistence-only document types rather than \`any\` to bypass the boundary.
208
+ 9. For persisted capabilities, prefer branded, domain-owned string IDs at domain and repository-port boundaries; generate identifiers with \`tailframe generate --app service identifiers <module> <NameId...>\`. Keep MongoDB \`ObjectId\` conversion inside MongoDB persistence adapters.
190
209
  10. Let HTTP routes, jobs, workers, scheduled tasks, and CLIs invoke use cases directly; do not call one entry point from another.
191
210
  11. Keep operation names, payloads, authentication, response contracts, and client types synchronized. A service module may import another only through a named file directly under the provider's use-cases directory. A UI module may import another only through a named file directly under the provider's public directory. Both graphs must remain acyclic. UI modules may import only the generated auth/notification/theme app stores and \`ThemeToggle\` from app; every other app path is private.
192
211
  12. Verify generated container and route wiring, then add only dependency construction, client, navigation, worker, or process registration earned by real behavior. Route module views directly from \`app/router.ts\`. App views and shell components consume product modules only through named module \`public/\` entries; they never import module views or other internals.
193
- 13. Run npm run validate:architecture in every affected repository, then run focused type checks and tests. Report repositories, operations, entry points, checks, placement decisions, and gaps.
212
+ 13. Run \`pnpm validate:architecture\` from the product root, then run focused type checks and tests. Report applications, operations, entry points, checks, placement decisions, and gaps.
194
213
 
195
214
  Do not create empty architectural layers, speculative operations, or mandatory controllers. Do not claim behavior from static files, weaken trusted context or persistence ownership, or change unrelated background behavior.
196
215
  `);
@@ -209,19 +228,18 @@ const svcDeps = {
209
228
  const svcDevDeps = {
210
229
  "@types/cors": "^2.8.17", "@types/express": "^5.0.1", "@types/jest": "^30.0.0", "@types/node": "^22.13.14", "@types/supertest": "^7.2.1",
211
230
  ...(postgres ? { "@types/pg": "^8.15.5" } : {}),
212
- "@zaaxch/tailframe": contractVersion,
213
231
  jest: "^30.0.0", nodemon: "^3.1.9", prettier: "3.5.3", "ts-jest": "^29.4.11", "ts-node": "^10.9.2",
214
232
  "tsc-alias": "^1.8.16", "tsconfig-paths": "^4.2.0", typescript: "^5.8.2", supertest: "^7.2.2"
215
233
  };
216
234
  add(`${svc}/package.json`, JSON.stringify({
217
- name: svc, version: "0.1.0", private: true, main: "dist/server.js",
235
+ name: servicePackage, version: "0.1.0", private: true, main: "dist/server.js", files: ["dist"],
218
236
  engines: { node: ">=22.13.0" },
219
237
  scripts: {
220
238
  dev: "nodemon --legacy-watch --exec ts-node -r tsconfig-paths/register src/server.ts",
221
239
  ...(options.worker ? { "dev:worker": "nodemon --legacy-watch --exec ts-node -r tsconfig-paths/register src/worker.ts" } : {}),
222
240
  build: "tsc && tsc-alias", start: "node dist/server.js",
223
241
  ...(options.worker ? { worker: "node dist/worker.js" } : {}),
224
- 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 .", "sync:architecture": "tailframe sync --check .", format: "prettier --write src/", "format:check": "prettier --check src/",
242
+ test: "pnpm test:unit && pnpm test:integration", "test:unit": "jest --selectProjects unit --runInBand", "test:integration": "jest --selectProjects integration --runInBand", "validate:architecture": "tailframe validate ../.. --app service", "sync:architecture": "tailframe sync --check ../..", format: "prettier --write src/", "format:check": "prettier --check src/",
225
243
  "schema:apply": "node dist/app/cli/applySchema.js", "schema:apply:dev": "ts-node -r tsconfig-paths/register src/app/cli/applySchema.ts",
226
244
  "test:db:up": "docker compose -f docker-compose.test.yml up -d", "test:db:down": "docker compose -f docker-compose.test.yml down -v",
227
245
  "docker:dev": "docker compose -f docker-compose.dev.yml up",
@@ -229,7 +247,6 @@ add(`${svc}/package.json`, JSON.stringify({
229
247
  "docker:push": "bash scripts/build_and_push.sh"
230
248
  }, dependencies: svcDeps, devDependencies: svcDevDeps
231
249
  }, null, "\t"));
232
- add(`${svc}/tailframe.json`, configSource({ kind: "service", profiles: serviceProfiles, contractVersion }));
233
250
  add(`${svc}/tsconfig.json`, JSON.stringify({
234
251
  compilerOptions: { target: "ES2022", module: "commonjs", rootDir: "src", outDir: "dist", strict: true, esModuleInterop: true, experimentalDecorators: true, emitDecoratorMetadata: true, baseUrl: ".", paths: { "@/*": ["src/*"] }, skipLibCheck: true },
235
252
  include: ["src/**/*.ts"]
@@ -249,7 +266,7 @@ const dbEnv = postgres
249
266
  : "MONGODB_URI=mongodb://localhost:27017/?replicaSet=rs0&directConnection=true\nMONGODB_DB_NAME=" + databaseName;
250
267
  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" : ""}`);
251
268
  add(`${svc}/.env.infrastructure.example`, `ECR_IMAGE=<account>.dkr.ecr.<region>.amazonaws.com/${options.name}
252
- IMAGE_TAG=<service-sha>${options.ui ? "_<ui-sha>" : ""}
269
+ IMAGE_TAG=<product-sha>
253
270
  APP_ENV_FILE=/opt/${options.name}/shared/.env.production
254
271
  ${postgres ? `POSTGRES_DATA_DIR=/opt/${options.name}/shared/postgres/data
255
272
  POSTGRES_ROOT_PASSWORD=<uri-safe-random-value>
@@ -476,7 +493,7 @@ These conventions apply to \`${svc}\`. This service is a modular monolith organi
476
493
  | --- | --- |
477
494
  | Product rule, query, command, or policy | \`src/modules/<module>/use-cases\` or \`domain\` |
478
495
  | Repository port | Owning module's \`use-cases/ports\` |
479
- | MongoDB implementation | Owning module's \`persistence\` |
496
+ | Database implementation | Owning module's \`persistence\` |
480
497
  | Express route or request schema | Owning module's \`http\` |
481
498
  | Vendor or external-system adapter | \`src/platform/integrations/<provider>\` |
482
499
  | Database, Redis, authentication, or HTTP mechanism | \`src/platform\` |
@@ -510,11 +527,13 @@ Product vocabulary stays in the module that owns its meaning; never move it into
510
527
  - Define repository contracts under the owning module's \`use-cases/ports\`.
511
528
  - Implement database-specific adapters under that module's \`persistence/\` directory.
512
529
  - For persisted capabilities, prefer branded, domain-owned string IDs in module types and repository ports. A technology-neutral \`Brand<Value, Tag>\` helper MAY be defined when a real module needs distinct ID types; do not create product entities or concrete ID types in the scaffold.
513
- - Repository ports and use cases MUST NOT expose database identifier types. Persistence adapters SHOULD define persistence-only document types and MUST NOT use \`any\` to bypass the domain/persistence distinction. MongoDB \`ObjectId\` conversion belongs inside the MongoDB persistence adapter.
530
+ - Repository ports and use cases MUST NOT expose database identifier types. Persistence adapters SHOULD define persistence-only storage types and MUST NOT use \`any\` to bypass the domain/persistence distinction. Conversion between domain and database representations belongs inside the database persistence adapter.
514
531
  - Add appropriate schema or validation, indexes, initialization or migrations, and focused tests.
515
532
 
516
533
  ## Project-specific persistence
517
- This service uses MongoDB. Module-owned MongoDB adapters own collection access, persistence-only document types, and conversion of branded module string IDs to and from ObjectId inside the adapter, along with queries, projections, validators, and indexes. Do not use any to bypass the domain/persistence distinction.
534
+ ${postgres
535
+ ? "This service uses PostgreSQL. Module-owned PostgreSQL adapters own table access, persistence-only row types, queries, projections, transactions, and conversion to domain types. Keep schema definitions and migrations with the owning module's persistence implementation, and never expose database types or use any to bypass repository ports."
536
+ : "This service uses MongoDB. Module-owned MongoDB adapters own collection access, persistence-only document types, and conversion of branded module string IDs to and from ObjectId inside the adapter, along with queries, projections, validators, and indexes. Do not use any to bypass the domain/persistence distinction."}
518
537
 
519
538
  ## Queries and commands
520
539
  - Commands enforce invariants and change state.
@@ -530,29 +549,25 @@ This service uses MongoDB. Module-owned MongoDB adapters own collection access,
530
549
  ${options.ui ? "- In production, serve the compiled UI from `dist/public` with a non-API SPA fallback. Never return the SPA for `/api` requests." : ""}
531
550
 
532
551
  ## Production image
533
- 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.` : ""}
552
+ The ECR build targets \`linux/amd64\`. ${options.ui ? "Its default immutable tag is `<product-sha>` and production Compose requires that exact tag." : "Its default immutable tag is `<product-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.` : ""}
534
553
 
535
554
  ## Production deployment
536
555
  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\`.
537
556
 
538
557
  ## Tests and validation
539
- 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.
558
+ Run \`pnpm validate:architecture\` and \`pnpm format:check\` after changing files or imports. The generated service also includes unit tests and ${postgres ? "PostgreSQL-backed" : "Mongo-backed"} integration tests. Run focused service database tests through pnpm for the full service check. Test use cases, policies, ${postgres ? "PostgreSQL" : "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 ${postgres ? "PostgreSQL" : "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 the service \`docker:push\` script, must run outside the sandbox from the first attempt.
540
559
  `);
541
560
 
542
561
  if (options.ui) {
543
562
  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" } : {}) };
544
- 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" };
545
- 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 .", "sync:architecture": "tailframe sync --check .", format: "prettier --write src/", "format:check": "prettier --check src/" }, dependencies: uiDeps, devDependencies: uiDevDeps }, null, "\t"));
546
- add(`${ui}/tailframe.json`, configSource({
547
- kind: "ui",
548
- profiles: [...(options.auth === "firebase" ? ["firebase"] : []), "notifications"],
549
- contractVersion
550
- }));
563
+ const uiDevDeps = { "@tsconfig/node22": "^22.0.1", "@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" };
564
+ add(`${ui}/package.json`, JSON.stringify({ name: uiPackage, 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 ../.. --app ui", "sync:architecture": "tailframe sync --check ../..", format: "prettier --write src/", "format:check": "prettier --check src/" }, dependencies: uiDeps, devDependencies: uiDevDeps }, null, "\t"));
565
+
551
566
  add(`${ui}/.prettierrc.json`, JSON.stringify(prettierConfig, null, "\t"));
552
567
  add(`${ui}/.gitattributes`, gitAttributes);
553
568
  add(`${ui}/tsconfig.json`, JSON.stringify({ files: [], references: [{ path: "./tsconfig.app.json" }, { path: "./tsconfig.node.json" }] }, null, "\t"));
554
- 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"));
555
- add(`${ui}/tsconfig.node.json`, JSON.stringify({ extends: "@tsconfig/node22/tsconfig.json", include: ["vite.config.*"], compilerOptions: { composite: true, types: ["node"] } }, null, "\t"));
569
+ add(`${ui}/tsconfig.app.json`, JSON.stringify({ extends: "@vue/tsconfig/tsconfig.dom.json", include: ["env.d.ts", "src/**/*", "src/**/*.vue"], compilerOptions: { composite: true, noEmit: true, tsBuildInfoFile: "./.tmp/tsconfig.app.tsbuildinfo", types: ["vitest/globals"], baseUrl: ".", paths: { "@/*": ["./src/*"] } } }, null, "\t"));
570
+ add(`${ui}/tsconfig.node.json`, JSON.stringify({ extends: "@tsconfig/node22/tsconfig.json", include: ["vite.config.ts"], compilerOptions: { composite: true, noEmit: true, tsBuildInfoFile: "./.tmp/tsconfig.node.tsbuildinfo", types: ["node"] } }, null, "\t"));
556
571
  add(`${ui}/vite.config.ts`, `import { fileURLToPath, URL } from "node:url";\nimport { defineConfig } from "vitest/config";\nimport vue from "@vitejs/plugin-vue";\nimport tailwindcss from "@tailwindcss/vite";\nexport default defineConfig({ plugins: [vue(), tailwindcss()], resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) } }, server: { proxy: { "/api": { target: "http://localhost:3000", changeOrigin: true } } }, test: { environment: "jsdom", globals: true } });`);
557
572
  add(`${ui}/env.d.ts`, `/// <reference types="vite/client" />`);
558
573
  add(`${ui}/index.html`, `<!doctype html>
@@ -568,7 +583,7 @@ add(`${ui}/index.html`, `<!doctype html>
568
583
  </body>
569
584
  </html>
570
585
  `);
571
- add(`${ui}/.gitignore`, `node_modules/\ndist/\n.env*\n!.env.example\ncerts/\n*.log\n`);
586
+ add(`${ui}/.gitignore`, `node_modules/\ndist/\n.env*\n!.env.example\ncerts/\n*.log\n.tmp/\n`);
572
587
  if (options.auth === "firebase") add(`${ui}/.env.example`, `VITE_FIREBASE_API_KEY=\nVITE_FIREBASE_AUTH_DOMAIN=\nVITE_FIREBASE_PROJECT_ID=\nVITE_FIREBASE_APP_ID=\n`);
573
588
  add(`${ui}/src/core/errors.ts`, uiErrorsSource);
574
589
  add(`${ui}/src/core/rpc.ts`, uiRpcSource);
@@ -589,6 +604,7 @@ if (options.auth === "firebase") add(`${ui}/src/app/configureHttp.ts`, configure
589
604
  add(`${ui}/src/platform/config.ts`, `export const apiBaseUrl = \`\${window.location.origin}/api/v1\`;`);
590
605
  add(`${ui}/src/modules/health/api/health.api.ts`, healthApiSource);
591
606
  add(`${ui}/src/modules/health/views/HealthView.vue`, `<script setup lang="ts">
607
+ import Button from "primevue/button";
592
608
  import { ref } from "vue";
593
609
  import { isServiceError } from "@/core/errors";
594
610
  import { getReadiness } from "@/modules/health/api/health.api";
@@ -616,14 +632,14 @@ async function checkReadiness() {
616
632
  <p class="font-bold uppercase tracking-[0.12em] text-emerald-800">Architecture reference</p>
617
633
  <h1 class="m-0 text-5xl font-bold sm:text-7xl">${title}</h1>
618
634
  <p>The product domain is intentionally undefined.</p>
619
- <button
635
+ <Button
620
636
  type="button"
637
+ :label="loading ? 'Checking…' : 'Check readiness'"
621
638
  class="self-start rounded-full bg-emerald-800 px-4 py-3 text-white disabled:opacity-60"
622
639
  :disabled="loading"
640
+ :loading="loading"
623
641
  @click="checkReadiness"
624
- >
625
- {{ loading ? "Checking…" : "Check readiness" }}
626
- </button>
642
+ />
627
643
  <p v-if="status" role="status">Service status: {{ status }}</p>
628
644
  <p v-if="error" role="alert">{{ error }}</p>
629
645
  </main>
@@ -652,7 +668,7 @@ These conventions apply to \`${ui}/src\`. Inspect the nearest working module bef
652
668
  - Use \`src/platform\` for Axios, Firebase, browser APIs, and other technical adapters.
653
669
  - Use \`src/modules/<module>\` for every product capability. Never substitute \`src/features\` or global \`apis\`, \`views\`, \`services\`, \`stores\`, or \`types\` directories.
654
670
  - These are the only UI architecture roots. Keep \`src/core\` flat. Shared capability code belongs to the module that owns it. \`src/app/stores\` is closed to generated \`auth.store.ts\`, \`notification.store.ts\`, and \`theme.store.ts\`; \`src/app/public\` is closed to generated \`ThemeToggle.vue\`. Do not edit their implementations; only the generated theme storage key varies. Internal shell components remain private under \`src/app/components\`.
655
- - Never create \`src/modules/theme\`, \`src/modules/auth\`, or \`src/modules/notification\`. Product records, current-user domain records, selections, filters, workflows, and module API calls always belong to the owning product module.
671
+ - Never create \`src/modules/theme\`, \`src/modules/auth\`, or \`src/modules/notification\`. Product records, current-user domain records, selections, filters, workflows, and module API calls always belong to the owning product module. When the service exposes a user capability, its current-user record belongs to a UI \`user\` module rather than whichever consumer happens to use fields from the response.
656
672
 
657
673
  ## Change placement
658
674
  | Change | Canonical owner |
@@ -685,7 +701,8 @@ Keep local state local. Use Pinia only for state shared across routes or unrelat
685
701
  Keep all product state in the owning module. The auth/notification/theme stores, NotificationHost, and ThemeToggle are Tailframe-owned generated sources shared byte-for-byte across projects apart from the explicit theme storage key.
686
702
 
687
703
  ## UI behavior
688
- Reuse Vue, PrimeVue, Tailwind, loading, error, and accessibility conventions. Provide explicit loading, empty, error, and authorization states.
704
+ - Prefer PrimeVue for standard interactive controls and overlays when an appropriate component exists. Use native HTML for semantic structure, links, and genuinely custom interactions; use Tailwind for layout and composition.
705
+ - Reuse Vue, PrimeVue, Tailwind, loading, error, and accessibility conventions. Provide explicit loading, empty, error, and authorization states.
689
706
 
690
707
  ## Styling
691
708
  - Prefer Tailwind utility classes in Vue templates over custom selectors and component-scoped CSS.
@@ -693,74 +710,44 @@ Reuse Vue, PrimeVue, Tailwind, loading, error, and accessibility conventions. Pr
693
710
  - Keep \`src/app/styles.css\` limited to Tailwind imports, theme tokens, and true global base behavior.
694
711
 
695
712
  ## Production packaging
696
- 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.` : ""}
713
+ The UI runs through Vite in development but has no production container. The service's multi-stage Dockerfile builds this application 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 \`<product-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.` : ""}
697
714
 
698
715
  ## Product language
699
716
  The product domain is undefined. Do not invent entities, workflows, roles, claims, navigation, or customer-facing promises.
700
717
 
701
718
  ## Validation
702
- 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.
719
+ Run \`pnpm validate:architecture\` and \`pnpm format:check\`, then add focused Vitest coverage and run type-check, build, and \`pnpm test\` as applicable. Use \`pnpm test:watch\` only for interactive development.
703
720
  `);
704
721
  }
705
722
 
706
- add(`${svc}/Dockerfile`, options.ui ? `FROM node:22.13-bookworm AS service-build
707
- WORKDIR /app/service
708
- COPY ${svc}/package*.json ./
709
- RUN npm ci
710
- COPY ${svc}/ ./
711
- RUN npm run build
712
-
713
- FROM node:22.13-bookworm AS service-production-dependencies
714
- WORKDIR /app/service
715
- COPY ${svc}/package*.json ./
716
- RUN npm ci --omit=dev && npm cache clean --force
717
-
718
- FROM node:22.13-bookworm AS ui-build
719
- WORKDIR /app/ui
720
- COPY ${ui}/package*.json ./
721
- RUN npm ci
722
- COPY ${ui}/ ./
723
- ${options.auth === "firebase" ? "RUN --mount=type=secret,id=ui_env,target=/app/ui/.env.production,required=true npm run build" : "RUN npm run build"}
724
-
725
- FROM node:22.13-bookworm-slim
726
- WORKDIR /app
727
- ENV NODE_ENV=production
728
- COPY --from=service-build /app/service/dist ./dist
729
- COPY --from=service-build /app/service/package*.json ./
730
- COPY --from=service-production-dependencies /app/service/node_modules ./node_modules
731
- COPY --from=ui-build /app/ui/dist ./dist/public
732
- USER node
733
- EXPOSE 3000
734
- CMD ["node", "dist/server.js"]
735
- ` : `FROM node:22.13-bookworm AS build
736
- WORKDIR /app
737
- COPY package*.json ./
738
- RUN npm ci
739
- COPY . .
740
- RUN npm run build
741
-
742
- FROM node:22.13-bookworm AS production-dependencies
743
- WORKDIR /app
744
- COPY package*.json ./
745
- RUN npm ci --omit=dev && npm cache clean --force
723
+ add(`${svc}/Dockerfile`, `FROM node:22.13-bookworm AS build
724
+ RUN npm install --global corepack@0.34.5 && corepack enable
725
+ WORKDIR /workspace
726
+ COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
727
+ COPY apps/service/package.json apps/service/package.json
728
+ ${options.ui ? "COPY apps/ui/package.json apps/ui/package.json\n" : ""}RUN pnpm install --frozen-lockfile
729
+ COPY apps/service apps/service
730
+ ${options.ui ? "COPY apps/ui apps/ui\n" : ""}RUN pnpm --filter ${servicePackage} build
731
+ ${options.ui ? (options.auth === "firebase" ? `RUN --mount=type=secret,id=ui_env,target=/workspace/apps/ui/.env.production,required=true pnpm --filter ${uiPackage} build` : `RUN pnpm --filter ${uiPackage} build`) + "\n" : ""}RUN pnpm --filter ${servicePackage} --prod deploy /prod/service
746
732
 
747
733
  FROM node:22.13-bookworm-slim
748
734
  WORKDIR /app
749
735
  ENV NODE_ENV=production
750
- COPY --from=build /app/dist ./dist
751
- COPY --from=build /app/package*.json ./
752
- COPY --from=production-dependencies /app/node_modules ./node_modules
753
- USER node
736
+ COPY --from=build /prod/service ./
737
+ ${options.ui ? "COPY --from=build /workspace/apps/ui/dist ./dist/public\n" : ""}USER node
754
738
  EXPOSE 3000
755
739
  CMD ["node", "dist/server.js"]
756
740
  `);
757
741
  add(`${svc}/Dockerfile.dev`, `FROM node:22.13-alpine
758
- WORKDIR /app
759
- COPY package*.json ./
760
- RUN npm install
761
- COPY . .
742
+ RUN npm install --global corepack@0.34.5 && corepack enable
743
+ WORKDIR /workspace
744
+ COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
745
+ COPY apps/service/package.json apps/service/package.json
746
+ RUN pnpm install --frozen-lockfile
747
+ COPY apps/service apps/service
748
+ WORKDIR /workspace/apps/service
762
749
  EXPOSE 3000
763
- CMD ["npm", "run", "dev"]
750
+ CMD ["pnpm", "dev"]
764
751
  `);
765
752
  add(`${svc}/scripts/build_and_push.sh`, `#!/usr/bin/env bash
766
753
  set -euo pipefail
@@ -768,54 +755,44 @@ set -euo pipefail
768
755
  : "\${AWS_ACCOUNT_ID:?Set AWS_ACCOUNT_ID}"
769
756
  : "\${AWS_REGION:?Set AWS_REGION}"
770
757
 
771
- SVC_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")/.." && pwd)"
772
- BUILD_CONTEXT="${options.ui ? `$(cd "\${SVC_DIR}/.." && pwd)` : `\${SVC_DIR}`}"
773
- ${options.ui ? `UI_DIR="\${BUILD_CONTEXT}/${ui}"
774
- SVC_SHA="$(git -C "\${SVC_DIR}" rev-parse --short HEAD)"
775
- UI_SHA="$(git -C "\${UI_DIR}" rev-parse --short HEAD)"` : `SVC_SHA="$(git -C "\${SVC_DIR}" rev-parse --short HEAD)"`}
776
-
758
+ SERVICE_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")/.." && pwd)"
759
+ PRODUCT_ROOT="$(cd "\${SERVICE_DIR}/../.." && pwd)"
760
+ PRODUCT_SHA="$(git -C "\${PRODUCT_ROOT}" rev-parse --short HEAD)"
777
761
  ECR_REPOSITORY="\${ECR_REPOSITORY:-${options.name}}"
778
- IMAGE_TAG="\${IMAGE_TAG:-${options.ui ? `\${SVC_SHA}_\${UI_SHA}` : `\${SVC_SHA}`}}"
762
+ IMAGE_TAG="\${IMAGE_TAG:-\${PRODUCT_SHA}}"
779
763
  REGISTRY="\${AWS_ACCOUNT_ID}.dkr.ecr.\${AWS_REGION}.amazonaws.com"
780
764
  IMAGE="\${REGISTRY}/\${ECR_REPOSITORY}:\${IMAGE_TAG}"
781
- DOCKERFILE="\${SVC_DIR}/Dockerfile"
782
- ${options.ui && options.auth === "firebase" ? `UI_ENV_FILE="\${UI_ENV_FILE:-\${UI_DIR}/.env.production}"` : ""}
765
+ DOCKERFILE="\${SERVICE_DIR}/Dockerfile"
766
+ ${options.ui && options.auth === "firebase" ? `UI_ENV_FILE="\${UI_ENV_FILE:-\${PRODUCT_ROOT}/apps/ui/.env.production}"` : ""}
783
767
 
784
768
  aws ecr get-login-password --region "\${AWS_REGION}" |
785
769
  docker login --username AWS --password-stdin "\${REGISTRY}"
786
770
 
787
- docker build --platform linux/amd64${options.ui && options.auth === "firebase" ? ` --secret "id=ui_env,src=\${UI_ENV_FILE}"` : ""} --file "\${DOCKERFILE}" --tag "\${IMAGE}" "\${BUILD_CONTEXT}"
771
+ docker build --platform linux/amd64${options.ui && options.auth === "firebase" ? ` --secret "id=ui_env,src=\${UI_ENV_FILE}"` : ""} --file "\${DOCKERFILE}" --tag "\${IMAGE}" "\${PRODUCT_ROOT}"
788
772
  docker push "\${IMAGE}"
789
773
 
790
774
  echo "Pushed \${IMAGE}"
791
775
  `);
792
-
793
- const workflowUiCheckout = options.ui ? `
794
- - name: Checkout UI dependency
795
- uses: actions/checkout@v6
796
- with:
797
- repository: \${{ github.repository_owner }}/${ui}
798
- ref: main
799
- ssh-key: \${{ secrets.UI_REPO_SSH_KEY }}
800
- path: ${ui}
801
- ` : "";
776
+ const workflowUiPath = options.ui ? ' - "apps/ui/**"\n' : "";
802
777
  const workflowUiEnvironment = options.ui && options.auth === "firebase" ? `
803
778
  - name: Write UI production environment
804
779
  env:
805
780
  UI_ENV_PRODUCTION: \${{ secrets.UI_ENV_PRODUCTION }}
806
781
  run: |
807
- printf '%s\\n' "$UI_ENV_PRODUCTION" > ${ui}/.env.production
782
+ printf '%s\\n' "$UI_ENV_PRODUCTION" > apps/ui/.env.production
808
783
  ` : "";
809
- const workflowTagResolution = options.ui ? `
810
- ui_sha="$(git -C ${ui} rev-parse --short HEAD)"
811
- echo "tag=\${svc_sha}_\${ui_sha}" >> "$GITHUB_OUTPUT"` : `
812
- echo "tag=\${svc_sha}" >> "$GITHUB_OUTPUT"`;
813
- add(`${svc}/.github/workflows/deploy.yml`, `name: Build and deploy
784
+ add(".github/workflows/deploy.yml", `name: Build and deploy
814
785
 
815
786
  on:
816
787
  push:
817
788
  branches:
818
789
  - main
790
+ paths:
791
+ - "apps/service/**"
792
+ ${workflowUiPath} - "package.json"
793
+ - "pnpm-workspace.yaml"
794
+ - "pnpm-lock.yaml"
795
+ - ".github/workflows/deploy.yml"
819
796
  workflow_dispatch:
820
797
 
821
798
  permissions:
@@ -840,15 +817,14 @@ jobs:
840
817
  DEPLOY_USER: \${{ vars.DEPLOY_USER }}
841
818
 
842
819
  steps:
843
- - name: Checkout service
820
+ - name: Checkout product
844
821
  uses: actions/checkout@v6
845
- with:
846
- path: ${svc}
847
- ${workflowUiCheckout}${workflowUiEnvironment}
822
+ ${workflowUiEnvironment}
848
823
  - name: Resolve immutable image
849
824
  id: image
850
825
  run: |
851
- svc_sha="$(git -C ${svc} rev-parse --short HEAD)"${workflowTagResolution}
826
+ product_sha="$(git rev-parse --short HEAD)"
827
+ echo "tag=\${product_sha}" >> "$GITHUB_OUTPUT"
852
828
  echo "registry=\${AWS_ACCOUNT_ID}.dkr.ecr.\${AWS_REGION}.amazonaws.com" >> "$GITHUB_OUTPUT"
853
829
  echo "image=\${AWS_ACCOUNT_ID}.dkr.ecr.\${AWS_REGION}.amazonaws.com/\${ECR_REPOSITORY}" >> "$GITHUB_OUTPUT"
854
830
 
@@ -859,10 +835,9 @@ ${workflowUiCheckout}${workflowUiEnvironment}
859
835
  aws-region: \${{ vars.AWS_REGION }}
860
836
 
861
837
  - name: Build and push image
862
- working-directory: ${svc}
863
838
  env:
864
839
  IMAGE_TAG: \${{ steps.image.outputs.tag }}
865
- run: ./scripts/build_and_push.sh
840
+ run: apps/service/scripts/build_and_push.sh
866
841
 
867
842
  - name: Configure deployment SSH
868
843
  env:
@@ -885,11 +860,11 @@ ${workflowUiCheckout}${workflowUiEnvironment}
885
860
  ssh -i ~/.ssh/${options.name}-deploy "\${DEPLOY_USER}@\${DEPLOY_HOST}" \\
886
861
  "install -m 755 -d /opt/${options.name}/current/deploy/mongo"
887
862
  scp -i ~/.ssh/${options.name}-deploy \\
888
- ${svc}/docker-compose.yml \\
889
- ${svc}/scripts/deploy_remote.sh \\
863
+ apps/service/docker-compose.yml \\
864
+ apps/service/scripts/deploy_remote.sh \\
890
865
  "\${DEPLOY_USER}@\${DEPLOY_HOST}:/opt/${options.name}/current/"
891
866
  scp -i ~/.ssh/${options.name}-deploy \\
892
- ${svc}/deploy/mongo/10-create-users.js \\
867
+ apps/service/deploy/mongo/10-create-users.js \\
893
868
  "\${DEPLOY_USER}@\${DEPLOY_HOST}:/opt/${options.name}/current/deploy/mongo/"
894
869
  ssh -i ~/.ssh/${options.name}-deploy "\${DEPLOY_USER}@\${DEPLOY_HOST}" \\
895
870
  "chmod 755 /opt/${options.name}/current/deploy_remote.sh"
@@ -899,7 +874,6 @@ ${workflowUiCheckout}${workflowUiEnvironment}
899
874
  ssh -i ~/.ssh/${options.name}-deploy "\${DEPLOY_USER}@\${DEPLOY_HOST}" \\
900
875
  "/opt/${options.name}/current/deploy_remote.sh '\${{ steps.image.outputs.tag }}' '\${{ steps.image.outputs.image }}'"
901
876
  `);
902
-
903
877
  add(`${svc}/scripts/deploy_remote.sh`, `#!/usr/bin/env bash
904
878
  set -euo pipefail
905
879
 
@@ -951,9 +925,9 @@ add(`${svc}/docs/production-deployment.md`, `# Production deployment
951
925
 
952
926
  ## Delivery model
953
927
 
954
- GitHub Actions checks out ${svc}${options.ui ? ` and ${ui}` : ""}${options.ui ? " as sibling build inputs" : ""}, builds one
955
- 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.
956
- Only service \`main\` pushes and manual dispatch trigger this workflow; UI pushes never deploy.
928
+ GitHub Actions checks out the product once, builds one
929
+ non-root production image, tags it \`<product-sha>\`, pushes it to ECR, and installs only deployment artifacts on the host.
930
+ Changes to the service, optional UI, root workspace/lock configuration, or deployment files on product \`main\`, plus manual dispatch, trigger this workflow.
957
931
 
958
932
  The host separates replaceable artifacts from persistent state:
959
933
 
@@ -981,7 +955,7 @@ Actions may replace \`current/\` and must never replace \`shared/\`. The server
981
955
  \`.env.infrastructure.example\` and replace every placeholder.
982
956
  - Compose \`environment:\` owns fixed production wiring: \`NODE_ENV\`, internal database/cache URLs, and mounted
983
957
  credential paths.
984
- ${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.
958
+ ${options.ui && options.auth === "firebase" ? `- The UI application's build-time \`.env.production\` comes from the GitHub \`UI_ENV_PRODUCTION\` secret and is unrelated to the service runtime file above.
985
959
  ` : ""}
986
960
  Do not inject \`.env.infrastructure\` into Node. Make both host environment files root-owned and mode \`0600\`.
987
961
 
@@ -1009,8 +983,7 @@ Configure these variables:
1009
983
 
1010
984
  Configure these secrets:
1011
985
 
1012
- ${options.ui ? `- \`UI_REPO_SSH_KEY\`: read-only deploy key for \`${ui}\`.
1013
- ` : ""}${options.ui && options.auth === "firebase" ? `- \`UI_ENV_PRODUCTION\`: Vite/Firebase build-time environment.
986
+ ${options.ui && options.auth === "firebase" ? `- \`UI_ENV_PRODUCTION\`: Vite/Firebase build-time environment.
1014
987
  ` : ""}- \`DEPLOY_SSH_PRIVATE_KEY\`: dedicated host deployment key.
1015
988
  - \`DEPLOY_SSH_KNOWN_HOSTS\`: pinned host key.
1016
989
 
@@ -1034,8 +1007,9 @@ const serviceVolumes = [
1034
1007
  ...(options.auth === "firebase" ? [" - ${SECRETS_DIR:?Set SECRETS_DIR}/firebase-service-account.json:/app/secrets/firebase-service-account.json:ro"] : [])
1035
1008
  ];
1036
1009
  const devVolumes = [
1037
- " - .:/app",
1038
- " - /app/node_modules",
1010
+ " - .:/workspace/apps/service",
1011
+ " - /workspace/node_modules",
1012
+ " - /workspace/apps/service/node_modules",
1039
1013
  ...(options.auth === "firebase" ? [" - ./firebase-service-account.development.json:/app/firebase-service-account.development.json:ro"] : [])
1040
1014
  ];
1041
1015
  const productionDependencies = [
@@ -1060,9 +1034,13 @@ const productionEnvironment = [
1060
1034
  ...(options.redis ? [" REDIS_URL: \"redis://:\${REDIS_PASSWORD:?Set REDIS_PASSWORD}@redis:6379\""] : [])
1061
1035
  ];
1062
1036
  const productionEnvironmentBlock = `\n environment:\n${productionEnvironment.join("\n")}`;
1063
- const databaseEnvironmentBlock = postgres
1064
- ? `\n environment:\n POSTGRES_URI: "postgresql://${options.name}-app:development@postgres:5432/${databaseName}"`
1065
- : `\n environment:\n MONGODB_URI: "mongodb://mongodb:27017/?replicaSet=rs0&directConnection=true"`;
1037
+ const developmentEnvironment = [
1038
+ postgres
1039
+ ? ` POSTGRES_URI: "postgresql://${options.name}-app:development@postgres:5432/${databaseName}"`
1040
+ : ` MONGODB_URI: "mongodb://mongodb:27017/?replicaSet=rs0&directConnection=true"`,
1041
+ ...(options.auth === "firebase" ? [" FIREBASE_SERVICE_ACCOUNT_PATH: /app/firebase-service-account.development.json"] : [])
1042
+ ];
1043
+ const developmentEnvironmentBlock = `\n environment:\n${developmentEnvironment.join("\n")}`;
1066
1044
  const volumeBlock = serviceVolumes.length ? `\n volumes:\n${serviceVolumes.join("\n")}` : "";
1067
1045
  const workerService = options.worker ? `\n worker:
1068
1046
  image: \${ECR_IMAGE:?Set ECR_IMAGE}:\${IMAGE_TAG:?Set IMAGE_TAG}
@@ -1235,7 +1213,7 @@ const devWorker = options.worker ? `\n worker:
1235
1213
  dockerfile: Dockerfile.dev
1236
1214
  env_file:
1237
1215
  - .env.development
1238
- command: ["npm", "run", "dev:worker"]${databaseEnvironmentBlock}
1216
+ command: ["npm", "run", "dev:worker"]${developmentEnvironmentBlock}
1239
1217
  volumes:
1240
1218
  ${devVolumes.join("\n")}${devDependsBlock}` : "";
1241
1219
  const devDatabase = postgres ? `\n postgres:
@@ -1299,7 +1277,7 @@ services:
1299
1277
  dockerfile: Dockerfile.dev
1300
1278
  env_file:
1301
1279
  - .env.development
1302
- command: ["npm", "run", "schema:apply:dev"]${databaseEnvironmentBlock}
1280
+ command: ["npm", "run", "schema:apply:dev"]${developmentEnvironmentBlock}
1303
1281
  volumes:
1304
1282
  ${devVolumes.join("\n")}
1305
1283
  depends_on:
@@ -1312,7 +1290,7 @@ ${devVolumes.join("\n")}
1312
1290
  env_file:
1313
1291
  - .env.development
1314
1292
  ports:
1315
- - "3000:3000"${databaseEnvironmentBlock}
1293
+ - "3000:3000"${developmentEnvironmentBlock}
1316
1294
  volumes:
1317
1295
  ${devVolumes.join("\n")}${devDependsBlock}${devWorker}${devDatabase}${devRedis}
1318
1296
  volumes:
@@ -1323,7 +1301,6 @@ for (const [relative, content] of Object.entries(ownedSources({ kind: "service",
1323
1301
  add(`${svc}/${relative}`, content);
1324
1302
  }
1325
1303
  if (options.ui) {
1326
- const uiProfiles = [...(options.auth === "firebase" ? ["firebase"] : []), "notifications"];
1327
1304
  for (const [relative, content] of Object.entries(ownedSources({ kind: "ui", profiles: uiProfiles }))) {
1328
1305
  add(`${ui}/${relative}`, content);
1329
1306
  }
@@ -1336,10 +1313,8 @@ for (const [relative, content] of Object.entries(files)) {
1336
1313
  if (relative.endsWith(".sh")) fs.chmodSync(destination, 0o755);
1337
1314
  }
1338
1315
 
1339
- for (const repository of [svc, ...(options.ui ? [ui] : [])]) {
1340
- const result = runSync(path.join(root, repository), "write", contractVersion);
1341
- if (result.errors.length) fail(result.errors.join("\n"));
1342
- }
1316
+ const syncResult = runSync(root, "write", contractVersion);
1317
+ if (syncResult.errors.length) fail(syncResult.errors.join("\n"));
1343
1318
 
1344
- return { root, name: options.name, database: options.db, authentication: options.auth, ui: options.ui, redis: options.redis, worker: options.worker, repositories: [svc, ...(options.ui ? [ui] : [])], files: Object.keys(files).length };
1319
+ return { root, name: options.name, database: options.db, authentication: options.auth, ui: options.ui, redis: options.redis, worker: options.worker, applications: [svc, ...(options.ui ? [ui] : [])], files: Object.keys(files).length };
1345
1320
  }