@zaaxch/tailframe 2.1.0 → 3.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 +30 -12
- package/package.json +1 -1
- package/src/architecture.mjs +15 -10
- package/src/config.mjs +59 -0
- package/src/conventions.mjs +22 -5
- package/src/flutter.mjs +111 -0
- package/src/generate.mjs +3 -2
- package/src/new.mjs +587 -66
- package/src/owned-guidance.mjs +15 -0
- package/src/owned-sources.mjs +479 -0
- package/src/service-templates.mjs +222 -19
- package/src/sync.mjs +57 -0
- package/src/ui-templates.mjs +180 -1
- package/src/validate.mjs +44 -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
|
}
|
|
@@ -63,11 +68,22 @@ if (fs.existsSync(root) && fs.readdirSync(root).length) fail(`Refusing to overwr
|
|
|
63
68
|
const title = options.name.split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join(" ");
|
|
64
69
|
const svc = `${options.name}-svc`;
|
|
65
70
|
const ui = `${options.name}-ui`;
|
|
71
|
+
const databaseName = options.name.replaceAll("-", "_");
|
|
72
|
+
const postgres = options.db === "postgres";
|
|
73
|
+
const mongoApplicationUser = `${options.name}-app`;
|
|
74
|
+
const mongoBackupUser = `${options.name}-backup`;
|
|
66
75
|
const files = {};
|
|
67
76
|
const add = (relative, content) => { files[relative] = content.endsWith("\n") ? content : `${content}\n`; };
|
|
68
77
|
// Generated repositories depend on the published toolkit rather than copying a validator, and they
|
|
69
78
|
// pin the exact contract version they were generated against.
|
|
70
79
|
const contractVersion = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
80
|
+
const serviceProfiles = [
|
|
81
|
+
...(options.auth === "firebase" ? ["firebase"] : []),
|
|
82
|
+
options.db,
|
|
83
|
+
...(options.redis ? ["redis", "rate-limit"] : []),
|
|
84
|
+
...(options.worker ? ["worker"] : []),
|
|
85
|
+
...(options.ui ? ["ui-host"] : [])
|
|
86
|
+
];
|
|
71
87
|
const prettierConfig = {
|
|
72
88
|
useTabs: true,
|
|
73
89
|
tabWidth: 4,
|
|
@@ -166,13 +182,13 @@ Use this workflow for any new domain capability or operation. Add only the layer
|
|
|
166
182
|
2. Read the root and every applicable child \`AGENTS.md\`.
|
|
167
183
|
3. Inspect the nearest working module and tests.
|
|
168
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.
|
|
169
|
-
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/
|
|
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.
|
|
170
186
|
6. Define use cases as \`execute(context, input)\`. Keep trusted \`RequestContext\` separate from client input.
|
|
171
187
|
7. Put ownership, RBAC, entitlements, and domain policies in use-case or domain code, not client bodies or route metadata.
|
|
172
188
|
8. Define repository contracts in the owning module and implement them in persistence adapters.
|
|
173
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.
|
|
174
190
|
10. Let HTTP routes, jobs, workers, scheduled tasks, and CLIs invoke use cases directly; do not call one entry point from another.
|
|
175
|
-
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.
|
|
191
|
+
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.
|
|
176
192
|
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.
|
|
177
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.
|
|
178
194
|
|
|
@@ -186,12 +202,13 @@ const svcDeps = {
|
|
|
186
202
|
joi: "^17.13.3",
|
|
187
203
|
"reflect-metadata": "^0.2.2",
|
|
188
204
|
tsyringe: "^4.9.1",
|
|
189
|
-
mongodb: "^6.17.0",
|
|
205
|
+
...(postgres ? { pg: "^8.16.3" } : { mongodb: "^6.17.0" }),
|
|
190
206
|
...(options.auth === "firebase" ? { "firebase-admin": "^13.0.0" } : {}),
|
|
191
|
-
...(options.redis ? { redis: "^5.10.0" } : {})
|
|
207
|
+
...(options.redis ? { "rate-limiter-flexible": "^11.2.0", redis: "^5.10.0" } : {})
|
|
192
208
|
};
|
|
193
209
|
const svcDevDeps = {
|
|
194
210
|
"@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
|
+
...(postgres ? { "@types/pg": "^8.15.5" } : {}),
|
|
195
212
|
"@zaaxch/tailframe": contractVersion,
|
|
196
213
|
jest: "^30.0.0", nodemon: "^3.1.9", prettier: "3.5.3", "ts-jest": "^29.4.11", "ts-node": "^10.9.2",
|
|
197
214
|
"tsc-alias": "^1.8.16", "tsconfig-paths": "^4.2.0", typescript: "^5.8.2", supertest: "^7.2.2"
|
|
@@ -204,13 +221,15 @@ add(`${svc}/package.json`, JSON.stringify({
|
|
|
204
221
|
...(options.worker ? { "dev:worker": "nodemon --legacy-watch --exec ts-node -r tsconfig-paths/register src/worker.ts" } : {}),
|
|
205
222
|
build: "tsc && tsc-alias", start: "node dist/server.js",
|
|
206
223
|
...(options.worker ? { worker: "node dist/worker.js" } : {}),
|
|
207
|
-
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 --
|
|
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/",
|
|
225
|
+
"schema:apply": "node dist/app/cli/applySchema.js", "schema:apply:dev": "ts-node -r tsconfig-paths/register src/app/cli/applySchema.ts",
|
|
208
226
|
"test:db:up": "docker compose -f docker-compose.test.yml up -d", "test:db:down": "docker compose -f docker-compose.test.yml down -v",
|
|
209
227
|
"docker:dev": "docker compose -f docker-compose.dev.yml up",
|
|
210
228
|
"docker:prod": "docker compose -f docker-compose.yml up -d",
|
|
211
229
|
"docker:push": "bash scripts/build_and_push.sh"
|
|
212
230
|
}, dependencies: svcDeps, devDependencies: svcDevDeps
|
|
213
231
|
}, null, "\t"));
|
|
232
|
+
add(`${svc}/tailframe.json`, configSource({ kind: "service", profiles: serviceProfiles, contractVersion }));
|
|
214
233
|
add(`${svc}/tsconfig.json`, JSON.stringify({
|
|
215
234
|
compilerOptions: { target: "ES2022", module: "commonjs", rootDir: "src", outDir: "dist", strict: true, esModuleInterop: true, experimentalDecorators: true, emitDecoratorMetadata: true, baseUrl: ".", paths: { "@/*": ["src/*"] }, skipLibCheck: true },
|
|
216
235
|
include: ["src/**/*.ts"]
|
|
@@ -222,22 +241,54 @@ module.exports = { projects: [
|
|
|
222
241
|
] };`);
|
|
223
242
|
add(`${svc}/.prettierrc.json`, JSON.stringify(prettierConfig, null, "\t"));
|
|
224
243
|
add(`${svc}/.gitattributes`, gitAttributes);
|
|
225
|
-
add(`${svc}/.gitignore`, `node_modules/\ndist/\n.env*\n!.env.example\n*.log\nfirebase-service-account*.json\n`);
|
|
244
|
+
add(`${svc}/.gitignore`, `node_modules/\ndist/\n.env*\n!.env.example\n!.env.infrastructure.example\n*.log\nfirebase-service-account*.json\n`);
|
|
226
245
|
add(`${svc}/.dockerignore`, `node_modules\ndist\n.git\n.env*\n!.env.example\n*.log\ndata\nfirebase-service-account*.json\n`);
|
|
227
246
|
if (options.ui) add(".dockerignore", `**/node_modules\n**/dist\n**/.git\n**/.env*\n**/*.log\n**/data\n**/firebase-service-account*.json\n`);
|
|
228
|
-
const dbEnv =
|
|
247
|
+
const dbEnv = postgres
|
|
248
|
+
? `POSTGRES_URI=postgresql://${options.name}-app:development@localhost:5432/${databaseName}`
|
|
249
|
+
: "MONGODB_URI=mongodb://localhost:27017/?replicaSet=rs0&directConnection=true\nMONGODB_DB_NAME=" + databaseName;
|
|
229
250
|
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
|
+
add(`${svc}/.env.infrastructure.example`, `ECR_IMAGE=<account>.dkr.ecr.<region>.amazonaws.com/${options.name}
|
|
252
|
+
IMAGE_TAG=<service-sha>${options.ui ? "_<ui-sha>" : ""}
|
|
253
|
+
APP_ENV_FILE=/opt/${options.name}/shared/.env.production
|
|
254
|
+
${postgres ? `POSTGRES_DATA_DIR=/opt/${options.name}/shared/postgres/data
|
|
255
|
+
POSTGRES_ROOT_PASSWORD=<uri-safe-random-value>
|
|
256
|
+
POSTGRES_APP_PASSWORD=<different-uri-safe-random-value>
|
|
257
|
+
` : `MONGO_DATA_DIR=/opt/${options.name}/shared/mongo/data
|
|
258
|
+
MONGO_KEYFILE=/opt/${options.name}/shared/mongo/config/keyfile
|
|
259
|
+
MONGO_INIT_SCRIPT=/opt/${options.name}/current/deploy/mongo/10-create-users.js
|
|
260
|
+
MONGODB_ROOT_PASSWORD=<uri-safe-random-value>
|
|
261
|
+
MONGODB_APP_PASSWORD=<different-uri-safe-random-value>
|
|
262
|
+
MONGODB_BACKUP_PASSWORD=<different-uri-safe-random-value>
|
|
263
|
+
`}
|
|
264
|
+
${options.auth === "firebase" ? `SECRETS_DIR=/opt/${options.name}/shared/secrets
|
|
265
|
+
` : ""}${options.redis ? `REDIS_DATA_DIR=/opt/${options.name}/shared/redis/data
|
|
266
|
+
REDIS_PASSWORD=<different-uri-safe-random-value>
|
|
267
|
+
` : ""}`);
|
|
230
268
|
add(`${svc}/src/platform/config/env.ts`, `import "@dotenvx/dotenvx/config";
|
|
231
269
|
export const env = {
|
|
232
270
|
nodeEnv: process.env.NODE_ENV ?? "development",
|
|
233
271
|
port: Number(process.env.PORT ?? 3000),
|
|
234
272
|
corsOrigin: process.env.CORS_ORIGIN ?? "https://localhost:5173",
|
|
235
|
-
${options.auth === "firebase" ? `firebaseProjectId: process.env.FIREBASE_PROJECT_ID ?? ""
|
|
236
|
-
|
|
237
|
-
|
|
273
|
+
${options.auth === "firebase" ? `firebaseProjectId: process.env.FIREBASE_PROJECT_ID ?? "",
|
|
274
|
+
firebaseServiceAccountPath: process.env.FIREBASE_SERVICE_ACCOUNT_PATH,` : ""}
|
|
275
|
+
${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",
|
|
276
|
+
mongodbDbName: process.env.MONGODB_DB_NAME ?? "${databaseName}",`}
|
|
238
277
|
${options.redis ? `redisUrl: process.env.REDIS_URL ?? "redis://localhost:6379",` : ""}
|
|
239
278
|
};`);
|
|
240
|
-
const databaseSource = `import {
|
|
279
|
+
const databaseSource = postgres ? `import { Pool } from "pg";
|
|
280
|
+
import { env } from "@/platform/config/env";
|
|
281
|
+
|
|
282
|
+
export const postgres = new Pool({ connectionString: env.postgresUri });
|
|
283
|
+
|
|
284
|
+
export async function connectDatabase() {
|
|
285
|
+
await postgres.query("SELECT 1");
|
|
286
|
+
return postgres;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export async function closeDatabase() {
|
|
290
|
+
await postgres.end();
|
|
291
|
+
}` : `import { MongoClient } from "mongodb";
|
|
241
292
|
import { env } from "@/platform/config/env";
|
|
242
293
|
|
|
243
294
|
export const mongo = new MongoClient(env.mongodbUri);
|
|
@@ -251,6 +302,15 @@ export async function closeDatabase() {
|
|
|
251
302
|
await mongo.close();
|
|
252
303
|
}`;
|
|
253
304
|
add(`${svc}/src/platform/database/index.ts`, databaseSource);
|
|
305
|
+
add(`${svc}/src/app/schema.ts`, `import type { SchemaLifecycle } from "@/core/SchemaLifecycle";
|
|
306
|
+
import { closeDatabase, connectDatabase } from "@/platform/database";
|
|
307
|
+
|
|
308
|
+
export const schemaLifecycle: SchemaLifecycle = {
|
|
309
|
+
async apply() {
|
|
310
|
+
await connectDatabase();
|
|
311
|
+
},
|
|
312
|
+
close: closeDatabase
|
|
313
|
+
};`);
|
|
254
314
|
if (options.redis) add(`${svc}/src/platform/redis/index.ts`, redisSource);
|
|
255
315
|
add(`${svc}/src/core/RequestContext.ts`, requestContextSource);
|
|
256
316
|
add(`${svc}/src/core/UseCase.ts`, useCaseSource);
|
|
@@ -267,9 +327,10 @@ add(`${svc}/src/modules/health/use-cases/GetReadiness.ts`, getReadinessSource);
|
|
|
267
327
|
add(`${svc}/src/modules/health/use-cases/ports/ReadinessProbe.ts`, readinessProbeSource);
|
|
268
328
|
add(`${svc}/src/modules/health/http/health.schemas.ts`, healthSchemasSource);
|
|
269
329
|
add(`${svc}/src/modules/health/http/health.routes.ts`, healthRoutesSource);
|
|
270
|
-
add(`${svc}/src/platform/integrations/
|
|
330
|
+
if (postgres) add(`${svc}/src/platform/integrations/postgres/PostgresReadinessProbe.ts`, postgresReadinessProbeSource);
|
|
331
|
+
else add(`${svc}/src/platform/integrations/mongodb/MongoReadinessProbe.ts`, mongoReadinessProbeSource);
|
|
271
332
|
if (options.redis) add(`${svc}/src/platform/integrations/redis/RedisReadinessProbe.ts`, redisReadinessProbeSource);
|
|
272
|
-
add(`${svc}/src/app/container.ts`, containerSource({ redis: options.redis }));
|
|
333
|
+
add(`${svc}/src/app/container.ts`, containerSource({ database: options.db, redis: options.redis }));
|
|
273
334
|
add(`${svc}/src/app/routes.ts`, applicationRoutesSource);
|
|
274
335
|
add(`${svc}/src/app/server.ts`, serverSource({ ui: options.ui, redis: options.redis, csrf }));
|
|
275
336
|
add(`${svc}/src/server.ts`, `import "reflect-metadata";\nimport { startServer } from "@/app/server";\nvoid startServer();\n`);
|
|
@@ -277,13 +338,34 @@ if (options.worker) add(`${svc}/src/app/workers/startWorker.ts`, workerSource())
|
|
|
277
338
|
if (options.worker) add(`${svc}/src/worker.ts`, `import "reflect-metadata";\nimport { startWorker } from "@/app/workers/startWorker";\nvoid startWorker();\n`);
|
|
278
339
|
add(`${svc}/src/modules/health/__tests__/GetHealth.test.ts`, getHealthTestSource);
|
|
279
340
|
add(`${svc}/src/modules/health/__tests__/GetReadiness.test.ts`, getReadinessTestSource);
|
|
280
|
-
add(`${svc}/src/__tests__/testDb.ts`, `import {
|
|
341
|
+
add(`${svc}/src/__tests__/testDb.ts`, postgres ? `import { Pool } from "pg";
|
|
342
|
+
const pool = new Pool({ connectionString: process.env.TEST_POSTGRES_URI ?? "postgresql://postgres:postgres@localhost:5433/${databaseName}_test" });
|
|
343
|
+
export async function openTestDatabase() { await pool.query("SELECT 1"); return pool; }
|
|
344
|
+
export async function resetTestDatabase() { await pool.query("DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public"); return pool; }
|
|
345
|
+
export async function closeTestDatabase() { await pool.end(); }
|
|
346
|
+
` : `import { MongoClient } from "mongodb";
|
|
281
347
|
const client = new MongoClient(process.env.TEST_MONGODB_URI ?? "mongodb://localhost:27018/?replicaSet=rs0&directConnection=true");
|
|
282
348
|
export async function openTestDatabase() { await client.connect(); return client.db(process.env.TEST_MONGODB_DB_NAME ?? "${options.name.replaceAll("-", "_")}_test"); }
|
|
283
349
|
export async function resetTestDatabase() { const database = await openTestDatabase(); await database.dropDatabase(); return database; }
|
|
284
350
|
export async function closeTestDatabase() { await client.close(); }
|
|
285
351
|
`);
|
|
286
|
-
add(`${svc}/docker-compose.test.yml`, `name: ${options.name}-test
|
|
352
|
+
add(`${svc}/docker-compose.test.yml`, postgres ? `name: ${options.name}-test
|
|
353
|
+
services:
|
|
354
|
+
postgres:
|
|
355
|
+
image: postgres:17
|
|
356
|
+
ports:
|
|
357
|
+
- "5433:5432"
|
|
358
|
+
environment:
|
|
359
|
+
POSTGRES_DB: ${databaseName}_test
|
|
360
|
+
POSTGRES_PASSWORD: postgres
|
|
361
|
+
tmpfs:
|
|
362
|
+
- /var/lib/postgresql/data
|
|
363
|
+
healthcheck:
|
|
364
|
+
test: ["CMD-SHELL", "pg_isready -U postgres -d ${databaseName}_test"]
|
|
365
|
+
interval: 2s
|
|
366
|
+
timeout: 2s
|
|
367
|
+
retries: 30
|
|
368
|
+
` : `name: ${options.name}-test
|
|
287
369
|
services:
|
|
288
370
|
mongo:
|
|
289
371
|
image: mongo:8
|
|
@@ -444,10 +526,14 @@ This service uses MongoDB. Module-owned MongoDB adapters own collection access,
|
|
|
444
526
|
- Keep optional entry-point assembly under \`src/app/<entry-point-kind>\`; root files such as \`src/server.ts\` and \`src/worker.ts\` may only bootstrap that assembly.
|
|
445
527
|
- Initialize infrastructure first, then call \`registerDependencies(...)\` from every process entry point. The composition root resets the container, explicitly constructs dependencies, and registers class-token instances. Keep tsyringe, injection tokens, decorators, and container resolution out of modules and platform adapters.
|
|
446
528
|
- Register routes, workers, and jobs explicitly. Shutdown paths are awaitable and close resources without forcing \`process.exit\`.
|
|
529
|
+
- Scheduled work prevents unintended overlap, stops intake before shutdown, awaits the active run, destroys its scheduler or consumer, and only then closes Redis and database clients.
|
|
447
530
|
${options.ui ? "- In production, serve the compiled UI from `dist/public` with a non-API SPA fallback. Never return the SPA for `/api` requests." : ""}
|
|
448
531
|
|
|
449
532
|
## Production image
|
|
450
|
-
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."}
|
|
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.` : ""}
|
|
534
|
+
|
|
535
|
+
## Production deployment
|
|
536
|
+
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\`.
|
|
451
537
|
|
|
452
538
|
## Tests and validation
|
|
453
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.
|
|
@@ -456,7 +542,12 @@ Run \`npm run validate:architecture\` and \`npm run format:check\` after changin
|
|
|
456
542
|
if (options.ui) {
|
|
457
543
|
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" } : {}) };
|
|
458
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" };
|
|
459
|
-
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 --
|
|
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
|
+
}));
|
|
460
551
|
add(`${ui}/.prettierrc.json`, JSON.stringify(prettierConfig, null, "\t"));
|
|
461
552
|
add(`${ui}/.gitattributes`, gitAttributes);
|
|
462
553
|
add(`${ui}/tsconfig.json`, JSON.stringify({ files: [], references: [{ path: "./tsconfig.app.json" }, { path: "./tsconfig.node.json" }] }, null, "\t"));
|
|
@@ -495,6 +586,7 @@ export default createRouter({
|
|
|
495
586
|
});`);
|
|
496
587
|
add(`${ui}/src/platform/http.ts`, uiHttpSource);
|
|
497
588
|
if (options.auth === "firebase") add(`${ui}/src/app/configureHttp.ts`, configureHttpSource);
|
|
589
|
+
add(`${ui}/src/platform/config.ts`, `export const apiBaseUrl = \`\${window.location.origin}/api/v1\`;`);
|
|
498
590
|
add(`${ui}/src/modules/health/api/health.api.ts`, healthApiSource);
|
|
499
591
|
add(`${ui}/src/modules/health/views/HealthView.vue`, `<script setup lang="ts">
|
|
500
592
|
import { ref } from "vue";
|
|
@@ -536,23 +628,31 @@ async function checkReadiness() {
|
|
|
536
628
|
<p v-if="error" role="alert">{{ error }}</p>
|
|
537
629
|
</main>
|
|
538
630
|
</template>`);
|
|
539
|
-
add(`${ui}/src/app/App.vue`, `<template
|
|
631
|
+
add(`${ui}/src/app/App.vue`, `<template>
|
|
632
|
+
<RouterView />
|
|
633
|
+
<NotificationHost />
|
|
634
|
+
</template>
|
|
635
|
+
|
|
636
|
+
<script setup lang="ts">
|
|
637
|
+
import { RouterView } from "vue-router";
|
|
638
|
+
import NotificationHost from "@/app/components/NotificationHost.vue";
|
|
639
|
+
</script>`);
|
|
540
640
|
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`);
|
|
541
641
|
add(`${ui}/src/main.ts`, `import { startApplication } from "@/app/startApplication";\nstartApplication();\n`);
|
|
542
642
|
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; }`);
|
|
543
|
-
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); }); });`);
|
|
643
|
+
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); }); });`);
|
|
544
644
|
add(`${ui}/AGENTS.md`, `# ${title} UI guidance
|
|
545
645
|
|
|
546
646
|
## Scope
|
|
547
647
|
These conventions apply to \`${ui}/src\`. Inspect the nearest working module before adding files.
|
|
548
648
|
|
|
549
649
|
## Architecture vocabulary
|
|
550
|
-
- Use \`src/app\` for Vue bootstrap, the application shell, routing, global styles, and the closed Tailframe auth/theme catalog.
|
|
650
|
+
- Use \`src/app\` for Vue bootstrap, the application shell, routing, global styles, and the closed Tailframe auth/notification/theme catalog.
|
|
551
651
|
- Use \`src/core\` only for small technology-neutral client contracts.
|
|
552
652
|
- Use \`src/platform\` for Axios, Firebase, browser APIs, and other technical adapters.
|
|
553
653
|
- Use \`src/modules/<module>\` for every product capability. Never substitute \`src/features\` or global \`apis\`, \`views\`, \`services\`, \`stores\`, or \`types\` directories.
|
|
554
|
-
- 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\`.
|
|
555
|
-
- Never create \`src/modules/theme\` or \`src/modules/
|
|
654
|
+
- 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.
|
|
556
656
|
|
|
557
657
|
## Change placement
|
|
558
658
|
| Change | Canonical owner |
|
|
@@ -560,14 +660,14 @@ These conventions apply to \`${ui}/src\`. Inspect the nearest working module bef
|
|
|
560
660
|
| Capability API, component, composable, state, type, or view | \`src/modules/<module>\` |
|
|
561
661
|
| Capability intentionally consumed by another module | Named file directly under provider \`src/modules/<module>/public\` |
|
|
562
662
|
| Axios, Firebase, browser, or vendor mechanism | \`src/platform\` |
|
|
563
|
-
| Firebase identity/readiness or theme preference | Generated \`src/app/stores/auth.store.ts\` or \`theme.store.ts\` |
|
|
663
|
+
| Firebase identity/readiness, notification queue, or theme preference | Generated \`src/app/stores/auth.store.ts\`, \`notification.store.ts\`, or \`theme.store.ts\` |
|
|
564
664
|
| Canonical theme control consumed by modules | Generated \`src/app/public/ThemeToggle.vue\` |
|
|
565
665
|
| Any other state or reusable presentation | Owning \`src/modules/<module>\` |
|
|
566
666
|
| Shell, router, global styles, or cross-capability composition | \`src/app\` |
|
|
567
667
|
| Technology-neutral client contract | Flat \`src/core\` |
|
|
568
668
|
| Root executable | Bootstrap of \`src/app\` only |
|
|
569
669
|
|
|
570
|
-
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.
|
|
670
|
+
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.
|
|
571
671
|
|
|
572
672
|
## Module shape
|
|
573
673
|
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\`.
|
|
@@ -582,7 +682,7 @@ Use named routes.${options.auth === "firebase" ? " Preserve Firebase authenticat
|
|
|
582
682
|
|
|
583
683
|
## State management
|
|
584
684
|
Keep local state local. Use Pinia only for state shared across routes or unrelated components.
|
|
585
|
-
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.
|
|
685
|
+
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.
|
|
586
686
|
|
|
587
687
|
## UI behavior
|
|
588
688
|
Reuse Vue, PrimeVue, Tailwind, loading, error, and accessibility conventions. Provide explicit loading, empty, error, and authorization states.
|
|
@@ -610,6 +710,11 @@ RUN npm ci
|
|
|
610
710
|
COPY ${svc}/ ./
|
|
611
711
|
RUN npm run build
|
|
612
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
|
+
|
|
613
718
|
FROM node:22.13-bookworm AS ui-build
|
|
614
719
|
WORKDIR /app/ui
|
|
615
720
|
COPY ${ui}/package*.json ./
|
|
@@ -622,8 +727,9 @@ WORKDIR /app
|
|
|
622
727
|
ENV NODE_ENV=production
|
|
623
728
|
COPY --from=service-build /app/service/dist ./dist
|
|
624
729
|
COPY --from=service-build /app/service/package*.json ./
|
|
625
|
-
COPY --from=service-
|
|
730
|
+
COPY --from=service-production-dependencies /app/service/node_modules ./node_modules
|
|
626
731
|
COPY --from=ui-build /app/ui/dist ./dist/public
|
|
732
|
+
USER node
|
|
627
733
|
EXPOSE 3000
|
|
628
734
|
CMD ["node", "dist/server.js"]
|
|
629
735
|
` : `FROM node:22.13-bookworm AS build
|
|
@@ -633,12 +739,18 @@ RUN npm ci
|
|
|
633
739
|
COPY . .
|
|
634
740
|
RUN npm run build
|
|
635
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
|
|
746
|
+
|
|
636
747
|
FROM node:22.13-bookworm-slim
|
|
637
748
|
WORKDIR /app
|
|
638
749
|
ENV NODE_ENV=production
|
|
639
750
|
COPY --from=build /app/dist ./dist
|
|
640
751
|
COPY --from=build /app/package*.json ./
|
|
641
|
-
COPY --from=
|
|
752
|
+
COPY --from=production-dependencies /app/node_modules ./node_modules
|
|
753
|
+
USER node
|
|
642
754
|
EXPOSE 3000
|
|
643
755
|
CMD ["node", "dist/server.js"]
|
|
644
756
|
`);
|
|
@@ -678,75 +790,443 @@ docker push "\${IMAGE}"
|
|
|
678
790
|
echo "Pushed \${IMAGE}"
|
|
679
791
|
`);
|
|
680
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
|
+
` : "";
|
|
802
|
+
const workflowUiEnvironment = options.ui && options.auth === "firebase" ? `
|
|
803
|
+
- name: Write UI production environment
|
|
804
|
+
env:
|
|
805
|
+
UI_ENV_PRODUCTION: \${{ secrets.UI_ENV_PRODUCTION }}
|
|
806
|
+
run: |
|
|
807
|
+
printf '%s\\n' "$UI_ENV_PRODUCTION" > ${ui}/.env.production
|
|
808
|
+
` : "";
|
|
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
|
|
814
|
+
|
|
815
|
+
on:
|
|
816
|
+
push:
|
|
817
|
+
branches:
|
|
818
|
+
- main
|
|
819
|
+
workflow_dispatch:
|
|
820
|
+
|
|
821
|
+
permissions:
|
|
822
|
+
contents: read
|
|
823
|
+
id-token: write
|
|
824
|
+
|
|
825
|
+
concurrency:
|
|
826
|
+
group: ${options.name}-production
|
|
827
|
+
cancel-in-progress: false
|
|
828
|
+
|
|
829
|
+
jobs:
|
|
830
|
+
deploy:
|
|
831
|
+
name: Deploy production
|
|
832
|
+
if: \${{ vars.DEPLOY_HOST != '' && vars.DEPLOY_USER != '' }}
|
|
833
|
+
runs-on: ubuntu-latest
|
|
834
|
+
environment: production
|
|
835
|
+
env:
|
|
836
|
+
AWS_ACCOUNT_ID: \${{ vars.AWS_ACCOUNT_ID }}
|
|
837
|
+
AWS_REGION: \${{ vars.AWS_REGION }}
|
|
838
|
+
ECR_REPOSITORY: \${{ vars.ECR_REPOSITORY || '${options.name}' }}
|
|
839
|
+
DEPLOY_HOST: \${{ vars.DEPLOY_HOST }}
|
|
840
|
+
DEPLOY_USER: \${{ vars.DEPLOY_USER }}
|
|
841
|
+
|
|
842
|
+
steps:
|
|
843
|
+
- name: Checkout service
|
|
844
|
+
uses: actions/checkout@v6
|
|
845
|
+
with:
|
|
846
|
+
path: ${svc}
|
|
847
|
+
${workflowUiCheckout}${workflowUiEnvironment}
|
|
848
|
+
- name: Resolve immutable image
|
|
849
|
+
id: image
|
|
850
|
+
run: |
|
|
851
|
+
svc_sha="$(git -C ${svc} rev-parse --short HEAD)"${workflowTagResolution}
|
|
852
|
+
echo "registry=\${AWS_ACCOUNT_ID}.dkr.ecr.\${AWS_REGION}.amazonaws.com" >> "$GITHUB_OUTPUT"
|
|
853
|
+
echo "image=\${AWS_ACCOUNT_ID}.dkr.ecr.\${AWS_REGION}.amazonaws.com/\${ECR_REPOSITORY}" >> "$GITHUB_OUTPUT"
|
|
854
|
+
|
|
855
|
+
- name: Configure AWS credentials
|
|
856
|
+
uses: aws-actions/configure-aws-credentials@v6
|
|
857
|
+
with:
|
|
858
|
+
role-to-assume: \${{ vars.AWS_ROLE_ARN }}
|
|
859
|
+
aws-region: \${{ vars.AWS_REGION }}
|
|
860
|
+
|
|
861
|
+
- name: Build and push image
|
|
862
|
+
working-directory: ${svc}
|
|
863
|
+
env:
|
|
864
|
+
IMAGE_TAG: \${{ steps.image.outputs.tag }}
|
|
865
|
+
run: ./scripts/build_and_push.sh
|
|
866
|
+
|
|
867
|
+
- name: Configure deployment SSH
|
|
868
|
+
env:
|
|
869
|
+
DEPLOY_SSH_PRIVATE_KEY: \${{ secrets.DEPLOY_SSH_PRIVATE_KEY }}
|
|
870
|
+
DEPLOY_SSH_KNOWN_HOSTS: \${{ secrets.DEPLOY_SSH_KNOWN_HOSTS }}
|
|
871
|
+
run: |
|
|
872
|
+
install -m 700 -d ~/.ssh
|
|
873
|
+
printf '%s\\n' "$DEPLOY_SSH_PRIVATE_KEY" > ~/.ssh/${options.name}-deploy
|
|
874
|
+
chmod 600 ~/.ssh/${options.name}-deploy
|
|
875
|
+
printf '%s\\n' "$DEPLOY_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
|
|
876
|
+
|
|
877
|
+
- name: Authenticate server with ECR
|
|
878
|
+
run: |
|
|
879
|
+
aws ecr get-login-password --region "$AWS_REGION" |
|
|
880
|
+
ssh -i ~/.ssh/${options.name}-deploy "\${DEPLOY_USER}@\${DEPLOY_HOST}" \\
|
|
881
|
+
"docker login --username AWS --password-stdin '\${{ steps.image.outputs.registry }}'"
|
|
882
|
+
|
|
883
|
+
- name: Install deployment files
|
|
884
|
+
run: |
|
|
885
|
+
ssh -i ~/.ssh/${options.name}-deploy "\${DEPLOY_USER}@\${DEPLOY_HOST}" \\
|
|
886
|
+
"install -m 755 -d /opt/${options.name}/current/deploy/mongo"
|
|
887
|
+
scp -i ~/.ssh/${options.name}-deploy \\
|
|
888
|
+
${svc}/docker-compose.yml \\
|
|
889
|
+
${svc}/scripts/deploy_remote.sh \\
|
|
890
|
+
"\${DEPLOY_USER}@\${DEPLOY_HOST}:/opt/${options.name}/current/"
|
|
891
|
+
scp -i ~/.ssh/${options.name}-deploy \\
|
|
892
|
+
${svc}/deploy/mongo/10-create-users.js \\
|
|
893
|
+
"\${DEPLOY_USER}@\${DEPLOY_HOST}:/opt/${options.name}/current/deploy/mongo/"
|
|
894
|
+
ssh -i ~/.ssh/${options.name}-deploy "\${DEPLOY_USER}@\${DEPLOY_HOST}" \\
|
|
895
|
+
"chmod 755 /opt/${options.name}/current/deploy_remote.sh"
|
|
896
|
+
|
|
897
|
+
- name: Deploy image
|
|
898
|
+
run: |
|
|
899
|
+
ssh -i ~/.ssh/${options.name}-deploy "\${DEPLOY_USER}@\${DEPLOY_HOST}" \\
|
|
900
|
+
"/opt/${options.name}/current/deploy_remote.sh '\${{ steps.image.outputs.tag }}' '\${{ steps.image.outputs.image }}'"
|
|
901
|
+
`);
|
|
902
|
+
|
|
903
|
+
add(`${svc}/scripts/deploy_remote.sh`, `#!/usr/bin/env bash
|
|
904
|
+
set -euo pipefail
|
|
905
|
+
|
|
906
|
+
IMAGE_TAG="\${1:?Usage: deploy_remote.sh IMAGE_TAG ECR_IMAGE}"
|
|
907
|
+
ECR_IMAGE="\${2:?Usage: deploy_remote.sh IMAGE_TAG ECR_IMAGE}"
|
|
908
|
+
DEPLOY_DIR="\${DEPLOY_DIR:-/opt/${options.name}/current}"
|
|
909
|
+
SHARED_DIR="\${SHARED_DIR:-/opt/${options.name}/shared}"
|
|
910
|
+
COMPOSE_FILE="\${DEPLOY_DIR}/docker-compose.yml"
|
|
911
|
+
INFRA_ENV="\${SHARED_DIR}/.env.infrastructure"
|
|
912
|
+
|
|
913
|
+
if [[ ! -f "\${INFRA_ENV}" ]]; then
|
|
914
|
+
echo "Missing provisioned infrastructure environment: \${INFRA_ENV}" >&2
|
|
915
|
+
exit 1
|
|
916
|
+
fi
|
|
917
|
+
|
|
918
|
+
NEXT_ENV="$(mktemp "\${SHARED_DIR}/.env.infrastructure.XXXXXX")"
|
|
919
|
+
cleanup() {
|
|
920
|
+
rm -f "\${NEXT_ENV}"
|
|
921
|
+
}
|
|
922
|
+
trap cleanup EXIT
|
|
923
|
+
|
|
924
|
+
grep -Ev '^(ECR_IMAGE|IMAGE_TAG)=' "\${INFRA_ENV}" > "\${NEXT_ENV}" || true
|
|
925
|
+
printf 'ECR_IMAGE=%s\\nIMAGE_TAG=%s\\n' "\${ECR_IMAGE}" "\${IMAGE_TAG}" >> "\${NEXT_ENV}"
|
|
926
|
+
chmod 600 "\${NEXT_ENV}"
|
|
927
|
+
mv "\${NEXT_ENV}" "\${INFRA_ENV}"
|
|
928
|
+
trap - EXIT
|
|
929
|
+
|
|
930
|
+
docker compose --env-file "\${INFRA_ENV}" --file "\${COMPOSE_FILE}" config --quiet
|
|
931
|
+
docker compose --env-file "\${INFRA_ENV}" --file "\${COMPOSE_FILE}" pull
|
|
932
|
+
docker compose --env-file "\${INFRA_ENV}" --file "\${COMPOSE_FILE}" up -d --wait
|
|
933
|
+
`);
|
|
934
|
+
|
|
935
|
+
if (postgres) add(`${svc}/deploy/postgres/10-create-application-user.sh`, `#!/usr/bin/env bash
|
|
936
|
+
set -euo pipefail
|
|
937
|
+
|
|
938
|
+
psql --set ON_ERROR_STOP=1 --username postgres --dbname "${databaseName}" --set app_password="\${POSTGRES_APP_PASSWORD}" <<'SQL'
|
|
939
|
+
SELECT 'CREATE ROLE "${options.name}-app" LOGIN PASSWORD ' || quote_literal(:'app_password')
|
|
940
|
+
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${options.name}-app')\gexec
|
|
941
|
+
GRANT CONNECT ON DATABASE "${databaseName}" TO "${options.name}-app";
|
|
942
|
+
GRANT USAGE ON SCHEMA public TO "${options.name}-app";
|
|
943
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO "${options.name}-app";
|
|
944
|
+
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO "${options.name}-app";
|
|
945
|
+
ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO "${options.name}-app";
|
|
946
|
+
ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO "${options.name}-app";
|
|
947
|
+
SQL
|
|
948
|
+
`);
|
|
949
|
+
|
|
950
|
+
add(`${svc}/docs/production-deployment.md`, `# Production deployment
|
|
951
|
+
|
|
952
|
+
## Delivery model
|
|
953
|
+
|
|
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.
|
|
957
|
+
|
|
958
|
+
The host separates replaceable artifacts from persistent state:
|
|
959
|
+
|
|
960
|
+
\`\`\`text
|
|
961
|
+
/opt/${options.name}/
|
|
962
|
+
├── current/
|
|
963
|
+
│ ├── docker-compose.yml
|
|
964
|
+
│ ├── deploy_remote.sh
|
|
965
|
+
│ └── deploy/mongo/10-create-users.js
|
|
966
|
+
└── shared/
|
|
967
|
+
├── .env.production
|
|
968
|
+
├── .env.infrastructure
|
|
969
|
+
${options.auth === "firebase" ? ` ├── secrets/firebase-service-account.json
|
|
970
|
+
` : ""} ├── mongo/config/keyfile
|
|
971
|
+
├── mongo/data/
|
|
972
|
+
${options.redis ? ` └── redis/data/
|
|
973
|
+
` : ""}\`\`\`
|
|
974
|
+
|
|
975
|
+
Actions may replace \`current/\` and must never replace \`shared/\`. The server never needs a source checkout.
|
|
976
|
+
|
|
977
|
+
## Configuration ownership
|
|
978
|
+
|
|
979
|
+
- \`.env.production\` contains Node application behavior and provider credentials.
|
|
980
|
+
- \`.env.infrastructure\` contains Compose paths, image identity, and infrastructure passwords. Start from
|
|
981
|
+
\`.env.infrastructure.example\` and replace every placeholder.
|
|
982
|
+
- Compose \`environment:\` owns fixed production wiring: \`NODE_ENV\`, internal database/cache URLs, and mounted
|
|
983
|
+
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.
|
|
985
|
+
` : ""}
|
|
986
|
+
Do not inject \`.env.infrastructure\` into Node. Make both host environment files root-owned and mode \`0600\`.
|
|
987
|
+
|
|
988
|
+
## One-time host bootstrap
|
|
989
|
+
|
|
990
|
+
1. Install Docker Engine and Docker Compose.
|
|
991
|
+
2. Create \`/opt/${options.name}/current/deploy/mongo\`, \`/opt/${options.name}/shared/mongo/config\`, and
|
|
992
|
+
\`/opt/${options.name}/shared/mongo/data\`${options.redis ? `, plus \`/opt/${options.name}/shared/redis/data\`` : ""}.
|
|
993
|
+
3. Install \`.env.production\` and a completed \`.env.infrastructure\` under \`shared/\`.
|
|
994
|
+
4. Generate the MongoDB replica key with \`openssl rand -base64 756\`, make it owned by UID/GID \`999:999\`, and
|
|
995
|
+
mode \`0400\`.
|
|
996
|
+
${options.auth === "firebase" ? `5. Install the Firebase service account at \`shared/secrets/firebase-service-account.json\`, owned by \`root:1000\` and mode \`0640\`.
|
|
997
|
+
6. ` : "5. "}Give the deployment user Docker access and write access to \`current/\`; keep \`shared/\` and its files protected.
|
|
998
|
+
${options.auth === "firebase" ? "7" : "6"}. Authorize the dedicated deployment SSH key and record the host key for GitHub Actions.
|
|
999
|
+
|
|
1000
|
+
Use a fresh Mongo data directory. Migrating an existing unauthenticated database requires a separately reviewed plan.
|
|
1001
|
+
|
|
1002
|
+
## GitHub production environment
|
|
1003
|
+
|
|
1004
|
+
Configure these variables:
|
|
1005
|
+
|
|
1006
|
+
- \`AWS_ACCOUNT_ID\`, \`AWS_REGION\`, and \`AWS_ROLE_ARN\` for the repository-restricted OIDC role.
|
|
1007
|
+
- \`DEPLOY_HOST\` and \`DEPLOY_USER\`; the deployment job remains skipped until both exist.
|
|
1008
|
+
- Optional \`ECR_REPOSITORY\`; the default is \`${options.name}\`.
|
|
1009
|
+
|
|
1010
|
+
Configure these secrets:
|
|
1011
|
+
|
|
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.
|
|
1014
|
+
` : ""}- \`DEPLOY_SSH_PRIVATE_KEY\`: dedicated host deployment key.
|
|
1015
|
+
- \`DEPLOY_SSH_KNOWN_HOSTS\`: pinned host key.
|
|
1016
|
+
|
|
1017
|
+
The ECR repository and AWS OIDC provider/role are provisioned separately. The workflow never stores long-lived AWS
|
|
1018
|
+
keys or creates cloud infrastructure.
|
|
1019
|
+
|
|
1020
|
+
## Deploy and rollback
|
|
1021
|
+
|
|
1022
|
+
The remote script requires the provisioned infrastructure file, atomically replaces only \`ECR_IMAGE\` and
|
|
1023
|
+
\`IMAGE_TAG\`, validates Compose, pulls, and starts with health waiting. Roll back by selecting a previous immutable
|
|
1024
|
+
tag and running the same script. Never use \`docker compose down -v\` in production; image rollback does not restore
|
|
1025
|
+
database state.
|
|
1026
|
+
|
|
1027
|
+
MongoDB and optional Redis expose no host ports and require authentication. Verify API readiness, container health,
|
|
1028
|
+
database/cache authentication, file permissions, and off-host backup/restore before treating the deployment as
|
|
1029
|
+
production-ready. Product-specific TLS, capacity limits, migrations, and scheduled-work grace periods must be added
|
|
1030
|
+
from measured application requirements rather than copied from another product.
|
|
1031
|
+
`);
|
|
1032
|
+
|
|
681
1033
|
const serviceVolumes = [
|
|
682
|
-
...(options.auth === "firebase" ? [" -
|
|
1034
|
+
...(options.auth === "firebase" ? [" - ${SECRETS_DIR:?Set SECRETS_DIR}/firebase-service-account.json:/app/secrets/firebase-service-account.json:ro"] : [])
|
|
683
1035
|
];
|
|
684
1036
|
const devVolumes = [
|
|
685
1037
|
" - .:/app",
|
|
686
1038
|
" - /app/node_modules",
|
|
687
1039
|
...(options.auth === "firebase" ? [" - ./firebase-service-account.development.json:/app/firebase-service-account.development.json:ro"] : [])
|
|
688
1040
|
];
|
|
689
|
-
const
|
|
690
|
-
["
|
|
1041
|
+
const productionDependencies = [
|
|
1042
|
+
["schema-init", "service_completed_successfully"],
|
|
1043
|
+
...(options.redis ? [["redis", "service_healthy"]] : [])
|
|
1044
|
+
];
|
|
1045
|
+
const developmentDependencies = [
|
|
1046
|
+
["schema-init", "service_completed_successfully"],
|
|
691
1047
|
...(options.redis ? [["redis", "service_started"]] : [])
|
|
692
1048
|
];
|
|
693
|
-
const dependsBlock = `\n depends_on:\n${
|
|
694
|
-
const
|
|
1049
|
+
const dependsBlock = `\n depends_on:\n${productionDependencies.map(([dependency, condition]) => ` ${dependency}:\n condition: ${condition}`).join("\n")}`;
|
|
1050
|
+
const devDependsBlock = `\n depends_on:\n${developmentDependencies.map(([dependency, condition]) => ` ${dependency}:\n condition: ${condition}`).join("\n")}`;
|
|
1051
|
+
const productionEnvironment = [
|
|
1052
|
+
" NODE_ENV: production",
|
|
1053
|
+
...(postgres
|
|
1054
|
+
? [` POSTGRES_URI: "postgresql://${options.name}-app:\${POSTGRES_APP_PASSWORD:?Set POSTGRES_APP_PASSWORD}@postgres:5432/${databaseName}"`]
|
|
1055
|
+
: [
|
|
1056
|
+
` MONGODB_URI: "mongodb://${mongoApplicationUser}:\${MONGODB_APP_PASSWORD:?Set MONGODB_APP_PASSWORD}@mongodb:27017/${databaseName}?replicaSet=rs0&directConnection=true&authSource=${databaseName}"`,
|
|
1057
|
+
` MONGODB_DB_NAME: "${databaseName}"`
|
|
1058
|
+
]),
|
|
1059
|
+
...(options.auth === "firebase" ? [" GOOGLE_APPLICATION_CREDENTIALS: /app/secrets/firebase-service-account.json"] : []),
|
|
1060
|
+
...(options.redis ? [" REDIS_URL: \"redis://:\${REDIS_PASSWORD:?Set REDIS_PASSWORD}@redis:6379\""] : [])
|
|
1061
|
+
];
|
|
1062
|
+
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"`;
|
|
695
1066
|
const volumeBlock = serviceVolumes.length ? `\n volumes:\n${serviceVolumes.join("\n")}` : "";
|
|
696
1067
|
const workerService = options.worker ? `\n worker:
|
|
697
1068
|
image: \${ECR_IMAGE:?Set ECR_IMAGE}:\${IMAGE_TAG:?Set IMAGE_TAG}
|
|
698
1069
|
env_file:
|
|
699
|
-
-
|
|
700
|
-
command: ["node", "dist/worker.js"]${
|
|
1070
|
+
- \${APP_ENV_FILE:-.env.production}
|
|
1071
|
+
command: ["node", "dist/worker.js"]${productionEnvironmentBlock}${volumeBlock}${dependsBlock}
|
|
1072
|
+
healthcheck:
|
|
1073
|
+
test: ["CMD", "node", "-e", "process.kill(1, 0)"]
|
|
1074
|
+
interval: 60s
|
|
1075
|
+
timeout: 5s
|
|
1076
|
+
retries: 3
|
|
1077
|
+
start_period: 30s
|
|
1078
|
+
stop_grace_period: 2m
|
|
1079
|
+
networks:
|
|
1080
|
+
- backend
|
|
701
1081
|
restart: unless-stopped` : "";
|
|
702
|
-
const databaseService = `\n
|
|
1082
|
+
const databaseService = postgres ? `\n postgres:
|
|
1083
|
+
image: postgres:17
|
|
1084
|
+
environment:
|
|
1085
|
+
POSTGRES_DB: "${databaseName}"
|
|
1086
|
+
POSTGRES_USER: postgres
|
|
1087
|
+
POSTGRES_PASSWORD: \${POSTGRES_ROOT_PASSWORD:?Set POSTGRES_ROOT_PASSWORD}
|
|
1088
|
+
POSTGRES_APP_PASSWORD: \${POSTGRES_APP_PASSWORD:?Set POSTGRES_APP_PASSWORD}
|
|
1089
|
+
volumes:
|
|
1090
|
+
- \${POSTGRES_DATA_DIR:?Set POSTGRES_DATA_DIR}:/var/lib/postgresql/data
|
|
1091
|
+
- ./deploy/postgres/10-create-application-user.sh:/docker-entrypoint-initdb.d/10-create-application-user.sh:ro
|
|
1092
|
+
healthcheck:
|
|
1093
|
+
test: ["CMD-SHELL", "pg_isready -U postgres -d ${databaseName}"]
|
|
1094
|
+
interval: 5s
|
|
1095
|
+
timeout: 5s
|
|
1096
|
+
retries: 30
|
|
1097
|
+
start_period: 20s
|
|
1098
|
+
networks:
|
|
1099
|
+
- backend
|
|
1100
|
+
restart: unless-stopped` : `\n mongodb:
|
|
703
1101
|
image: mongo:8
|
|
704
|
-
command:
|
|
1102
|
+
command:
|
|
1103
|
+
- mongod
|
|
1104
|
+
- --replSet
|
|
1105
|
+
- rs0
|
|
1106
|
+
- --bind_ip_all
|
|
1107
|
+
- --keyFile
|
|
1108
|
+
- /etc/mongo-keyfile/keyfile
|
|
1109
|
+
environment:
|
|
1110
|
+
MONGO_INITDB_DATABASE: "${databaseName}"
|
|
1111
|
+
MONGO_INITDB_ROOT_USERNAME: "${options.name}-root"
|
|
1112
|
+
MONGO_INITDB_ROOT_PASSWORD: \${MONGODB_ROOT_PASSWORD:?Set MONGODB_ROOT_PASSWORD}
|
|
1113
|
+
MONGODB_DB_NAME: "${databaseName}"
|
|
1114
|
+
MONGODB_APP_USERNAME: "${mongoApplicationUser}"
|
|
1115
|
+
MONGODB_APP_PASSWORD: \${MONGODB_APP_PASSWORD:?Set MONGODB_APP_PASSWORD}
|
|
1116
|
+
MONGODB_BACKUP_USERNAME: "${mongoBackupUser}"
|
|
1117
|
+
MONGODB_BACKUP_PASSWORD: \${MONGODB_BACKUP_PASSWORD:?Set MONGODB_BACKUP_PASSWORD}
|
|
1118
|
+
MONGODB_APP_COLLECTIONS: "_tailframe_reserved"
|
|
705
1119
|
volumes:
|
|
706
|
-
-
|
|
1120
|
+
- \${MONGO_DATA_DIR:?Set MONGO_DATA_DIR}:/data/db
|
|
1121
|
+
- \${MONGO_KEYFILE:?Set MONGO_KEYFILE}:/etc/mongo-keyfile/keyfile:ro
|
|
1122
|
+
- \${MONGO_INIT_SCRIPT:?Set MONGO_INIT_SCRIPT}:/docker-entrypoint-initdb.d/10-create-users.js:ro
|
|
707
1123
|
healthcheck:
|
|
708
|
-
test:
|
|
709
|
-
|
|
710
|
-
|
|
1124
|
+
test:
|
|
1125
|
+
- CMD-SHELL
|
|
1126
|
+
- >-
|
|
1127
|
+
mongosh --quiet --host localhost
|
|
1128
|
+
--username "$\${MONGO_INITDB_ROOT_USERNAME}"
|
|
1129
|
+
--password "$\${MONGO_INITDB_ROOT_PASSWORD}"
|
|
1130
|
+
--authenticationDatabase admin
|
|
1131
|
+
--eval "quit(db.adminCommand({ping:1}).ok ? 0 : 1)"
|
|
1132
|
+
interval: 5s
|
|
1133
|
+
timeout: 5s
|
|
711
1134
|
retries: 30
|
|
1135
|
+
start_period: 20s
|
|
1136
|
+
networks:
|
|
1137
|
+
- backend
|
|
712
1138
|
restart: unless-stopped
|
|
713
1139
|
mongo-init:
|
|
714
1140
|
image: mongo:8
|
|
1141
|
+
environment:
|
|
1142
|
+
MONGO_INITDB_ROOT_USERNAME: "${options.name}-root"
|
|
1143
|
+
MONGO_INITDB_ROOT_PASSWORD: \${MONGODB_ROOT_PASSWORD:?Set MONGODB_ROOT_PASSWORD}
|
|
715
1144
|
depends_on:
|
|
716
1145
|
mongodb:
|
|
717
1146
|
condition: service_healthy
|
|
718
1147
|
restart: "no"
|
|
719
|
-
entrypoint:
|
|
720
|
-
|
|
721
|
-
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
1148
|
+
entrypoint: ["bash", "-ec"]
|
|
1149
|
+
command:
|
|
1150
|
+
- >-
|
|
1151
|
+
mongosh --quiet --host mongodb:27017
|
|
1152
|
+
--username "$\${MONGO_INITDB_ROOT_USERNAME}"
|
|
1153
|
+
--password "$\${MONGO_INITDB_ROOT_PASSWORD}"
|
|
1154
|
+
--authenticationDatabase admin
|
|
1155
|
+
--eval "try { const status = rs.status(); quit(status.ok === 1 ? 0 : 1); }
|
|
1156
|
+
catch (error) {
|
|
1157
|
+
if (error.code === 94 || error.codeName === 'NotYetInitialized') {
|
|
1158
|
+
const result = rs.initiate({_id:'rs0',members:[{_id:0,host:'mongodb:27017'}]});
|
|
1159
|
+
quit(result.ok === 1 ? 0 : 1);
|
|
728
1160
|
}
|
|
729
|
-
|
|
1161
|
+
throw error;
|
|
1162
|
+
}"
|
|
1163
|
+
networks:
|
|
1164
|
+
- backend`;
|
|
1165
|
+
const schemaInitDependencies = postgres ? [["postgres", "service_healthy"]] : [["mongo-init", "service_completed_successfully"]];
|
|
1166
|
+
const schemaInitDependsBlock = `\n depends_on:\n${schemaInitDependencies.map(([dependency, condition]) => ` ${dependency}:\n condition: ${condition}`).join("\n")}`;
|
|
1167
|
+
const schemaInitEnvironment = postgres
|
|
1168
|
+
? `\n environment:\n POSTGRES_URI: "postgresql://postgres:\${POSTGRES_ROOT_PASSWORD:?Set POSTGRES_ROOT_PASSWORD}@postgres:5432/${databaseName}"`
|
|
1169
|
+
: `\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}"`;
|
|
1170
|
+
const schemaInitService = `\n schema-init:
|
|
1171
|
+
image: \${ECR_IMAGE:?Set ECR_IMAGE}:\${IMAGE_TAG:?Set IMAGE_TAG}
|
|
1172
|
+
command: ["node", "dist/app/cli/applySchema.js"]${schemaInitEnvironment}${schemaInitDependsBlock}
|
|
1173
|
+
networks:
|
|
1174
|
+
- backend
|
|
1175
|
+
restart: "no"`;
|
|
730
1176
|
const redisService = options.redis ? `\n redis:
|
|
731
1177
|
image: redis:7-alpine
|
|
1178
|
+
command:
|
|
1179
|
+
- redis-server
|
|
1180
|
+
- --appendonly
|
|
1181
|
+
- "yes"
|
|
1182
|
+
- --appendfsync
|
|
1183
|
+
- everysec
|
|
1184
|
+
- --maxmemory-policy
|
|
1185
|
+
- noeviction
|
|
1186
|
+
- --requirepass
|
|
1187
|
+
- \${REDIS_PASSWORD:?Set REDIS_PASSWORD}
|
|
1188
|
+
environment:
|
|
1189
|
+
REDIS_PASSWORD: \${REDIS_PASSWORD:?Set REDIS_PASSWORD}
|
|
732
1190
|
volumes:
|
|
733
|
-
-
|
|
1191
|
+
- \${REDIS_DATA_DIR:?Set REDIS_DATA_DIR}:/data
|
|
1192
|
+
healthcheck:
|
|
1193
|
+
test:
|
|
1194
|
+
- CMD-SHELL
|
|
1195
|
+
- REDISCLI_AUTH="$\${REDIS_PASSWORD}" redis-cli ping | grep -q PONG
|
|
1196
|
+
interval: 10s
|
|
1197
|
+
timeout: 3s
|
|
1198
|
+
retries: 10
|
|
1199
|
+
start_period: 10s
|
|
1200
|
+
networks:
|
|
1201
|
+
- backend
|
|
734
1202
|
restart: unless-stopped` : "";
|
|
735
|
-
const namedVolumes = [
|
|
736
|
-
" mongodb-data:",
|
|
737
|
-
...(options.redis ? [" redis-data:"] : [])
|
|
738
|
-
];
|
|
739
1203
|
add(`${svc}/docker-compose.yml`, `name: ${options.name}-prod
|
|
740
1204
|
services:
|
|
741
1205
|
${options.name}:
|
|
742
1206
|
image: \${ECR_IMAGE:?Set ECR_IMAGE}:\${IMAGE_TAG:?Set IMAGE_TAG}
|
|
743
1207
|
env_file:
|
|
744
|
-
-
|
|
1208
|
+
- \${APP_ENV_FILE:-.env.production}
|
|
745
1209
|
ports:
|
|
746
|
-
- "3000:3000"${
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
1210
|
+
- "3000:3000"${productionEnvironmentBlock}${volumeBlock}${dependsBlock}
|
|
1211
|
+
healthcheck:
|
|
1212
|
+
test:
|
|
1213
|
+
- CMD-SHELL
|
|
1214
|
+
- >-
|
|
1215
|
+
node -e
|
|
1216
|
+
"fetch('http://127.0.0.1:3000/api/v1/health.readiness',
|
|
1217
|
+
{method:'POST',headers:{'Content-Type':'application/json','X-Requested-With':'XMLHttpRequest'},body:'{}'})
|
|
1218
|
+
.then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
|
1219
|
+
interval: 30s
|
|
1220
|
+
timeout: 10s
|
|
1221
|
+
retries: 3
|
|
1222
|
+
start_period: 30s
|
|
1223
|
+
stop_grace_period: 2m
|
|
1224
|
+
networks:
|
|
1225
|
+
- backend
|
|
1226
|
+
restart: unless-stopped${workerService}${schemaInitService}${databaseService}${redisService}
|
|
1227
|
+
networks:
|
|
1228
|
+
backend:
|
|
1229
|
+
driver: bridge
|
|
750
1230
|
`);
|
|
751
1231
|
|
|
752
1232
|
const devWorker = options.worker ? `\n worker:
|
|
@@ -757,8 +1237,22 @@ const devWorker = options.worker ? `\n worker:
|
|
|
757
1237
|
- .env.development
|
|
758
1238
|
command: ["npm", "run", "dev:worker"]${databaseEnvironmentBlock}
|
|
759
1239
|
volumes:
|
|
760
|
-
${devVolumes.join("\n")}${
|
|
761
|
-
const devDatabase = `\n
|
|
1240
|
+
${devVolumes.join("\n")}${devDependsBlock}` : "";
|
|
1241
|
+
const devDatabase = postgres ? `\n postgres:
|
|
1242
|
+
image: postgres:17
|
|
1243
|
+
ports:
|
|
1244
|
+
- "5432:5432"
|
|
1245
|
+
environment:
|
|
1246
|
+
POSTGRES_DB: ${databaseName}
|
|
1247
|
+
POSTGRES_USER: ${options.name}-app
|
|
1248
|
+
POSTGRES_PASSWORD: development
|
|
1249
|
+
volumes:
|
|
1250
|
+
- postgres-dev-data:/var/lib/postgresql/data
|
|
1251
|
+
healthcheck:
|
|
1252
|
+
test: ["CMD-SHELL", "pg_isready -U ${options.name}-app -d ${databaseName}"]
|
|
1253
|
+
interval: 2s
|
|
1254
|
+
timeout: 2s
|
|
1255
|
+
retries: 30` : `\n mongodb:
|
|
762
1256
|
image: mongo:8
|
|
763
1257
|
command: mongod --replSet rs0 --bind_ip_all
|
|
764
1258
|
ports:
|
|
@@ -794,11 +1288,23 @@ const devRedis = options.redis ? `\n redis:
|
|
|
794
1288
|
volumes:
|
|
795
1289
|
- redis-dev-data:/data` : "";
|
|
796
1290
|
const devNamedVolumes = [
|
|
797
|
-
" mongodb-dev-data:",
|
|
1291
|
+
postgres ? " postgres-dev-data:" : " mongodb-dev-data:",
|
|
798
1292
|
...(options.redis ? [" redis-dev-data:"] : [])
|
|
799
1293
|
];
|
|
800
1294
|
add(`${svc}/docker-compose.dev.yml`, `name: ${options.name}-dev
|
|
801
1295
|
services:
|
|
1296
|
+
schema-init:
|
|
1297
|
+
build:
|
|
1298
|
+
context: .
|
|
1299
|
+
dockerfile: Dockerfile.dev
|
|
1300
|
+
env_file:
|
|
1301
|
+
- .env.development
|
|
1302
|
+
command: ["npm", "run", "schema:apply:dev"]${databaseEnvironmentBlock}
|
|
1303
|
+
volumes:
|
|
1304
|
+
${devVolumes.join("\n")}
|
|
1305
|
+
depends_on:
|
|
1306
|
+
${postgres ? "postgres" : "mongo-init"}:
|
|
1307
|
+
condition: ${postgres ? "service_healthy" : "service_completed_successfully"}
|
|
802
1308
|
${options.name}:
|
|
803
1309
|
build:
|
|
804
1310
|
context: .
|
|
@@ -808,11 +1314,21 @@ services:
|
|
|
808
1314
|
ports:
|
|
809
1315
|
- "3000:3000"${databaseEnvironmentBlock}
|
|
810
1316
|
volumes:
|
|
811
|
-
${devVolumes.join("\n")}${
|
|
1317
|
+
${devVolumes.join("\n")}${devDependsBlock}${devWorker}${devDatabase}${devRedis}
|
|
812
1318
|
volumes:
|
|
813
1319
|
${devNamedVolumes.join("\n")}
|
|
814
1320
|
`);
|
|
815
1321
|
|
|
1322
|
+
for (const [relative, content] of Object.entries(ownedSources({ kind: "service", profiles: serviceProfiles }))) {
|
|
1323
|
+
add(`${svc}/${relative}`, content);
|
|
1324
|
+
}
|
|
1325
|
+
if (options.ui) {
|
|
1326
|
+
const uiProfiles = [...(options.auth === "firebase" ? ["firebase"] : []), "notifications"];
|
|
1327
|
+
for (const [relative, content] of Object.entries(ownedSources({ kind: "ui", profiles: uiProfiles }))) {
|
|
1328
|
+
add(`${ui}/${relative}`, content);
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
|
|
816
1332
|
for (const [relative, content] of Object.entries(files)) {
|
|
817
1333
|
const destination = path.join(root, relative);
|
|
818
1334
|
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
@@ -820,5 +1336,10 @@ for (const [relative, content] of Object.entries(files)) {
|
|
|
820
1336
|
if (relative.endsWith(".sh")) fs.chmodSync(destination, 0o755);
|
|
821
1337
|
}
|
|
822
1338
|
|
|
823
|
-
|
|
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
|
+
}
|
|
1343
|
+
|
|
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 };
|
|
824
1345
|
}
|