@zaaxch/tailframe 2.2.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/tailframe.mjs +43 -13
- package/package.json +1 -1
- package/src/architecture.mjs +13 -8
- package/src/config.mjs +93 -0
- package/src/conventions.mjs +17 -3
- package/src/exceptions.mjs +16 -6
- package/src/generate.mjs +16 -6
- package/src/new.mjs +324 -201
- package/src/owned-guidance.mjs +27 -0
- package/src/owned-sources.mjs +222 -0
- package/src/service-templates.mjs +222 -19
- package/src/sync.mjs +64 -0
- package/src/ui-templates.mjs +135 -1
- package/src/validate.mjs +90 -0
package/src/new.mjs
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { authAppStoreSource, GenerateError } from "./generate.mjs";
|
|
4
|
+
import { configSource } from "./config.mjs";
|
|
5
|
+
import { ownedSources } from "./owned-sources.mjs";
|
|
6
|
+
import { runSync } from "./sync.mjs";
|
|
4
7
|
import {
|
|
5
8
|
appErrorSource,
|
|
6
9
|
applicationRoutesSource,
|
|
@@ -16,6 +19,7 @@ import {
|
|
|
16
19
|
healthSchemasSource,
|
|
17
20
|
httpErrorsSource,
|
|
18
21
|
mongoReadinessProbeSource,
|
|
22
|
+
postgresReadinessProbeSource,
|
|
19
23
|
readinessProbeSource,
|
|
20
24
|
redisReadinessProbeSource,
|
|
21
25
|
redisSource,
|
|
@@ -42,13 +46,14 @@ function parseArgs(argv) {
|
|
|
42
46
|
const result = { db: "mongo", auth: "none", ui: false, redis: false, worker: false };
|
|
43
47
|
for (let i = 0; i < argv.length; i += 1) {
|
|
44
48
|
const arg = argv[i];
|
|
45
|
-
if (["--name", "--path", "--auth"].includes(arg)) result[arg.slice(2)] = argv[++i];
|
|
49
|
+
if (["--name", "--path", "--auth", "--db"].includes(arg)) result[arg.slice(2)] = argv[++i];
|
|
46
50
|
else if (["--ui", "--redis", "--worker"].includes(arg)) result[arg.slice(2)] = true;
|
|
47
51
|
else fail(`Unknown argument: ${arg}`);
|
|
48
52
|
}
|
|
49
53
|
if (!result.name || !/^[a-z][a-z0-9-]*$/.test(result.name)) fail("--name must use lowercase letters, digits, and hyphens and start with a letter");
|
|
50
54
|
if (!result.path) fail("--path is required");
|
|
51
55
|
if (!["none", "firebase"].includes(result.auth)) fail("--auth must be none or firebase");
|
|
56
|
+
if (!["mongo", "postgres"].includes(result.db)) fail("--db must be mongo or postgres");
|
|
52
57
|
if (result.worker) result.redis = true;
|
|
53
58
|
return result;
|
|
54
59
|
}
|
|
@@ -61,16 +66,31 @@ if (!fs.existsSync(parent) || !fs.statSync(parent).isDirectory()) fail(`Parent d
|
|
|
61
66
|
if (fs.existsSync(root) && fs.readdirSync(root).length) fail(`Refusing to overwrite non-empty directory: ${root}`);
|
|
62
67
|
|
|
63
68
|
const title = options.name.split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join(" ");
|
|
64
|
-
const svc =
|
|
65
|
-
const ui =
|
|
69
|
+
const svc = "apps/service";
|
|
70
|
+
const ui = "apps/ui";
|
|
71
|
+
const servicePackage = `@${options.name}/service`;
|
|
72
|
+
const uiPackage = `@${options.name}/ui`;
|
|
66
73
|
const databaseName = options.name.replaceAll("-", "_");
|
|
74
|
+
const postgres = options.db === "postgres";
|
|
67
75
|
const mongoApplicationUser = `${options.name}-app`;
|
|
68
76
|
const mongoBackupUser = `${options.name}-backup`;
|
|
69
77
|
const files = {};
|
|
70
78
|
const add = (relative, content) => { files[relative] = content.endsWith("\n") ? content : `${content}\n`; };
|
|
71
|
-
// Generated
|
|
79
|
+
// Generated products depend on the published toolkit rather than copying a validator, and they
|
|
72
80
|
// pin the exact contract version they were generated against.
|
|
73
81
|
const contractVersion = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
82
|
+
const serviceProfiles = [
|
|
83
|
+
...(options.auth === "firebase" ? ["firebase"] : []),
|
|
84
|
+
options.db,
|
|
85
|
+
...(options.redis ? ["redis", "rate-limit"] : []),
|
|
86
|
+
...(options.worker ? ["worker"] : []),
|
|
87
|
+
...(options.ui ? ["ui-host"] : [])
|
|
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
|
+
];
|
|
74
94
|
const prettierConfig = {
|
|
75
95
|
useTabs: true,
|
|
76
96
|
tabWidth: 4,
|
|
@@ -92,92 +112,102 @@ const gitAttributes = `* text=auto eol=lf
|
|
|
92
112
|
|
|
93
113
|
add(".prettierrc.json", JSON.stringify(prettierConfig, null, "\t"));
|
|
94
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
|
+
allowBuilds:
|
|
135
|
+
"@firebase/util": true
|
|
136
|
+
esbuild: true
|
|
137
|
+
protobufjs: true
|
|
138
|
+
unrs-resolver: true
|
|
139
|
+
`);
|
|
140
|
+
add("tailframe.json", configSource({ apps, contractVersion }));
|
|
95
141
|
|
|
96
142
|
const repos = [[svc, "Express/TypeScript API and background runtime."]];
|
|
97
143
|
if (options.ui) repos.push([ui, "Vue/Vite customer interface."]);
|
|
98
144
|
|
|
99
|
-
add("AGENTS.md", `# ${title}
|
|
145
|
+
add("AGENTS.md", `# ${title} product guidance
|
|
100
146
|
|
|
101
147
|
## Sources of truth
|
|
102
148
|
|
|
103
149
|
- Treat implementation and tests as the source of truth for current behavior.
|
|
104
|
-
-
|
|
105
|
-
-
|
|
150
|
+
- Treat \`tailframe.json\` and the root pnpm workspace as the product architecture and dependency boundary.
|
|
151
|
+
- Read the nearest application \`AGENTS.md\` before changing files under \`apps/\`.
|
|
106
152
|
|
|
107
153
|
## Product boundary
|
|
108
154
|
|
|
109
|
-
The product domain is not yet defined. Do not invent domain entities, workflows, claims, roles, or integrations.
|
|
155
|
+
The product domain is not yet defined. Do not invent domain entities, workflows, claims, roles, or integrations.
|
|
110
156
|
|
|
111
157
|
## Repository map
|
|
112
158
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
${repos.map(([name, description]) => `- \`${name}/\` — ${description} Read \`${name}/AGENTS.md\` before editing it.`).join("\n")}
|
|
159
|
+
- \`apps/service/\` Express/TypeScript API and background runtime.
|
|
160
|
+
${options.ui ? "- \`apps/ui/\` Vue/Vite customer interface.\n" : ""}- The product root owns Git history, the pnpm lockfile, Tailframe metadata, orchestration, CI, and deployment.
|
|
116
161
|
|
|
117
|
-
## Cross-
|
|
162
|
+
## Cross-application changes
|
|
118
163
|
|
|
119
|
-
1. Read
|
|
120
|
-
2.
|
|
121
|
-
3.
|
|
122
|
-
4.
|
|
123
|
-
5. Do not claim end-to-end behavior from changes or static checks in only one repository.
|
|
164
|
+
1. Read the root and applicable application guidance.
|
|
165
|
+
2. Keep shared wire contracts aligned across service and UI.
|
|
166
|
+
3. Run focused checks while iterating and product-root checks before handoff.
|
|
167
|
+
4. Do not claim end-to-end behavior from changes or static checks in only one application.
|
|
124
168
|
|
|
125
169
|
## Module workflow
|
|
126
170
|
|
|
127
|
-
- Use the local \`new-module\` skill whenever creating or extending a domain module, public operation, persisted entity, or client capability
|
|
128
|
-
-
|
|
129
|
-
- Do not wait for a task to become cross-repository before using the skill.
|
|
130
|
-
- Create architecture files with \`tailframe generate\` in the affected repository; the skill orchestrates the work, the CLI creates the files.
|
|
171
|
+
- Use the local \`new-module\` skill whenever creating or extending a domain module, public operation, persisted entity, or client capability.
|
|
172
|
+
- Create architecture files with \`tailframe generate --app service|ui\` from the product root.
|
|
131
173
|
|
|
132
174
|
## Code-change guidelines
|
|
133
175
|
|
|
134
|
-
- Keep changes
|
|
135
|
-
-
|
|
136
|
-
- Preserve unrelated worktree changes.
|
|
137
|
-
- Confirm every committed change belongs to the requested scope.
|
|
176
|
+
- Keep changes focused and preserve unrelated worktree changes.
|
|
177
|
+
- Do not create nested repositories or package-manager boundaries under \`apps/\`.
|
|
138
178
|
|
|
139
179
|
## GitHub workflow
|
|
140
180
|
|
|
141
181
|
- Use \`gh\` for GitHub interactions when it supports the action.
|
|
142
|
-
- Inspect existing issues and pull requests before proposing duplicates.
|
|
143
182
|
- Never mutate GitHub state without explicit authorization.
|
|
144
|
-
- Verify external mutations with a read-only follow-up.
|
|
145
183
|
|
|
146
184
|
## GitHub authentication and sandboxing
|
|
147
185
|
|
|
148
|
-
- All \`gh\`, AWS/ECR, and Docker commands must run outside the sandbox. Request approval before the first attempt
|
|
149
|
-
-
|
|
150
|
-
- AWS/ECR commands include \`aws ecr\` authentication and package scripts that build or push ECR images.
|
|
151
|
-
- Never print, log, commit, or include GitHub tokens in command output.
|
|
152
|
-
- Keep mutation approvals separate from read-only GitHub access.
|
|
153
|
-
|
|
154
|
-
## Starting work from a GitHub issue
|
|
155
|
-
|
|
156
|
-
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.
|
|
186
|
+
- All \`gh\`, AWS/ECR, and Docker commands must run outside the sandbox. Request approval before the first attempt.
|
|
187
|
+
- Never print, log, commit, or include credentials or tokens in command output.
|
|
157
188
|
`);
|
|
158
|
-
|
|
159
189
|
add(".agents/skills/new-module/SKILL.md", `---
|
|
160
190
|
name: new-module
|
|
161
|
-
description: Create or extend a domain-colocated module in
|
|
191
|
+
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.
|
|
162
192
|
---
|
|
163
193
|
|
|
164
194
|
# New module
|
|
165
195
|
|
|
166
196
|
Use this workflow for any new domain capability or operation. Add only the layers earned by actual behavior.
|
|
167
197
|
|
|
168
|
-
1. Identify the module, requested operations, entry points, and
|
|
198
|
+
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.
|
|
169
199
|
2. Read the root and every applicable child \`AGENTS.md\`.
|
|
170
200
|
3. Inspect the nearest working module and tests.
|
|
171
|
-
4. Create architecture files only with the tailframe CLI: \`tailframe generate
|
|
172
|
-
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\` and \`theme.store.ts\`; \`app/public\` is closed to generated \`ThemeToggle.vue\`. Never create \`src/modules/theme\` or \`src/modules/
|
|
201
|
+
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.
|
|
202
|
+
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.
|
|
173
203
|
6. Define use cases as \`execute(context, input)\`. Keep trusted \`RequestContext\` separate from client input.
|
|
174
204
|
7. Put ownership, RBAC, entitlements, and domain policies in use-case or domain code, not client bodies or route metadata.
|
|
175
205
|
8. Define repository contracts in the owning module and implement them in persistence adapters.
|
|
176
|
-
9. For persisted capabilities, prefer branded, domain-owned string IDs at domain and repository-port boundaries; generate
|
|
206
|
+
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.
|
|
177
207
|
10. Let HTTP routes, jobs, workers, scheduled tasks, and CLIs invoke use cases directly; do not call one entry point from another.
|
|
178
|
-
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/theme app stores and \`ThemeToggle\` from app; every other app path is private.
|
|
208
|
+
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.
|
|
179
209
|
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.
|
|
180
|
-
13. Run
|
|
210
|
+
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.
|
|
181
211
|
|
|
182
212
|
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.
|
|
183
213
|
`);
|
|
@@ -189,25 +219,26 @@ const svcDeps = {
|
|
|
189
219
|
joi: "^17.13.3",
|
|
190
220
|
"reflect-metadata": "^0.2.2",
|
|
191
221
|
tsyringe: "^4.9.1",
|
|
192
|
-
mongodb: "^6.17.0",
|
|
222
|
+
...(postgres ? { pg: "^8.16.3" } : { mongodb: "^6.17.0" }),
|
|
193
223
|
...(options.auth === "firebase" ? { "firebase-admin": "^13.0.0" } : {}),
|
|
194
|
-
...(options.redis ? { redis: "^5.10.0" } : {})
|
|
224
|
+
...(options.redis ? { "rate-limiter-flexible": "^11.2.0", redis: "^5.10.0" } : {})
|
|
195
225
|
};
|
|
196
226
|
const svcDevDeps = {
|
|
197
227
|
"@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",
|
|
198
|
-
"@
|
|
228
|
+
...(postgres ? { "@types/pg": "^8.15.5" } : {}),
|
|
199
229
|
jest: "^30.0.0", nodemon: "^3.1.9", prettier: "3.5.3", "ts-jest": "^29.4.11", "ts-node": "^10.9.2",
|
|
200
230
|
"tsc-alias": "^1.8.16", "tsconfig-paths": "^4.2.0", typescript: "^5.8.2", supertest: "^7.2.2"
|
|
201
231
|
};
|
|
202
232
|
add(`${svc}/package.json`, JSON.stringify({
|
|
203
|
-
name:
|
|
233
|
+
name: servicePackage, version: "0.1.0", private: true, main: "dist/server.js", files: ["dist"],
|
|
204
234
|
engines: { node: ">=22.13.0" },
|
|
205
235
|
scripts: {
|
|
206
236
|
dev: "nodemon --legacy-watch --exec ts-node -r tsconfig-paths/register src/server.ts",
|
|
207
237
|
...(options.worker ? { "dev:worker": "nodemon --legacy-watch --exec ts-node -r tsconfig-paths/register src/worker.ts" } : {}),
|
|
208
238
|
build: "tsc && tsc-alias", start: "node dist/server.js",
|
|
209
239
|
...(options.worker ? { worker: "node dist/worker.js" } : {}),
|
|
210
|
-
test: "
|
|
240
|
+
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/",
|
|
241
|
+
"schema:apply": "node dist/app/cli/applySchema.js", "schema:apply:dev": "ts-node -r tsconfig-paths/register src/app/cli/applySchema.ts",
|
|
211
242
|
"test:db:up": "docker compose -f docker-compose.test.yml up -d", "test:db:down": "docker compose -f docker-compose.test.yml down -v",
|
|
212
243
|
"docker:dev": "docker compose -f docker-compose.dev.yml up",
|
|
213
244
|
"docker:prod": "docker compose -f docker-compose.yml up -d",
|
|
@@ -228,17 +259,23 @@ add(`${svc}/.gitattributes`, gitAttributes);
|
|
|
228
259
|
add(`${svc}/.gitignore`, `node_modules/\ndist/\n.env*\n!.env.example\n!.env.infrastructure.example\n*.log\nfirebase-service-account*.json\n`);
|
|
229
260
|
add(`${svc}/.dockerignore`, `node_modules\ndist\n.git\n.env*\n!.env.example\n*.log\ndata\nfirebase-service-account*.json\n`);
|
|
230
261
|
if (options.ui) add(".dockerignore", `**/node_modules\n**/dist\n**/.git\n**/.env*\n**/*.log\n**/data\n**/firebase-service-account*.json\n`);
|
|
231
|
-
const dbEnv =
|
|
262
|
+
const dbEnv = postgres
|
|
263
|
+
? `POSTGRES_URI=postgresql://${options.name}-app:development@localhost:5432/${databaseName}`
|
|
264
|
+
: "MONGODB_URI=mongodb://localhost:27017/?replicaSet=rs0&directConnection=true\nMONGODB_DB_NAME=" + databaseName;
|
|
232
265
|
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
266
|
add(`${svc}/.env.infrastructure.example`, `ECR_IMAGE=<account>.dkr.ecr.<region>.amazonaws.com/${options.name}
|
|
234
|
-
IMAGE_TAG=<
|
|
267
|
+
IMAGE_TAG=<product-sha>
|
|
235
268
|
APP_ENV_FILE=/opt/${options.name}/shared/.env.production
|
|
236
|
-
|
|
269
|
+
${postgres ? `POSTGRES_DATA_DIR=/opt/${options.name}/shared/postgres/data
|
|
270
|
+
POSTGRES_ROOT_PASSWORD=<uri-safe-random-value>
|
|
271
|
+
POSTGRES_APP_PASSWORD=<different-uri-safe-random-value>
|
|
272
|
+
` : `MONGO_DATA_DIR=/opt/${options.name}/shared/mongo/data
|
|
237
273
|
MONGO_KEYFILE=/opt/${options.name}/shared/mongo/config/keyfile
|
|
238
274
|
MONGO_INIT_SCRIPT=/opt/${options.name}/current/deploy/mongo/10-create-users.js
|
|
239
275
|
MONGODB_ROOT_PASSWORD=<uri-safe-random-value>
|
|
240
276
|
MONGODB_APP_PASSWORD=<different-uri-safe-random-value>
|
|
241
277
|
MONGODB_BACKUP_PASSWORD=<different-uri-safe-random-value>
|
|
278
|
+
`}
|
|
242
279
|
${options.auth === "firebase" ? `SECRETS_DIR=/opt/${options.name}/shared/secrets
|
|
243
280
|
` : ""}${options.redis ? `REDIS_DATA_DIR=/opt/${options.name}/shared/redis/data
|
|
244
281
|
REDIS_PASSWORD=<different-uri-safe-random-value>
|
|
@@ -248,12 +285,25 @@ export const env = {
|
|
|
248
285
|
nodeEnv: process.env.NODE_ENV ?? "development",
|
|
249
286
|
port: Number(process.env.PORT ?? 3000),
|
|
250
287
|
corsOrigin: process.env.CORS_ORIGIN ?? "https://localhost:5173",
|
|
251
|
-
${options.auth === "firebase" ? `firebaseProjectId: process.env.FIREBASE_PROJECT_ID ?? ""
|
|
252
|
-
|
|
253
|
-
|
|
288
|
+
${options.auth === "firebase" ? `firebaseProjectId: process.env.FIREBASE_PROJECT_ID ?? "",
|
|
289
|
+
firebaseServiceAccountPath: process.env.FIREBASE_SERVICE_ACCOUNT_PATH,` : ""}
|
|
290
|
+
${postgres ? `postgresUri: process.env.POSTGRES_URI ?? "postgresql://${options.name}-app:development@localhost:5432/${databaseName}",` : `mongodbUri: process.env.MONGODB_URI ?? "mongodb://localhost:27017/?replicaSet=rs0&directConnection=true",
|
|
291
|
+
mongodbDbName: process.env.MONGODB_DB_NAME ?? "${databaseName}",`}
|
|
254
292
|
${options.redis ? `redisUrl: process.env.REDIS_URL ?? "redis://localhost:6379",` : ""}
|
|
255
293
|
};`);
|
|
256
|
-
const databaseSource = `import {
|
|
294
|
+
const databaseSource = postgres ? `import { Pool } from "pg";
|
|
295
|
+
import { env } from "@/platform/config/env";
|
|
296
|
+
|
|
297
|
+
export const postgres = new Pool({ connectionString: env.postgresUri });
|
|
298
|
+
|
|
299
|
+
export async function connectDatabase() {
|
|
300
|
+
await postgres.query("SELECT 1");
|
|
301
|
+
return postgres;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export async function closeDatabase() {
|
|
305
|
+
await postgres.end();
|
|
306
|
+
}` : `import { MongoClient } from "mongodb";
|
|
257
307
|
import { env } from "@/platform/config/env";
|
|
258
308
|
|
|
259
309
|
export const mongo = new MongoClient(env.mongodbUri);
|
|
@@ -267,6 +317,15 @@ export async function closeDatabase() {
|
|
|
267
317
|
await mongo.close();
|
|
268
318
|
}`;
|
|
269
319
|
add(`${svc}/src/platform/database/index.ts`, databaseSource);
|
|
320
|
+
add(`${svc}/src/app/schema.ts`, `import type { SchemaLifecycle } from "@/core/SchemaLifecycle";
|
|
321
|
+
import { closeDatabase, connectDatabase } from "@/platform/database";
|
|
322
|
+
|
|
323
|
+
export const schemaLifecycle: SchemaLifecycle = {
|
|
324
|
+
async apply() {
|
|
325
|
+
await connectDatabase();
|
|
326
|
+
},
|
|
327
|
+
close: closeDatabase
|
|
328
|
+
};`);
|
|
270
329
|
if (options.redis) add(`${svc}/src/platform/redis/index.ts`, redisSource);
|
|
271
330
|
add(`${svc}/src/core/RequestContext.ts`, requestContextSource);
|
|
272
331
|
add(`${svc}/src/core/UseCase.ts`, useCaseSource);
|
|
@@ -283,9 +342,10 @@ add(`${svc}/src/modules/health/use-cases/GetReadiness.ts`, getReadinessSource);
|
|
|
283
342
|
add(`${svc}/src/modules/health/use-cases/ports/ReadinessProbe.ts`, readinessProbeSource);
|
|
284
343
|
add(`${svc}/src/modules/health/http/health.schemas.ts`, healthSchemasSource);
|
|
285
344
|
add(`${svc}/src/modules/health/http/health.routes.ts`, healthRoutesSource);
|
|
286
|
-
add(`${svc}/src/platform/integrations/
|
|
345
|
+
if (postgres) add(`${svc}/src/platform/integrations/postgres/PostgresReadinessProbe.ts`, postgresReadinessProbeSource);
|
|
346
|
+
else add(`${svc}/src/platform/integrations/mongodb/MongoReadinessProbe.ts`, mongoReadinessProbeSource);
|
|
287
347
|
if (options.redis) add(`${svc}/src/platform/integrations/redis/RedisReadinessProbe.ts`, redisReadinessProbeSource);
|
|
288
|
-
add(`${svc}/src/app/container.ts`, containerSource({ redis: options.redis }));
|
|
348
|
+
add(`${svc}/src/app/container.ts`, containerSource({ database: options.db, redis: options.redis }));
|
|
289
349
|
add(`${svc}/src/app/routes.ts`, applicationRoutesSource);
|
|
290
350
|
add(`${svc}/src/app/server.ts`, serverSource({ ui: options.ui, redis: options.redis, csrf }));
|
|
291
351
|
add(`${svc}/src/server.ts`, `import "reflect-metadata";\nimport { startServer } from "@/app/server";\nvoid startServer();\n`);
|
|
@@ -293,13 +353,34 @@ if (options.worker) add(`${svc}/src/app/workers/startWorker.ts`, workerSource())
|
|
|
293
353
|
if (options.worker) add(`${svc}/src/worker.ts`, `import "reflect-metadata";\nimport { startWorker } from "@/app/workers/startWorker";\nvoid startWorker();\n`);
|
|
294
354
|
add(`${svc}/src/modules/health/__tests__/GetHealth.test.ts`, getHealthTestSource);
|
|
295
355
|
add(`${svc}/src/modules/health/__tests__/GetReadiness.test.ts`, getReadinessTestSource);
|
|
296
|
-
add(`${svc}/src/__tests__/testDb.ts`, `import {
|
|
356
|
+
add(`${svc}/src/__tests__/testDb.ts`, postgres ? `import { Pool } from "pg";
|
|
357
|
+
const pool = new Pool({ connectionString: process.env.TEST_POSTGRES_URI ?? "postgresql://postgres:postgres@localhost:5433/${databaseName}_test" });
|
|
358
|
+
export async function openTestDatabase() { await pool.query("SELECT 1"); return pool; }
|
|
359
|
+
export async function resetTestDatabase() { await pool.query("DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public"); return pool; }
|
|
360
|
+
export async function closeTestDatabase() { await pool.end(); }
|
|
361
|
+
` : `import { MongoClient } from "mongodb";
|
|
297
362
|
const client = new MongoClient(process.env.TEST_MONGODB_URI ?? "mongodb://localhost:27018/?replicaSet=rs0&directConnection=true");
|
|
298
363
|
export async function openTestDatabase() { await client.connect(); return client.db(process.env.TEST_MONGODB_DB_NAME ?? "${options.name.replaceAll("-", "_")}_test"); }
|
|
299
364
|
export async function resetTestDatabase() { const database = await openTestDatabase(); await database.dropDatabase(); return database; }
|
|
300
365
|
export async function closeTestDatabase() { await client.close(); }
|
|
301
366
|
`);
|
|
302
|
-
add(`${svc}/docker-compose.test.yml`, `name: ${options.name}-test
|
|
367
|
+
add(`${svc}/docker-compose.test.yml`, postgres ? `name: ${options.name}-test
|
|
368
|
+
services:
|
|
369
|
+
postgres:
|
|
370
|
+
image: postgres:17
|
|
371
|
+
ports:
|
|
372
|
+
- "5433:5432"
|
|
373
|
+
environment:
|
|
374
|
+
POSTGRES_DB: ${databaseName}_test
|
|
375
|
+
POSTGRES_PASSWORD: postgres
|
|
376
|
+
tmpfs:
|
|
377
|
+
- /var/lib/postgresql/data
|
|
378
|
+
healthcheck:
|
|
379
|
+
test: ["CMD-SHELL", "pg_isready -U postgres -d ${databaseName}_test"]
|
|
380
|
+
interval: 2s
|
|
381
|
+
timeout: 2s
|
|
382
|
+
retries: 30
|
|
383
|
+
` : `name: ${options.name}-test
|
|
303
384
|
services:
|
|
304
385
|
mongo:
|
|
305
386
|
image: mongo:8
|
|
@@ -410,7 +491,7 @@ These conventions apply to \`${svc}\`. This service is a modular monolith organi
|
|
|
410
491
|
| --- | --- |
|
|
411
492
|
| Product rule, query, command, or policy | \`src/modules/<module>/use-cases\` or \`domain\` |
|
|
412
493
|
| Repository port | Owning module's \`use-cases/ports\` |
|
|
413
|
-
|
|
|
494
|
+
| Database implementation | Owning module's \`persistence\` |
|
|
414
495
|
| Express route or request schema | Owning module's \`http\` |
|
|
415
496
|
| Vendor or external-system adapter | \`src/platform/integrations/<provider>\` |
|
|
416
497
|
| Database, Redis, authentication, or HTTP mechanism | \`src/platform\` |
|
|
@@ -444,11 +525,13 @@ Product vocabulary stays in the module that owns its meaning; never move it into
|
|
|
444
525
|
- Define repository contracts under the owning module's \`use-cases/ports\`.
|
|
445
526
|
- Implement database-specific adapters under that module's \`persistence/\` directory.
|
|
446
527
|
- 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.
|
|
447
|
-
- Repository ports and use cases MUST NOT expose database identifier types. Persistence adapters SHOULD define persistence-only
|
|
528
|
+
- 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.
|
|
448
529
|
- Add appropriate schema or validation, indexes, initialization or migrations, and focused tests.
|
|
449
530
|
|
|
450
531
|
## Project-specific persistence
|
|
451
|
-
|
|
532
|
+
${postgres
|
|
533
|
+
? "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."
|
|
534
|
+
: "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."}
|
|
452
535
|
|
|
453
536
|
## Queries and commands
|
|
454
537
|
- Commands enforce invariants and change state.
|
|
@@ -464,19 +547,20 @@ This service uses MongoDB. Module-owned MongoDB adapters own collection access,
|
|
|
464
547
|
${options.ui ? "- In production, serve the compiled UI from `dist/public` with a non-API SPA fallback. Never return the SPA for `/api` requests." : ""}
|
|
465
548
|
|
|
466
549
|
## Production image
|
|
467
|
-
The ECR build targets \`linux/amd64\`. ${options.ui ? "Its default immutable tag is `<
|
|
550
|
+
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.` : ""}
|
|
468
551
|
|
|
469
552
|
## Production deployment
|
|
470
553
|
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
554
|
|
|
472
555
|
## Tests and validation
|
|
473
|
-
Run \`
|
|
556
|
+
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.
|
|
474
557
|
`);
|
|
475
558
|
|
|
476
559
|
if (options.ui) {
|
|
477
560
|
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" } : {}) };
|
|
478
|
-
const uiDevDeps = { "@tsconfig/node22": "^22.0.1", "@
|
|
479
|
-
add(`${ui}/package.json`, JSON.stringify({ name:
|
|
561
|
+
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" };
|
|
562
|
+
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"));
|
|
563
|
+
|
|
480
564
|
add(`${ui}/.prettierrc.json`, JSON.stringify(prettierConfig, null, "\t"));
|
|
481
565
|
add(`${ui}/.gitattributes`, gitAttributes);
|
|
482
566
|
add(`${ui}/tsconfig.json`, JSON.stringify({ files: [], references: [{ path: "./tsconfig.app.json" }, { path: "./tsconfig.node.json" }] }, null, "\t"));
|
|
@@ -515,8 +599,10 @@ export default createRouter({
|
|
|
515
599
|
});`);
|
|
516
600
|
add(`${ui}/src/platform/http.ts`, uiHttpSource);
|
|
517
601
|
if (options.auth === "firebase") add(`${ui}/src/app/configureHttp.ts`, configureHttpSource);
|
|
602
|
+
add(`${ui}/src/platform/config.ts`, `export const apiBaseUrl = \`\${window.location.origin}/api/v1\`;`);
|
|
518
603
|
add(`${ui}/src/modules/health/api/health.api.ts`, healthApiSource);
|
|
519
604
|
add(`${ui}/src/modules/health/views/HealthView.vue`, `<script setup lang="ts">
|
|
605
|
+
import Button from "primevue/button";
|
|
520
606
|
import { ref } from "vue";
|
|
521
607
|
import { isServiceError } from "@/core/errors";
|
|
522
608
|
import { getReadiness } from "@/modules/health/api/health.api";
|
|
@@ -544,35 +630,43 @@ async function checkReadiness() {
|
|
|
544
630
|
<p class="font-bold uppercase tracking-[0.12em] text-emerald-800">Architecture reference</p>
|
|
545
631
|
<h1 class="m-0 text-5xl font-bold sm:text-7xl">${title}</h1>
|
|
546
632
|
<p>The product domain is intentionally undefined.</p>
|
|
547
|
-
<
|
|
633
|
+
<Button
|
|
548
634
|
type="button"
|
|
635
|
+
:label="loading ? 'Checking…' : 'Check readiness'"
|
|
549
636
|
class="self-start rounded-full bg-emerald-800 px-4 py-3 text-white disabled:opacity-60"
|
|
550
637
|
:disabled="loading"
|
|
638
|
+
:loading="loading"
|
|
551
639
|
@click="checkReadiness"
|
|
552
|
-
|
|
553
|
-
{{ loading ? "Checking…" : "Check readiness" }}
|
|
554
|
-
</button>
|
|
640
|
+
/>
|
|
555
641
|
<p v-if="status" role="status">Service status: {{ status }}</p>
|
|
556
642
|
<p v-if="error" role="alert">{{ error }}</p>
|
|
557
643
|
</main>
|
|
558
644
|
</template>`);
|
|
559
|
-
add(`${ui}/src/app/App.vue`, `<template
|
|
645
|
+
add(`${ui}/src/app/App.vue`, `<template>
|
|
646
|
+
<RouterView />
|
|
647
|
+
<NotificationHost />
|
|
648
|
+
</template>
|
|
649
|
+
|
|
650
|
+
<script setup lang="ts">
|
|
651
|
+
import { RouterView } from "vue-router";
|
|
652
|
+
import NotificationHost from "@/app/components/NotificationHost.vue";
|
|
653
|
+
</script>`);
|
|
560
654
|
add(`${ui}/src/app/startApplication.ts`, `import { createApp } from "vue";\nimport { createPinia } from "pinia";\nimport PrimeVue from "primevue/config";\nimport App from "@/app/App.vue";\n${options.auth === "firebase" ? 'import { configureHttp } from "@/app/configureHttp";\n' : ""}import router from "@/app/router";\nimport "@/app/styles.css";\nexport function startApplication() {\n\tconst app = createApp(App);\n\tconst pinia = createPinia();\n\tapp.use(pinia).use(PrimeVue).use(router);\n\t${options.auth === "firebase" ? "configureHttp(pinia);\n\t" : ""}app.mount("#app");\n}\n`);
|
|
561
655
|
add(`${ui}/src/main.ts`, `import { startApplication } from "@/app/startApplication";\nstartApplication();\n`);
|
|
562
656
|
add(`${ui}/src/app/styles.css`, `@import "tailwindcss";\n:root { font-family: Inter, ui-sans-serif, system-ui; color: #17221c; background: #f5f3ec; }\nbody { margin: 0; }`);
|
|
563
|
-
add(`${ui}/src/app/__tests__/App.spec.ts`, `import { mount } from "@vue/test-utils";\nimport App from "@/app/App.vue";\ndescribe("App", () => { it("renders a router view", () => { expect(mount(App, { global: { stubs: ["RouterView"] } }).exists()).toBe(true); }); });`);
|
|
657
|
+
add(`${ui}/src/app/__tests__/App.spec.ts`, `import { mount } from "@vue/test-utils";\nimport App from "@/app/App.vue";\ndescribe("App", () => { it("renders a router view", () => { expect(mount(App, { global: { stubs: ["RouterView", "NotificationHost"] } }).exists()).toBe(true); }); });`);
|
|
564
658
|
add(`${ui}/AGENTS.md`, `# ${title} UI guidance
|
|
565
659
|
|
|
566
660
|
## Scope
|
|
567
661
|
These conventions apply to \`${ui}/src\`. Inspect the nearest working module before adding files.
|
|
568
662
|
|
|
569
663
|
## Architecture vocabulary
|
|
570
|
-
- Use \`src/app\` for Vue bootstrap, the application shell, routing, global styles, and the closed Tailframe auth/theme catalog.
|
|
664
|
+
- Use \`src/app\` for Vue bootstrap, the application shell, routing, global styles, and the closed Tailframe auth/notification/theme catalog.
|
|
571
665
|
- Use \`src/core\` only for small technology-neutral client contracts.
|
|
572
666
|
- Use \`src/platform\` for Axios, Firebase, browser APIs, and other technical adapters.
|
|
573
667
|
- Use \`src/modules/<module>\` for every product capability. Never substitute \`src/features\` or global \`apis\`, \`views\`, \`services\`, \`stores\`, or \`types\` directories.
|
|
574
|
-
- 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\` 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\`.
|
|
575
|
-
- Never create \`src/modules/theme\` or \`src/modules/
|
|
668
|
+
- 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\`.
|
|
669
|
+
- 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.
|
|
576
670
|
|
|
577
671
|
## Change placement
|
|
578
672
|
| Change | Canonical owner |
|
|
@@ -580,14 +674,14 @@ These conventions apply to \`${ui}/src\`. Inspect the nearest working module bef
|
|
|
580
674
|
| Capability API, component, composable, state, type, or view | \`src/modules/<module>\` |
|
|
581
675
|
| Capability intentionally consumed by another module | Named file directly under provider \`src/modules/<module>/public\` |
|
|
582
676
|
| Axios, Firebase, browser, or vendor mechanism | \`src/platform\` |
|
|
583
|
-
| Firebase identity/readiness or theme preference | Generated \`src/app/stores/auth.store.ts\` or \`theme.store.ts\` |
|
|
677
|
+
| Firebase identity/readiness, notification queue, or theme preference | Generated \`src/app/stores/auth.store.ts\`, \`notification.store.ts\`, or \`theme.store.ts\` |
|
|
584
678
|
| Canonical theme control consumed by modules | Generated \`src/app/public/ThemeToggle.vue\` |
|
|
585
679
|
| Any other state or reusable presentation | Owning \`src/modules/<module>\` |
|
|
586
680
|
| Shell, router, global styles, or cross-capability composition | \`src/app\` |
|
|
587
681
|
| Technology-neutral client contract | Flat \`src/core\` |
|
|
588
682
|
| Root executable | Bootstrap of \`src/app\` only |
|
|
589
683
|
|
|
590
|
-
UI modules may import a sibling only through a named file directly under the provider's \`public/\` directory; keep the graph acyclic. From app, modules may import only generated \`auth.store.ts\`, \`theme.store.ts\`, and \`ThemeToggle.vue\`; every other app path is private. App views and shell components consume modules only through named module \`public/\` entries; route module views directly from \`app/router.ts\`. Use \`@/\` or relative internal imports; do not add another source alias. Platform imports neither app nor modules.
|
|
684
|
+
UI modules may import a sibling only through a named file directly under the provider's \`public/\` directory; keep the graph acyclic. From app, modules may import only generated \`auth.store.ts\`, \`notification.store.ts\`, \`theme.store.ts\`, and \`ThemeToggle.vue\`; every other app path is private. App views and shell components consume modules only through named module \`public/\` entries; route module views directly from \`app/router.ts\`. Use \`@/\` or relative internal imports; do not add another source alias. Platform imports neither app nor modules.
|
|
591
685
|
|
|
592
686
|
## Module shape
|
|
593
687
|
Under \`src/modules/<module>\`, add only needed \`api/\`, \`components/\`, \`composables/\`, \`public/\`, \`routes/\`, \`stores/\`, \`types/\`, \`views/\`, and \`__tests__/\` directories. Public entries are named direct files, never nested directories or barrels. Do not invent alternative layer names or generate speculative CRUD. Create module and public shell files only with \`tailframe generate\`.
|
|
@@ -602,10 +696,11 @@ Use named routes.${options.auth === "firebase" ? " Preserve Firebase authenticat
|
|
|
602
696
|
|
|
603
697
|
## State management
|
|
604
698
|
Keep local state local. Use Pinia only for state shared across routes or unrelated components.
|
|
605
|
-
Keep all product state in the owning module. The auth/theme stores and ThemeToggle are Tailframe-owned generated sources shared byte-for-byte across projects apart from the explicit theme storage key.
|
|
699
|
+
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.
|
|
606
700
|
|
|
607
701
|
## UI behavior
|
|
608
|
-
|
|
702
|
+
- 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.
|
|
703
|
+
- Reuse Vue, PrimeVue, Tailwind, loading, error, and accessibility conventions. Provide explicit loading, empty, error, and authorization states.
|
|
609
704
|
|
|
610
705
|
## Styling
|
|
611
706
|
- Prefer Tailwind utility classes in Vue templates over custom selectors and component-scoped CSS.
|
|
@@ -613,74 +708,44 @@ Reuse Vue, PrimeVue, Tailwind, loading, error, and accessibility conventions. Pr
|
|
|
613
708
|
- Keep \`src/app/styles.css\` limited to Tailwind imports, theme tokens, and true global base behavior.
|
|
614
709
|
|
|
615
710
|
## Production packaging
|
|
616
|
-
The UI runs through Vite in development but has no production container. The service's multi-stage Dockerfile builds this
|
|
711
|
+
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.` : ""}
|
|
617
712
|
|
|
618
713
|
## Product language
|
|
619
714
|
The product domain is undefined. Do not invent entities, workflows, roles, claims, navigation, or customer-facing promises.
|
|
620
715
|
|
|
621
716
|
## Validation
|
|
622
|
-
Run \`
|
|
717
|
+
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.
|
|
623
718
|
`);
|
|
624
719
|
}
|
|
625
720
|
|
|
626
|
-
add(`${svc}/Dockerfile`,
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
COPY
|
|
631
|
-
RUN
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
COPY ${svc}/package*.json ./
|
|
636
|
-
RUN npm ci --omit=dev && npm cache clean --force
|
|
637
|
-
|
|
638
|
-
FROM node:22.13-bookworm AS ui-build
|
|
639
|
-
WORKDIR /app/ui
|
|
640
|
-
COPY ${ui}/package*.json ./
|
|
641
|
-
RUN npm ci
|
|
642
|
-
COPY ${ui}/ ./
|
|
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"}
|
|
644
|
-
|
|
645
|
-
FROM node:22.13-bookworm-slim
|
|
646
|
-
WORKDIR /app
|
|
647
|
-
ENV NODE_ENV=production
|
|
648
|
-
COPY --from=service-build /app/service/dist ./dist
|
|
649
|
-
COPY --from=service-build /app/service/package*.json ./
|
|
650
|
-
COPY --from=service-production-dependencies /app/service/node_modules ./node_modules
|
|
651
|
-
COPY --from=ui-build /app/ui/dist ./dist/public
|
|
652
|
-
USER node
|
|
653
|
-
EXPOSE 3000
|
|
654
|
-
CMD ["node", "dist/server.js"]
|
|
655
|
-
` : `FROM node:22.13-bookworm AS build
|
|
656
|
-
WORKDIR /app
|
|
657
|
-
COPY package*.json ./
|
|
658
|
-
RUN npm ci
|
|
659
|
-
COPY . .
|
|
660
|
-
RUN npm run build
|
|
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
|
|
721
|
+
add(`${svc}/Dockerfile`, `FROM node:22.13-bookworm AS build
|
|
722
|
+
RUN corepack enable
|
|
723
|
+
WORKDIR /workspace
|
|
724
|
+
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
|
|
725
|
+
COPY apps/service/package.json apps/service/package.json
|
|
726
|
+
${options.ui ? "COPY apps/ui/package.json apps/ui/package.json\n" : ""}RUN pnpm install --frozen-lockfile
|
|
727
|
+
COPY apps/service apps/service
|
|
728
|
+
${options.ui ? "COPY apps/ui apps/ui\n" : ""}RUN pnpm --filter ${servicePackage} build
|
|
729
|
+
${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
|
|
666
730
|
|
|
667
731
|
FROM node:22.13-bookworm-slim
|
|
668
732
|
WORKDIR /app
|
|
669
733
|
ENV NODE_ENV=production
|
|
670
|
-
COPY --from=build /
|
|
671
|
-
COPY --from=build /
|
|
672
|
-
COPY --from=production-dependencies /app/node_modules ./node_modules
|
|
673
|
-
USER node
|
|
734
|
+
COPY --from=build /prod/service ./
|
|
735
|
+
${options.ui ? "COPY --from=build /workspace/apps/ui/dist ./dist/public\n" : ""}USER node
|
|
674
736
|
EXPOSE 3000
|
|
675
737
|
CMD ["node", "dist/server.js"]
|
|
676
738
|
`);
|
|
677
739
|
add(`${svc}/Dockerfile.dev`, `FROM node:22.13-alpine
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
COPY . .
|
|
740
|
+
RUN corepack enable
|
|
741
|
+
WORKDIR /workspace
|
|
742
|
+
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
|
|
743
|
+
COPY apps/service/package.json apps/service/package.json
|
|
744
|
+
RUN pnpm install --frozen-lockfile
|
|
745
|
+
COPY apps/service apps/service
|
|
746
|
+
WORKDIR /workspace/apps/service
|
|
682
747
|
EXPOSE 3000
|
|
683
|
-
CMD ["
|
|
748
|
+
CMD ["pnpm", "dev"]
|
|
684
749
|
`);
|
|
685
750
|
add(`${svc}/scripts/build_and_push.sh`, `#!/usr/bin/env bash
|
|
686
751
|
set -euo pipefail
|
|
@@ -688,54 +753,44 @@ set -euo pipefail
|
|
|
688
753
|
: "\${AWS_ACCOUNT_ID:?Set AWS_ACCOUNT_ID}"
|
|
689
754
|
: "\${AWS_REGION:?Set AWS_REGION}"
|
|
690
755
|
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
$
|
|
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
|
-
|
|
756
|
+
SERVICE_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")/.." && pwd)"
|
|
757
|
+
PRODUCT_ROOT="$(cd "\${SERVICE_DIR}/../.." && pwd)"
|
|
758
|
+
PRODUCT_SHA="$(git -C "\${PRODUCT_ROOT}" rev-parse --short HEAD)"
|
|
697
759
|
ECR_REPOSITORY="\${ECR_REPOSITORY:-${options.name}}"
|
|
698
|
-
IMAGE_TAG="\${IMAGE_TAG
|
|
760
|
+
IMAGE_TAG="\${IMAGE_TAG:-\${PRODUCT_SHA}}"
|
|
699
761
|
REGISTRY="\${AWS_ACCOUNT_ID}.dkr.ecr.\${AWS_REGION}.amazonaws.com"
|
|
700
762
|
IMAGE="\${REGISTRY}/\${ECR_REPOSITORY}:\${IMAGE_TAG}"
|
|
701
|
-
DOCKERFILE="\${
|
|
702
|
-
${options.ui && options.auth === "firebase" ? `UI_ENV_FILE="\${UI_ENV_FILE:-\${
|
|
763
|
+
DOCKERFILE="\${SERVICE_DIR}/Dockerfile"
|
|
764
|
+
${options.ui && options.auth === "firebase" ? `UI_ENV_FILE="\${UI_ENV_FILE:-\${PRODUCT_ROOT}/apps/ui/.env.production}"` : ""}
|
|
703
765
|
|
|
704
766
|
aws ecr get-login-password --region "\${AWS_REGION}" |
|
|
705
767
|
docker login --username AWS --password-stdin "\${REGISTRY}"
|
|
706
768
|
|
|
707
|
-
docker build --platform linux/amd64${options.ui && options.auth === "firebase" ? ` --secret "id=ui_env,src=\${UI_ENV_FILE}"` : ""} --file "\${DOCKERFILE}" --tag "\${IMAGE}" "\${
|
|
769
|
+
docker build --platform linux/amd64${options.ui && options.auth === "firebase" ? ` --secret "id=ui_env,src=\${UI_ENV_FILE}"` : ""} --file "\${DOCKERFILE}" --tag "\${IMAGE}" "\${PRODUCT_ROOT}"
|
|
708
770
|
docker push "\${IMAGE}"
|
|
709
771
|
|
|
710
772
|
echo "Pushed \${IMAGE}"
|
|
711
773
|
`);
|
|
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
|
-
` : "";
|
|
774
|
+
const workflowUiPath = options.ui ? ' - "apps/ui/**"\n' : "";
|
|
722
775
|
const workflowUiEnvironment = options.ui && options.auth === "firebase" ? `
|
|
723
776
|
- name: Write UI production environment
|
|
724
777
|
env:
|
|
725
778
|
UI_ENV_PRODUCTION: \${{ secrets.UI_ENV_PRODUCTION }}
|
|
726
779
|
run: |
|
|
727
|
-
printf '%s\\n' "$UI_ENV_PRODUCTION" >
|
|
780
|
+
printf '%s\\n' "$UI_ENV_PRODUCTION" > apps/ui/.env.production
|
|
728
781
|
` : "";
|
|
729
|
-
|
|
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
|
|
782
|
+
add(".github/workflows/deploy.yml", `name: Build and deploy
|
|
734
783
|
|
|
735
784
|
on:
|
|
736
785
|
push:
|
|
737
786
|
branches:
|
|
738
787
|
- main
|
|
788
|
+
paths:
|
|
789
|
+
- "apps/service/**"
|
|
790
|
+
${workflowUiPath} - "package.json"
|
|
791
|
+
- "pnpm-workspace.yaml"
|
|
792
|
+
- "pnpm-lock.yaml"
|
|
793
|
+
- ".github/workflows/deploy.yml"
|
|
739
794
|
workflow_dispatch:
|
|
740
795
|
|
|
741
796
|
permissions:
|
|
@@ -760,15 +815,14 @@ jobs:
|
|
|
760
815
|
DEPLOY_USER: \${{ vars.DEPLOY_USER }}
|
|
761
816
|
|
|
762
817
|
steps:
|
|
763
|
-
- name: Checkout
|
|
818
|
+
- name: Checkout product
|
|
764
819
|
uses: actions/checkout@v6
|
|
765
|
-
|
|
766
|
-
path: ${svc}
|
|
767
|
-
${workflowUiCheckout}${workflowUiEnvironment}
|
|
820
|
+
${workflowUiEnvironment}
|
|
768
821
|
- name: Resolve immutable image
|
|
769
822
|
id: image
|
|
770
823
|
run: |
|
|
771
|
-
|
|
824
|
+
product_sha="$(git rev-parse --short HEAD)"
|
|
825
|
+
echo "tag=\${product_sha}" >> "$GITHUB_OUTPUT"
|
|
772
826
|
echo "registry=\${AWS_ACCOUNT_ID}.dkr.ecr.\${AWS_REGION}.amazonaws.com" >> "$GITHUB_OUTPUT"
|
|
773
827
|
echo "image=\${AWS_ACCOUNT_ID}.dkr.ecr.\${AWS_REGION}.amazonaws.com/\${ECR_REPOSITORY}" >> "$GITHUB_OUTPUT"
|
|
774
828
|
|
|
@@ -779,10 +833,9 @@ ${workflowUiCheckout}${workflowUiEnvironment}
|
|
|
779
833
|
aws-region: \${{ vars.AWS_REGION }}
|
|
780
834
|
|
|
781
835
|
- name: Build and push image
|
|
782
|
-
working-directory: ${svc}
|
|
783
836
|
env:
|
|
784
837
|
IMAGE_TAG: \${{ steps.image.outputs.tag }}
|
|
785
|
-
run:
|
|
838
|
+
run: apps/service/scripts/build_and_push.sh
|
|
786
839
|
|
|
787
840
|
- name: Configure deployment SSH
|
|
788
841
|
env:
|
|
@@ -805,11 +858,11 @@ ${workflowUiCheckout}${workflowUiEnvironment}
|
|
|
805
858
|
ssh -i ~/.ssh/${options.name}-deploy "\${DEPLOY_USER}@\${DEPLOY_HOST}" \\
|
|
806
859
|
"install -m 755 -d /opt/${options.name}/current/deploy/mongo"
|
|
807
860
|
scp -i ~/.ssh/${options.name}-deploy \\
|
|
808
|
-
|
|
809
|
-
|
|
861
|
+
apps/service/docker-compose.yml \\
|
|
862
|
+
apps/service/scripts/deploy_remote.sh \\
|
|
810
863
|
"\${DEPLOY_USER}@\${DEPLOY_HOST}:/opt/${options.name}/current/"
|
|
811
864
|
scp -i ~/.ssh/${options.name}-deploy \\
|
|
812
|
-
|
|
865
|
+
apps/service/deploy/mongo/10-create-users.js \\
|
|
813
866
|
"\${DEPLOY_USER}@\${DEPLOY_HOST}:/opt/${options.name}/current/deploy/mongo/"
|
|
814
867
|
ssh -i ~/.ssh/${options.name}-deploy "\${DEPLOY_USER}@\${DEPLOY_HOST}" \\
|
|
815
868
|
"chmod 755 /opt/${options.name}/current/deploy_remote.sh"
|
|
@@ -819,7 +872,6 @@ ${workflowUiCheckout}${workflowUiEnvironment}
|
|
|
819
872
|
ssh -i ~/.ssh/${options.name}-deploy "\${DEPLOY_USER}@\${DEPLOY_HOST}" \\
|
|
820
873
|
"/opt/${options.name}/current/deploy_remote.sh '\${{ steps.image.outputs.tag }}' '\${{ steps.image.outputs.image }}'"
|
|
821
874
|
`);
|
|
822
|
-
|
|
823
875
|
add(`${svc}/scripts/deploy_remote.sh`, `#!/usr/bin/env bash
|
|
824
876
|
set -euo pipefail
|
|
825
877
|
|
|
@@ -852,33 +904,28 @@ docker compose --env-file "\${INFRA_ENV}" --file "\${COMPOSE_FILE}" pull
|
|
|
852
904
|
docker compose --env-file "\${INFRA_ENV}" --file "\${COMPOSE_FILE}" up -d --wait
|
|
853
905
|
`);
|
|
854
906
|
|
|
855
|
-
add(`${svc}/deploy/
|
|
856
|
-
|
|
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
|
-
});
|
|
907
|
+
if (postgres) add(`${svc}/deploy/postgres/10-create-application-user.sh`, `#!/usr/bin/env bash
|
|
908
|
+
set -euo pipefail
|
|
867
909
|
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
}
|
|
910
|
+
psql --set ON_ERROR_STOP=1 --username postgres --dbname "${databaseName}" --set app_password="\${POSTGRES_APP_PASSWORD}" <<'SQL'
|
|
911
|
+
SELECT 'CREATE ROLE "${options.name}-app" LOGIN PASSWORD ' || quote_literal(:'app_password')
|
|
912
|
+
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${options.name}-app')\gexec
|
|
913
|
+
GRANT CONNECT ON DATABASE "${databaseName}" TO "${options.name}-app";
|
|
914
|
+
GRANT USAGE ON SCHEMA public TO "${options.name}-app";
|
|
915
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO "${options.name}-app";
|
|
916
|
+
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO "${options.name}-app";
|
|
917
|
+
ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO "${options.name}-app";
|
|
918
|
+
ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO "${options.name}-app";
|
|
919
|
+
SQL
|
|
873
920
|
`);
|
|
874
921
|
|
|
875
922
|
add(`${svc}/docs/production-deployment.md`, `# Production deployment
|
|
876
923
|
|
|
877
924
|
## Delivery model
|
|
878
925
|
|
|
879
|
-
GitHub Actions checks out
|
|
880
|
-
non-root production image, tags it
|
|
881
|
-
|
|
926
|
+
GitHub Actions checks out the product once, builds one
|
|
927
|
+
non-root production image, tags it \`<product-sha>\`, pushes it to ECR, and installs only deployment artifacts on the host.
|
|
928
|
+
Changes to the service, optional UI, root workspace/lock configuration, or deployment files on product \`main\`, plus manual dispatch, trigger this workflow.
|
|
882
929
|
|
|
883
930
|
The host separates replaceable artifacts from persistent state:
|
|
884
931
|
|
|
@@ -906,7 +953,7 @@ Actions may replace \`current/\` and must never replace \`shared/\`. The server
|
|
|
906
953
|
\`.env.infrastructure.example\` and replace every placeholder.
|
|
907
954
|
- Compose \`environment:\` owns fixed production wiring: \`NODE_ENV\`, internal database/cache URLs, and mounted
|
|
908
955
|
credential paths.
|
|
909
|
-
${options.ui && options.auth === "firebase" ? `- The UI
|
|
956
|
+
${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.
|
|
910
957
|
` : ""}
|
|
911
958
|
Do not inject \`.env.infrastructure\` into Node. Make both host environment files root-owned and mode \`0600\`.
|
|
912
959
|
|
|
@@ -934,8 +981,7 @@ Configure these variables:
|
|
|
934
981
|
|
|
935
982
|
Configure these secrets:
|
|
936
983
|
|
|
937
|
-
${options.ui ? `- \`
|
|
938
|
-
` : ""}${options.ui && options.auth === "firebase" ? `- \`UI_ENV_PRODUCTION\`: Vite/Firebase build-time environment.
|
|
984
|
+
${options.ui && options.auth === "firebase" ? `- \`UI_ENV_PRODUCTION\`: Vite/Firebase build-time environment.
|
|
939
985
|
` : ""}- \`DEPLOY_SSH_PRIVATE_KEY\`: dedicated host deployment key.
|
|
940
986
|
- \`DEPLOY_SSH_KNOWN_HOSTS\`: pinned host key.
|
|
941
987
|
|
|
@@ -964,24 +1010,30 @@ const devVolumes = [
|
|
|
964
1010
|
...(options.auth === "firebase" ? [" - ./firebase-service-account.development.json:/app/firebase-service-account.development.json:ro"] : [])
|
|
965
1011
|
];
|
|
966
1012
|
const productionDependencies = [
|
|
967
|
-
["
|
|
1013
|
+
["schema-init", "service_completed_successfully"],
|
|
968
1014
|
...(options.redis ? [["redis", "service_healthy"]] : [])
|
|
969
1015
|
];
|
|
970
1016
|
const developmentDependencies = [
|
|
971
|
-
["
|
|
1017
|
+
["schema-init", "service_completed_successfully"],
|
|
972
1018
|
...(options.redis ? [["redis", "service_started"]] : [])
|
|
973
1019
|
];
|
|
974
1020
|
const dependsBlock = `\n depends_on:\n${productionDependencies.map(([dependency, condition]) => ` ${dependency}:\n condition: ${condition}`).join("\n")}`;
|
|
975
1021
|
const devDependsBlock = `\n depends_on:\n${developmentDependencies.map(([dependency, condition]) => ` ${dependency}:\n condition: ${condition}`).join("\n")}`;
|
|
976
1022
|
const productionEnvironment = [
|
|
977
1023
|
" NODE_ENV: production",
|
|
978
|
-
|
|
979
|
-
|
|
1024
|
+
...(postgres
|
|
1025
|
+
? [` POSTGRES_URI: "postgresql://${options.name}-app:\${POSTGRES_APP_PASSWORD:?Set POSTGRES_APP_PASSWORD}@postgres:5432/${databaseName}"`]
|
|
1026
|
+
: [
|
|
1027
|
+
` MONGODB_URI: "mongodb://${mongoApplicationUser}:\${MONGODB_APP_PASSWORD:?Set MONGODB_APP_PASSWORD}@mongodb:27017/${databaseName}?replicaSet=rs0&directConnection=true&authSource=${databaseName}"`,
|
|
1028
|
+
` MONGODB_DB_NAME: "${databaseName}"`
|
|
1029
|
+
]),
|
|
980
1030
|
...(options.auth === "firebase" ? [" GOOGLE_APPLICATION_CREDENTIALS: /app/secrets/firebase-service-account.json"] : []),
|
|
981
1031
|
...(options.redis ? [" REDIS_URL: \"redis://:\${REDIS_PASSWORD:?Set REDIS_PASSWORD}@redis:6379\""] : [])
|
|
982
1032
|
];
|
|
983
1033
|
const productionEnvironmentBlock = `\n environment:\n${productionEnvironment.join("\n")}`;
|
|
984
|
-
const databaseEnvironmentBlock =
|
|
1034
|
+
const databaseEnvironmentBlock = postgres
|
|
1035
|
+
? `\n environment:\n POSTGRES_URI: "postgresql://${options.name}-app:development@postgres:5432/${databaseName}"`
|
|
1036
|
+
: `\n environment:\n MONGODB_URI: "mongodb://mongodb:27017/?replicaSet=rs0&directConnection=true"`;
|
|
985
1037
|
const volumeBlock = serviceVolumes.length ? `\n volumes:\n${serviceVolumes.join("\n")}` : "";
|
|
986
1038
|
const workerService = options.worker ? `\n worker:
|
|
987
1039
|
image: \${ECR_IMAGE:?Set ECR_IMAGE}:\${IMAGE_TAG:?Set IMAGE_TAG}
|
|
@@ -998,7 +1050,25 @@ const workerService = options.worker ? `\n worker:
|
|
|
998
1050
|
networks:
|
|
999
1051
|
- backend
|
|
1000
1052
|
restart: unless-stopped` : "";
|
|
1001
|
-
const databaseService = `\n
|
|
1053
|
+
const databaseService = postgres ? `\n postgres:
|
|
1054
|
+
image: postgres:17
|
|
1055
|
+
environment:
|
|
1056
|
+
POSTGRES_DB: "${databaseName}"
|
|
1057
|
+
POSTGRES_USER: postgres
|
|
1058
|
+
POSTGRES_PASSWORD: \${POSTGRES_ROOT_PASSWORD:?Set POSTGRES_ROOT_PASSWORD}
|
|
1059
|
+
POSTGRES_APP_PASSWORD: \${POSTGRES_APP_PASSWORD:?Set POSTGRES_APP_PASSWORD}
|
|
1060
|
+
volumes:
|
|
1061
|
+
- \${POSTGRES_DATA_DIR:?Set POSTGRES_DATA_DIR}:/var/lib/postgresql/data
|
|
1062
|
+
- ./deploy/postgres/10-create-application-user.sh:/docker-entrypoint-initdb.d/10-create-application-user.sh:ro
|
|
1063
|
+
healthcheck:
|
|
1064
|
+
test: ["CMD-SHELL", "pg_isready -U postgres -d ${databaseName}"]
|
|
1065
|
+
interval: 5s
|
|
1066
|
+
timeout: 5s
|
|
1067
|
+
retries: 30
|
|
1068
|
+
start_period: 20s
|
|
1069
|
+
networks:
|
|
1070
|
+
- backend
|
|
1071
|
+
restart: unless-stopped` : `\n mongodb:
|
|
1002
1072
|
image: mongo:8
|
|
1003
1073
|
command:
|
|
1004
1074
|
- mongod
|
|
@@ -1011,8 +1081,12 @@ const databaseService = `\n mongodb:
|
|
|
1011
1081
|
MONGO_INITDB_DATABASE: "${databaseName}"
|
|
1012
1082
|
MONGO_INITDB_ROOT_USERNAME: "${options.name}-root"
|
|
1013
1083
|
MONGO_INITDB_ROOT_PASSWORD: \${MONGODB_ROOT_PASSWORD:?Set MONGODB_ROOT_PASSWORD}
|
|
1084
|
+
MONGODB_DB_NAME: "${databaseName}"
|
|
1085
|
+
MONGODB_APP_USERNAME: "${mongoApplicationUser}"
|
|
1014
1086
|
MONGODB_APP_PASSWORD: \${MONGODB_APP_PASSWORD:?Set MONGODB_APP_PASSWORD}
|
|
1087
|
+
MONGODB_BACKUP_USERNAME: "${mongoBackupUser}"
|
|
1015
1088
|
MONGODB_BACKUP_PASSWORD: \${MONGODB_BACKUP_PASSWORD:?Set MONGODB_BACKUP_PASSWORD}
|
|
1089
|
+
MONGODB_APP_COLLECTIONS: "_tailframe_reserved"
|
|
1016
1090
|
volumes:
|
|
1017
1091
|
- \${MONGO_DATA_DIR:?Set MONGO_DATA_DIR}:/data/db
|
|
1018
1092
|
- \${MONGO_KEYFILE:?Set MONGO_KEYFILE}:/etc/mongo-keyfile/keyfile:ro
|
|
@@ -1059,6 +1133,17 @@ const databaseService = `\n mongodb:
|
|
|
1059
1133
|
}"
|
|
1060
1134
|
networks:
|
|
1061
1135
|
- backend`;
|
|
1136
|
+
const schemaInitDependencies = postgres ? [["postgres", "service_healthy"]] : [["mongo-init", "service_completed_successfully"]];
|
|
1137
|
+
const schemaInitDependsBlock = `\n depends_on:\n${schemaInitDependencies.map(([dependency, condition]) => ` ${dependency}:\n condition: ${condition}`).join("\n")}`;
|
|
1138
|
+
const schemaInitEnvironment = postgres
|
|
1139
|
+
? `\n environment:\n POSTGRES_URI: "postgresql://postgres:\${POSTGRES_ROOT_PASSWORD:?Set POSTGRES_ROOT_PASSWORD}@postgres:5432/${databaseName}"`
|
|
1140
|
+
: `\n environment:\n MONGODB_URI: "mongodb://${options.name}-root:\${MONGODB_ROOT_PASSWORD:?Set MONGODB_ROOT_PASSWORD}@mongodb:27017/${databaseName}?replicaSet=rs0&directConnection=true&authSource=admin"\n MONGODB_DB_NAME: "${databaseName}"`;
|
|
1141
|
+
const schemaInitService = `\n schema-init:
|
|
1142
|
+
image: \${ECR_IMAGE:?Set ECR_IMAGE}:\${IMAGE_TAG:?Set IMAGE_TAG}
|
|
1143
|
+
command: ["node", "dist/app/cli/applySchema.js"]${schemaInitEnvironment}${schemaInitDependsBlock}
|
|
1144
|
+
networks:
|
|
1145
|
+
- backend
|
|
1146
|
+
restart: "no"`;
|
|
1062
1147
|
const redisService = options.redis ? `\n redis:
|
|
1063
1148
|
image: redis:7-alpine
|
|
1064
1149
|
command:
|
|
@@ -1109,7 +1194,7 @@ services:
|
|
|
1109
1194
|
stop_grace_period: 2m
|
|
1110
1195
|
networks:
|
|
1111
1196
|
- backend
|
|
1112
|
-
restart: unless-stopped${workerService}${databaseService}${redisService}
|
|
1197
|
+
restart: unless-stopped${workerService}${schemaInitService}${databaseService}${redisService}
|
|
1113
1198
|
networks:
|
|
1114
1199
|
backend:
|
|
1115
1200
|
driver: bridge
|
|
@@ -1124,7 +1209,21 @@ const devWorker = options.worker ? `\n worker:
|
|
|
1124
1209
|
command: ["npm", "run", "dev:worker"]${databaseEnvironmentBlock}
|
|
1125
1210
|
volumes:
|
|
1126
1211
|
${devVolumes.join("\n")}${devDependsBlock}` : "";
|
|
1127
|
-
const devDatabase = `\n
|
|
1212
|
+
const devDatabase = postgres ? `\n postgres:
|
|
1213
|
+
image: postgres:17
|
|
1214
|
+
ports:
|
|
1215
|
+
- "5432:5432"
|
|
1216
|
+
environment:
|
|
1217
|
+
POSTGRES_DB: ${databaseName}
|
|
1218
|
+
POSTGRES_USER: ${options.name}-app
|
|
1219
|
+
POSTGRES_PASSWORD: development
|
|
1220
|
+
volumes:
|
|
1221
|
+
- postgres-dev-data:/var/lib/postgresql/data
|
|
1222
|
+
healthcheck:
|
|
1223
|
+
test: ["CMD-SHELL", "pg_isready -U ${options.name}-app -d ${databaseName}"]
|
|
1224
|
+
interval: 2s
|
|
1225
|
+
timeout: 2s
|
|
1226
|
+
retries: 30` : `\n mongodb:
|
|
1128
1227
|
image: mongo:8
|
|
1129
1228
|
command: mongod --replSet rs0 --bind_ip_all
|
|
1130
1229
|
ports:
|
|
@@ -1160,11 +1259,23 @@ const devRedis = options.redis ? `\n redis:
|
|
|
1160
1259
|
volumes:
|
|
1161
1260
|
- redis-dev-data:/data` : "";
|
|
1162
1261
|
const devNamedVolumes = [
|
|
1163
|
-
" mongodb-dev-data:",
|
|
1262
|
+
postgres ? " postgres-dev-data:" : " mongodb-dev-data:",
|
|
1164
1263
|
...(options.redis ? [" redis-dev-data:"] : [])
|
|
1165
1264
|
];
|
|
1166
1265
|
add(`${svc}/docker-compose.dev.yml`, `name: ${options.name}-dev
|
|
1167
1266
|
services:
|
|
1267
|
+
schema-init:
|
|
1268
|
+
build:
|
|
1269
|
+
context: .
|
|
1270
|
+
dockerfile: Dockerfile.dev
|
|
1271
|
+
env_file:
|
|
1272
|
+
- .env.development
|
|
1273
|
+
command: ["npm", "run", "schema:apply:dev"]${databaseEnvironmentBlock}
|
|
1274
|
+
volumes:
|
|
1275
|
+
${devVolumes.join("\n")}
|
|
1276
|
+
depends_on:
|
|
1277
|
+
${postgres ? "postgres" : "mongo-init"}:
|
|
1278
|
+
condition: ${postgres ? "service_healthy" : "service_completed_successfully"}
|
|
1168
1279
|
${options.name}:
|
|
1169
1280
|
build:
|
|
1170
1281
|
context: .
|
|
@@ -1179,6 +1290,15 @@ volumes:
|
|
|
1179
1290
|
${devNamedVolumes.join("\n")}
|
|
1180
1291
|
`);
|
|
1181
1292
|
|
|
1293
|
+
for (const [relative, content] of Object.entries(ownedSources({ kind: "service", profiles: serviceProfiles }))) {
|
|
1294
|
+
add(`${svc}/${relative}`, content);
|
|
1295
|
+
}
|
|
1296
|
+
if (options.ui) {
|
|
1297
|
+
for (const [relative, content] of Object.entries(ownedSources({ kind: "ui", profiles: uiProfiles }))) {
|
|
1298
|
+
add(`${ui}/${relative}`, content);
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1182
1302
|
for (const [relative, content] of Object.entries(files)) {
|
|
1183
1303
|
const destination = path.join(root, relative);
|
|
1184
1304
|
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
@@ -1186,5 +1306,8 @@ for (const [relative, content] of Object.entries(files)) {
|
|
|
1186
1306
|
if (relative.endsWith(".sh")) fs.chmodSync(destination, 0o755);
|
|
1187
1307
|
}
|
|
1188
1308
|
|
|
1189
|
-
|
|
1309
|
+
const syncResult = runSync(root, "write", contractVersion);
|
|
1310
|
+
if (syncResult.errors.length) fail(syncResult.errors.join("\n"));
|
|
1311
|
+
|
|
1312
|
+
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 };
|
|
1190
1313
|
}
|