@zaaxch/tailframe 0.1.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/assets/validate-architecture.mjs +279 -0
- package/bin/tailframe.mjs +83 -0
- package/package.json +21 -0
- package/src/architecture.mjs +259 -0
- package/src/conventions.mjs +188 -0
- package/src/exceptions.mjs +36 -0
- package/src/generate.mjs +353 -0
- package/src/new.mjs +754 -0
- package/src/validate.mjs +15 -0
package/src/new.mjs
ADDED
|
@@ -0,0 +1,754 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { GenerateError } from "./generate.mjs";
|
|
4
|
+
|
|
5
|
+
function fail(message) {
|
|
6
|
+
throw new GenerateError(message);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function parseArgs(argv) {
|
|
10
|
+
const result = { db: "mongo", auth: "none", ui: false, redis: false, worker: false };
|
|
11
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
12
|
+
const arg = argv[i];
|
|
13
|
+
if (["--name", "--path", "--auth"].includes(arg)) result[arg.slice(2)] = argv[++i];
|
|
14
|
+
else if (["--ui", "--redis", "--worker"].includes(arg)) result[arg.slice(2)] = true;
|
|
15
|
+
else fail(`Unknown argument: ${arg}`);
|
|
16
|
+
}
|
|
17
|
+
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");
|
|
18
|
+
if (!result.path) fail("--path is required");
|
|
19
|
+
if (!["none", "firebase"].includes(result.auth)) fail("--auth must be none or firebase");
|
|
20
|
+
if (result.worker) result.redis = true;
|
|
21
|
+
return result;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createProject(argv) {
|
|
25
|
+
const options = parseArgs(argv);
|
|
26
|
+
const parent = path.resolve(options.path);
|
|
27
|
+
const root = path.join(parent, options.name);
|
|
28
|
+
if (!fs.existsSync(parent) || !fs.statSync(parent).isDirectory()) fail(`Parent directory does not exist: ${parent}`);
|
|
29
|
+
if (fs.existsSync(root) && fs.readdirSync(root).length) fail(`Refusing to overwrite non-empty directory: ${root}`);
|
|
30
|
+
|
|
31
|
+
const title = options.name.split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join(" ");
|
|
32
|
+
const svc = `${options.name}-svc`;
|
|
33
|
+
const ui = `${options.name}-ui`;
|
|
34
|
+
const files = {};
|
|
35
|
+
const add = (relative, content) => { files[relative] = content.endsWith("\n") ? content : `${content}\n`; };
|
|
36
|
+
const architectureValidatorSource = fs.readFileSync(new URL("../assets/validate-architecture.mjs", import.meta.url), "utf8");
|
|
37
|
+
const prettierConfig = {
|
|
38
|
+
useTabs: true,
|
|
39
|
+
tabWidth: 4,
|
|
40
|
+
printWidth: 120,
|
|
41
|
+
trailingComma: "none",
|
|
42
|
+
organizeImportsSkipDestructiveCodeActions: true,
|
|
43
|
+
endOfLine: "lf",
|
|
44
|
+
singleQuote: false
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
add(".prettierrc.json", JSON.stringify(prettierConfig, null, "\t"));
|
|
48
|
+
add(".gitattributes", `* text=auto eol=lf
|
|
49
|
+
|
|
50
|
+
*.png binary
|
|
51
|
+
*.jpg binary
|
|
52
|
+
*.jpeg binary
|
|
53
|
+
*.gif binary
|
|
54
|
+
*.ico binary
|
|
55
|
+
*.pdf binary
|
|
56
|
+
`);
|
|
57
|
+
|
|
58
|
+
const repos = [[svc, "Express/TypeScript API and background runtime."]];
|
|
59
|
+
if (options.ui) repos.push([ui, "Vue/Vite customer interface."]);
|
|
60
|
+
|
|
61
|
+
add("AGENTS.md", `# ${title} repository guidance
|
|
62
|
+
|
|
63
|
+
## Sources of truth
|
|
64
|
+
|
|
65
|
+
- Treat implementation and tests as the source of truth for current behavior.
|
|
66
|
+
- Product documentation may establish intent but is not proof of implemented behavior.
|
|
67
|
+
- Do not claim functionality works merely because a type, route, UI control, configuration key, placeholder, or planning entry exists.
|
|
68
|
+
|
|
69
|
+
## Product boundary
|
|
70
|
+
|
|
71
|
+
The product domain is not yet defined. Do not invent domain entities, workflows, claims, roles, or integrations. Add product-specific guidance only after an explicit decision or implementation establishes it.
|
|
72
|
+
|
|
73
|
+
## Repository map
|
|
74
|
+
|
|
75
|
+
This project root contains separate repositories. Do not assume that a convention from one repository applies to another.
|
|
76
|
+
|
|
77
|
+
${repos.map(([name, description]) => `- \`${name}/\` — ${description} Read \`${name}/AGENTS.md\` before editing it.`).join("\n")}
|
|
78
|
+
|
|
79
|
+
## Cross-repository changes
|
|
80
|
+
|
|
81
|
+
1. Read each applicable child \`AGENTS.md\`.
|
|
82
|
+
2. Inspect the nearest working implementation in each repository.
|
|
83
|
+
3. Keep shared contracts aligned across all affected clients and services.
|
|
84
|
+
4. Validate each repository with its own scripts.
|
|
85
|
+
5. Do not claim end-to-end behavior from changes or static checks in only one repository.
|
|
86
|
+
|
|
87
|
+
## Module workflow
|
|
88
|
+
|
|
89
|
+
- Use the local \`new-module\` skill whenever creating or extending a domain module, public operation, persisted entity, or client capability module.
|
|
90
|
+
- This applies when work affects one repository, multiple repositories, or one phase of a feature whose backend and clients will be implemented separately.
|
|
91
|
+
- Do not wait for a task to become cross-repository before using the skill.
|
|
92
|
+
- Create architecture files with \`tailframe generate\` in the affected repository; the skill orchestrates the work, the CLI creates the files.
|
|
93
|
+
|
|
94
|
+
## Code-change guidelines
|
|
95
|
+
|
|
96
|
+
- Keep changes small and focused and avoid unrelated refactoring.
|
|
97
|
+
- Modify existing structures when they fit the requirement.
|
|
98
|
+
- Preserve unrelated worktree changes.
|
|
99
|
+
- Confirm every committed change belongs to the requested scope.
|
|
100
|
+
|
|
101
|
+
## GitHub workflow
|
|
102
|
+
|
|
103
|
+
- Use \`gh\` for GitHub interactions when it supports the action.
|
|
104
|
+
- Inspect existing issues and pull requests before proposing duplicates.
|
|
105
|
+
- Never mutate GitHub state without explicit authorization.
|
|
106
|
+
- Verify external mutations with a read-only follow-up.
|
|
107
|
+
|
|
108
|
+
## GitHub authentication and sandboxing
|
|
109
|
+
|
|
110
|
+
- All \`gh\`, AWS/ECR, and Docker commands must run outside the sandbox. Request approval before the first attempt; do not try them inside the sandbox first.
|
|
111
|
+
- Docker commands include \`docker\`, \`docker compose\`, \`docker exec\`, and package scripts whose purpose is to invoke Docker.
|
|
112
|
+
- AWS/ECR commands include \`aws ecr\` authentication and package scripts that build or push ECR images.
|
|
113
|
+
- Never print, log, commit, or include GitHub tokens in command output.
|
|
114
|
+
- Keep mutation approvals separate from read-only GitHub access.
|
|
115
|
+
|
|
116
|
+
## Starting work from a GitHub issue
|
|
117
|
+
|
|
118
|
+
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.
|
|
119
|
+
`);
|
|
120
|
+
|
|
121
|
+
add(".agents/skills/new-module/SKILL.md", `---
|
|
122
|
+
name: new-module
|
|
123
|
+
description: Create or extend a domain-colocated module in one repository or across the optional service and UI repositories using explicit use cases, separate trusted request context, module-owned repository ports and persistence adapters, HTTP validation, and focused tests. Use for backend capabilities, HTTP operations, persisted entities, queries, commands, jobs, workers, frontend capability modules, or phased work where a client is implemented later.
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
# New module
|
|
127
|
+
|
|
128
|
+
Use this workflow for any new domain capability or operation. Add only the layers earned by actual behavior.
|
|
129
|
+
|
|
130
|
+
1. Identify the module, requested operations, entry points, and repositories in scope. Classify every intended file with the applicable AGENTS.md placement table before writing. Do not assume full CRUD.
|
|
131
|
+
2. Read the root and every applicable child \`AGENTS.md\`.
|
|
132
|
+
3. Inspect the nearest working module and tests.
|
|
133
|
+
4. Create architecture files only with the tailframe CLI: \`tailframe generate\` creates service modules, use cases, http files, ports, adapters, identifiers, and integrations, and UI modules, api clients, views, components, stores, and composables on canonical paths with canonical names. Hand-creating architecture files is a conformance violation; complete the printed wiring checklist after each generation.
|
|
134
|
+
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. Theme state, its toggle, tests, the router, and the application shell always belong under UI \`src/app\`; never create \`src/modules/theme\`. 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.
|
|
135
|
+
6. Define use cases as \`execute(context, input)\`. Keep trusted \`RequestContext\` separate from client input.
|
|
136
|
+
7. Put ownership, RBAC, entitlements, and domain policies in use-case or domain code, not client bodies or route metadata.
|
|
137
|
+
8. Define repository contracts in the owning module and implement them in persistence adapters.
|
|
138
|
+
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.
|
|
139
|
+
10. Let HTTP routes, jobs, workers, scheduled tasks, and CLIs invoke use cases directly; do not call one entry point from another.
|
|
140
|
+
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 and must keep the module dependency graph acyclic. UI modules never import one another; compose them in UI app.
|
|
141
|
+
12. Add required container, route, client, navigation, worker, or process registration and focused tests.
|
|
142
|
+
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.
|
|
143
|
+
|
|
144
|
+
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.
|
|
145
|
+
`);
|
|
146
|
+
|
|
147
|
+
const svcDeps = {
|
|
148
|
+
"@dotenvx/dotenvx": "^1.39.1",
|
|
149
|
+
"body-parser": "^2.2.0",
|
|
150
|
+
cors: "^2.8.5",
|
|
151
|
+
express: "^4.21.2",
|
|
152
|
+
joi: "^17.13.3",
|
|
153
|
+
"reflect-metadata": "^0.2.2",
|
|
154
|
+
tsyringe: "^4.9.1",
|
|
155
|
+
mongodb: "^6.17.0",
|
|
156
|
+
...(options.auth === "firebase" ? { "firebase-admin": "^13.0.0" } : {}),
|
|
157
|
+
...(options.redis ? { redis: "^5.10.0" } : {})
|
|
158
|
+
};
|
|
159
|
+
const svcDevDeps = {
|
|
160
|
+
"@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",
|
|
161
|
+
jest: "^30.0.0", nodemon: "^3.1.9", prettier: "3.5.3", "ts-jest": "^29.4.11", "ts-node": "^10.9.2",
|
|
162
|
+
"tsc-alias": "^1.8.16", "tsconfig-paths": "^4.2.0", typescript: "^5.8.2", supertest: "^7.2.2"
|
|
163
|
+
};
|
|
164
|
+
add(`${svc}/package.json`, JSON.stringify({
|
|
165
|
+
name: svc, version: "0.1.0", private: true, main: "dist/server.js",
|
|
166
|
+
engines: { node: ">=22.13.0" },
|
|
167
|
+
scripts: {
|
|
168
|
+
dev: "nodemon --legacy-watch --exec ts-node -r tsconfig-paths/register src/server.ts",
|
|
169
|
+
...(options.worker ? { "dev:worker": "nodemon --legacy-watch --exec ts-node -r tsconfig-paths/register src/worker.ts" } : {}),
|
|
170
|
+
build: "tsc && tsc-alias", start: "node dist/server.js",
|
|
171
|
+
...(options.worker ? { worker: "node dist/worker.js" } : {}),
|
|
172
|
+
test: "npm run test:unit && npm run test:integration", "test:unit": "jest --selectProjects unit --runInBand", "test:integration": "jest --selectProjects integration --runInBand", "validate:architecture": "node scripts/validate-architecture.mjs --kind service .", format: "prettier --write src/",
|
|
173
|
+
"test:db:up": "docker compose -f docker-compose.test.yml up -d", "test:db:down": "docker compose -f docker-compose.test.yml down -v",
|
|
174
|
+
"docker:dev": "docker compose -f docker-compose.dev.yml up",
|
|
175
|
+
"docker:prod": "docker compose -f docker-compose.yml up -d",
|
|
176
|
+
"docker:push": "bash scripts/build_and_push.sh"
|
|
177
|
+
}, dependencies: svcDeps, devDependencies: svcDevDeps
|
|
178
|
+
}, null, "\t"));
|
|
179
|
+
add(`${svc}/scripts/validate-architecture.mjs`, architectureValidatorSource);
|
|
180
|
+
add(`${svc}/tsconfig.json`, JSON.stringify({
|
|
181
|
+
compilerOptions: { target: "ES2022", module: "commonjs", rootDir: "src", outDir: "dist", strict: true, esModuleInterop: true, experimentalDecorators: true, emitDecoratorMetadata: true, baseUrl: ".", paths: { "@/*": ["src/*"] }, skipLibCheck: true },
|
|
182
|
+
include: ["src/**/*.ts"]
|
|
183
|
+
}, null, "\t"));
|
|
184
|
+
add(`${svc}/jest.config.js`, `const base = { preset: "ts-jest", testEnvironment: "node", setupFiles: ["reflect-metadata"], roots: ["<rootDir>/src"], moduleNameMapper: { "^@/(.*)$": "<rootDir>/src/$1" } };
|
|
185
|
+
module.exports = { projects: [
|
|
186
|
+
{ ...base, displayName: "unit", testMatch: ["**/*.test.ts"], testPathIgnorePatterns: ["/node_modules/", "\\\\.integration\\\\.test\\\\.ts$"] },
|
|
187
|
+
{ ...base, displayName: "integration", testMatch: ["**/*.integration.test.ts"] }
|
|
188
|
+
] };`);
|
|
189
|
+
add(`${svc}/.prettierrc.json`, JSON.stringify(prettierConfig, null, "\t"));
|
|
190
|
+
add(`${svc}/.gitignore`, `node_modules/\ndist/\n.env*\n!.env.example\n*.log\nfirebase-service-account*.json\n`);
|
|
191
|
+
add(`${svc}/.dockerignore`, `node_modules\ndist\n.git\n.env*\n!.env.example\n*.log\ndata\nfirebase-service-account*.json\n`);
|
|
192
|
+
if (options.ui) add(".dockerignore", `**/node_modules\n**/dist\n**/.git\n**/.env*\n**/*.log\n**/data\n**/firebase-service-account*.json\n`);
|
|
193
|
+
const dbEnv = "MONGODB_URI=mongodb://localhost:27017/?replicaSet=rs0&directConnection=true\nMONGODB_DB_NAME=" + options.name.replaceAll("-", "_");
|
|
194
|
+
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" : ""}`);
|
|
195
|
+
add(`${svc}/src/platform/config/env.ts`, `import "@dotenvx/dotenvx/config";
|
|
196
|
+
export const env = {
|
|
197
|
+
nodeEnv: process.env.NODE_ENV ?? "development",
|
|
198
|
+
port: Number(process.env.PORT ?? 3000),
|
|
199
|
+
corsOrigin: process.env.CORS_ORIGIN ?? "https://localhost:5173",
|
|
200
|
+
${options.auth === "firebase" ? `firebaseProjectId: process.env.FIREBASE_PROJECT_ID ?? "",` : ""}
|
|
201
|
+
mongodbUri: process.env.MONGODB_URI ?? "mongodb://localhost:27017/?replicaSet=rs0&directConnection=true",
|
|
202
|
+
mongodbDbName: process.env.MONGODB_DB_NAME ?? "${options.name.replaceAll("-", "_")}",
|
|
203
|
+
${options.redis ? `redisUrl: process.env.REDIS_URL ?? "redis://localhost:6379",` : ""}
|
|
204
|
+
};`);
|
|
205
|
+
const databaseSource = `import { MongoClient } from "mongodb";
|
|
206
|
+
import { env } from "@/platform/config/env";
|
|
207
|
+
export const mongo = new MongoClient(env.mongodbUri);
|
|
208
|
+
export async function connectDatabase() { await mongo.connect(); return mongo.db(env.mongodbDbName); }
|
|
209
|
+
export async function closeDatabase() { await mongo.close(); }`;
|
|
210
|
+
add(`${svc}/src/platform/database/index.ts`, databaseSource);
|
|
211
|
+
if (options.redis) add(`${svc}/src/platform/redis/index.ts`, `import { createClient, type RedisClientType } from "redis";
|
|
212
|
+
import { env } from "@/platform/config/env";
|
|
213
|
+
export const redis: RedisClientType = createClient({ url: env.redisUrl });
|
|
214
|
+
redis.on("error", (error) => console.error("Redis client error", error));
|
|
215
|
+
export async function connectRedis() { if (!redis.isOpen) await redis.connect(); return redis; }
|
|
216
|
+
export async function closeRedis() { if (redis.isOpen) await redis.quit(); }
|
|
217
|
+
`);
|
|
218
|
+
add(`${svc}/src/core/RequestContext.ts`, `export interface AuthenticatedPrincipal {\n\tuid: string;\n\temail?: string;\n}\nexport interface RequestContext {\n\trequestId: string;\n\tprincipal?: AuthenticatedPrincipal;\n\troles: string[];\n}\n`);
|
|
219
|
+
add(`${svc}/src/core/UseCase.ts`, `import type { RequestContext } from "@/core/RequestContext";\nexport interface UseCase<Input, Output> {\n\texecute(context: RequestContext, input: Input): Promise<Output>;\n}\n`);
|
|
220
|
+
add(`${svc}/src/platform/http/createRequestContext.ts`, options.auth === "firebase" ? `import { randomUUID } from "node:crypto";
|
|
221
|
+
import type { Request } from "express";
|
|
222
|
+
import type { RequestContext } from "@/core/RequestContext";
|
|
223
|
+
import { verifyFirebaseToken } from "@/platform/auth/firebase";
|
|
224
|
+
export async function createRequestContext(req: Request): Promise<RequestContext> {
|
|
225
|
+
const requestId = String(req.headers["x-request-id"] ?? randomUUID());
|
|
226
|
+
const decoded = req.headers.authorization?.startsWith("Bearer ") ? await verifyFirebaseToken(req.headers.authorization.slice(7)) : undefined;
|
|
227
|
+
return { requestId, principal: decoded ? { uid: decoded.uid, email: decoded.email } : undefined, roles: [] };
|
|
228
|
+
}
|
|
229
|
+
export function systemRequestContext(requestId = "system"): RequestContext { return { requestId, roles: [] }; }
|
|
230
|
+
` : `import { randomUUID } from "node:crypto";
|
|
231
|
+
import type { Request } from "express";
|
|
232
|
+
import type { RequestContext } from "@/core/RequestContext";
|
|
233
|
+
export function createRequestContext(req: Request): RequestContext {
|
|
234
|
+
return { requestId: String(req.headers["x-request-id"] ?? randomUUID()), roles: [] };
|
|
235
|
+
}
|
|
236
|
+
export function systemRequestContext(requestId = "system"): RequestContext { return { requestId, roles: [] }; }
|
|
237
|
+
`);
|
|
238
|
+
if (options.auth === "firebase") add(`${svc}/src/platform/auth/firebase.ts`, `import { applicationDefault, getApps, initializeApp } from "firebase-admin/app";
|
|
239
|
+
import { getAuth, type DecodedIdToken } from "firebase-admin/auth";
|
|
240
|
+
import { env } from "@/platform/config/env";
|
|
241
|
+
export function verifyFirebaseToken(token: string): Promise<DecodedIdToken> {
|
|
242
|
+
if (!getApps().length) initializeApp({ credential: applicationDefault(), projectId: env.firebaseProjectId || undefined });
|
|
243
|
+
return getAuth().verifyIdToken(token);
|
|
244
|
+
}`);
|
|
245
|
+
add(`${svc}/src/platform/http/rpc.ts`, `import type { Response } from "express";\nexport function rpcResult(res: Response, result: unknown) { return res.json({ result }); }\n`);
|
|
246
|
+
add(`${svc}/src/platform/http/errors.ts`, `import type { ErrorRequestHandler } from "express";
|
|
247
|
+
export const errorHandler: ErrorRequestHandler = (error, _req, res, _next) => {
|
|
248
|
+
if (error?.isJoi) { res.status(400).json({ error: { message: error.message } }); return; }
|
|
249
|
+
const status = Number(error?.status ?? 500);
|
|
250
|
+
res.status(status).json({ error: { message: status === 500 ? "Internal server error" : error.message } });
|
|
251
|
+
};
|
|
252
|
+
`);
|
|
253
|
+
add(`${svc}/src/modules/health/use-cases/GetHealth.ts`, `import type { UseCase } from "@/core/UseCase";\nimport type { RequestContext } from "@/core/RequestContext";\nexport type HealthResult = { status: "ok" };\nexport class GetHealth implements UseCase<Record<string, never>, HealthResult> {\n\tasync execute(_context: RequestContext, _input: Record<string, never>): Promise<HealthResult> { return { status: "ok" }; }\n}\n`);
|
|
254
|
+
add(`${svc}/src/modules/health/http/health.schemas.ts`, `import Joi from "joi";\nexport const GetHealthSchema = Joi.object({}).unknown(false);\n`);
|
|
255
|
+
add(`${svc}/src/modules/health/http/health.routes.ts`, `import { Router } from "express";\nimport type { GetHealth } from "@/modules/health/use-cases/GetHealth";\nimport { GetHealthSchema } from "@/modules/health/http/health.schemas";\nimport { createRequestContext } from "@/platform/http/createRequestContext";\nimport { rpcResult } from "@/platform/http/rpc";\nexport function healthRoutes(getHealth: GetHealth) {\n\tconst router = Router();\n\trouter.post("/health.get", async (req, res, next) => { try { const input = await GetHealthSchema.validateAsync(req.body ?? {}); rpcResult(res, await getHealth.execute(await createRequestContext(req), input)); } catch (error) { next(error); } });\n\treturn router;\n}\n`);
|
|
256
|
+
add(`${svc}/src/app/container.ts`, `import { container } from "tsyringe";\nimport { GetHealth } from "@/modules/health/use-cases/GetHealth";\ncontainer.registerSingleton(GetHealth, GetHealth);\nexport { container };\n`);
|
|
257
|
+
add(`${svc}/src/app/routes.ts`, `import { Router } from "express";\nimport { container } from "@/app/container";\nimport { GetHealth } from "@/modules/health/use-cases/GetHealth";\nimport { healthRoutes } from "@/modules/health/http/health.routes";\nexport function applicationRoutes() { const router = Router(); router.use(healthRoutes(container.resolve(GetHealth))); return router; }\n`);
|
|
258
|
+
add(`${svc}/src/app/server.ts`, `import path from "node:path";
|
|
259
|
+
import express from "express";
|
|
260
|
+
import cors from "cors";
|
|
261
|
+
import { applicationRoutes } from "@/app/routes";
|
|
262
|
+
import { env } from "@/platform/config/env";
|
|
263
|
+
import { connectDatabase, closeDatabase } from "@/platform/database";
|
|
264
|
+
import { errorHandler } from "@/platform/http/errors";
|
|
265
|
+
export interface ApplicationOptions { publicPath?: string; production?: boolean; }
|
|
266
|
+
export function createApplication(options: ApplicationOptions = {}) {
|
|
267
|
+
const app = express();
|
|
268
|
+
${options.ui ? `const publicPath = options.publicPath ?? path.join(__dirname, "..", "public");
|
|
269
|
+
const production = options.production ?? env.nodeEnv === "production";
|
|
270
|
+
if (production) app.use(express.static(publicPath, { index: false, maxAge: "1y", immutable: true }));` : ""}
|
|
271
|
+
app.use(cors({ origin: env.corsOrigin }));
|
|
272
|
+
app.use(express.json());
|
|
273
|
+
app.use("/api/v1", applicationRoutes());
|
|
274
|
+
${options.ui ? `if (production) {
|
|
275
|
+
app.get(/^(?!\\/api(?:\\/|$)).*/, (_req, res) => res.sendFile(path.join(publicPath, "index.html")));
|
|
276
|
+
}` : ""}
|
|
277
|
+
app.use(errorHandler);
|
|
278
|
+
return app;
|
|
279
|
+
}
|
|
280
|
+
export async function startServer() {
|
|
281
|
+
await connectDatabase();
|
|
282
|
+
const app = createApplication();
|
|
283
|
+
const server = app.listen(env.port);
|
|
284
|
+
const shutdown = async () => { server.close(); await closeDatabase(); };
|
|
285
|
+
process.once("SIGINT", shutdown);
|
|
286
|
+
process.once("SIGTERM", shutdown);
|
|
287
|
+
return { app, server, shutdown };
|
|
288
|
+
}
|
|
289
|
+
`);
|
|
290
|
+
add(`${svc}/src/server.ts`, `import "reflect-metadata";\nimport { startServer } from "@/app/server";\nvoid startServer();\n`);
|
|
291
|
+
if (options.worker) add(`${svc}/src/app/workers/startWorker.ts`, `import { connectDatabase, closeDatabase } from "@/platform/database";
|
|
292
|
+
import { connectRedis, closeRedis } from "@/platform/redis";
|
|
293
|
+
import { systemRequestContext } from "@/platform/http/createRequestContext";
|
|
294
|
+
import { GetHealth } from "@/modules/health/use-cases/GetHealth";
|
|
295
|
+
// Workers invoke use cases directly. Add real scheduled work only with an explicit domain workflow.
|
|
296
|
+
export async function startWorker() {
|
|
297
|
+
await connectDatabase();
|
|
298
|
+
await connectRedis();
|
|
299
|
+
console.log("${title} worker runtime ready", await new GetHealth().execute(systemRequestContext(), {}));
|
|
300
|
+
const shutdown = async () => { await closeDatabase(); await closeRedis(); process.exit(0); };
|
|
301
|
+
process.once("SIGINT", shutdown);
|
|
302
|
+
process.once("SIGTERM", shutdown);
|
|
303
|
+
}
|
|
304
|
+
`);
|
|
305
|
+
if (options.worker) add(`${svc}/src/worker.ts`, `import "reflect-metadata";\nimport { startWorker } from "@/app/workers/startWorker";\nvoid startWorker();\n`);
|
|
306
|
+
add(`${svc}/src/modules/health/__tests__/GetHealth.test.ts`, `import { GetHealth } from "@/modules/health/use-cases/GetHealth";\ndescribe("GetHealth", () => { it("returns readiness through the use-case contract", async () => { await expect(new GetHealth().execute({ requestId: "test", roles: [] }, {})).resolves.toEqual({ status: "ok" }); }); });\n`);
|
|
307
|
+
add(`${svc}/src/__tests__/testDb.ts`, `import { MongoClient } from "mongodb";
|
|
308
|
+
const client = new MongoClient(process.env.TEST_MONGODB_URI ?? "mongodb://localhost:27018/?replicaSet=rs0&directConnection=true");
|
|
309
|
+
export async function openTestDatabase() { await client.connect(); return client.db(process.env.TEST_MONGODB_DB_NAME ?? "${options.name.replaceAll("-", "_")}_test"); }
|
|
310
|
+
export async function resetTestDatabase() { const database = await openTestDatabase(); await database.dropDatabase(); return database; }
|
|
311
|
+
export async function closeTestDatabase() { await client.close(); }
|
|
312
|
+
`);
|
|
313
|
+
add(`${svc}/docker-compose.test.yml`, `name: ${options.name}-test
|
|
314
|
+
services:
|
|
315
|
+
mongo:
|
|
316
|
+
image: mongo:8
|
|
317
|
+
command: mongod --replSet rs0 --bind_ip_all
|
|
318
|
+
ports:
|
|
319
|
+
- "27018:27017"
|
|
320
|
+
tmpfs:
|
|
321
|
+
- /data/db
|
|
322
|
+
healthcheck:
|
|
323
|
+
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
|
|
324
|
+
interval: 2s
|
|
325
|
+
timeout: 2s
|
|
326
|
+
retries: 30
|
|
327
|
+
mongo-init:
|
|
328
|
+
image: mongo:8
|
|
329
|
+
depends_on:
|
|
330
|
+
mongo:
|
|
331
|
+
condition: service_healthy
|
|
332
|
+
restart: "no"
|
|
333
|
+
entrypoint:
|
|
334
|
+
- bash
|
|
335
|
+
- -c
|
|
336
|
+
- |
|
|
337
|
+
mongosh --host mongo --quiet --eval '
|
|
338
|
+
try {
|
|
339
|
+
rs.status().ok
|
|
340
|
+
} catch (error) {
|
|
341
|
+
rs.initiate({ _id: "rs0", members: [{ _id: 0, host: "localhost:27017" }] })
|
|
342
|
+
}
|
|
343
|
+
'
|
|
344
|
+
`);
|
|
345
|
+
add(`${svc}/src/app/__tests__/boundary.integration.test.ts`, `import request from "supertest";
|
|
346
|
+
${options.ui ? 'import path from "node:path";\n' : ""}
|
|
347
|
+
import { createApplication } from "@/app/server";
|
|
348
|
+
import { closeTestDatabase, resetTestDatabase } from "@/__tests__/testDb";
|
|
349
|
+
describe("HTTP boundary", () => {
|
|
350
|
+
beforeAll(async () => { await resetTestDatabase(); });
|
|
351
|
+
afterAll(async () => { await closeTestDatabase(); });
|
|
352
|
+
it("returns a successful RPC envelope", async () => {
|
|
353
|
+
await request(createApplication()).post("/api/v1/health.get").send({}).expect(200).expect(({ body }) => {
|
|
354
|
+
expect(body).toEqual({ result: { status: "ok" } });
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
it("returns a validation error envelope", async () => {
|
|
358
|
+
await request(createApplication()).post("/api/v1/health.get").send({ unexpected: true }).expect(400).expect(({ body }) => {
|
|
359
|
+
expect(body.error.message).toEqual(expect.any(String));
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
${options.ui ? ` it("serves the production SPA without falling through for API paths", async () => {
|
|
363
|
+
const app = createApplication({ production: true, publicPath: path.join(__dirname, "fixtures/public") });
|
|
364
|
+
await request(app).get("/dashboard").expect(200).expect(({ text }) => expect(text).toContain("boundary-test-spa"));
|
|
365
|
+
await request(app).get("/api/v1/does-not-exist").expect(404);
|
|
366
|
+
});
|
|
367
|
+
` : ""}
|
|
368
|
+
});
|
|
369
|
+
`);
|
|
370
|
+
if (options.ui) add(`${svc}/src/app/__tests__/fixtures/public/index.html`, `<!doctype html><html><body><div id="app">boundary-test-spa</div></body></html>`);
|
|
371
|
+
add(`${svc}/AGENTS.md`, `# ${title} service guidance
|
|
372
|
+
|
|
373
|
+
## Scope
|
|
374
|
+
These conventions apply to \`${svc}\`. This service is a modular monolith organized around domain capabilities, not global technical layers.
|
|
375
|
+
|
|
376
|
+
## Architecture vocabulary
|
|
377
|
+
- Use \`src/app\` for process assembly, server creation, route mounting, and dependency registration.
|
|
378
|
+
- Use \`src/core\` only for small technology-neutral contracts such as \`RequestContext\` and \`UseCase\`.
|
|
379
|
+
- Use \`src/platform\` for technical mechanisms such as Express, authentication, configuration, databases, Redis, and shared HTTP behavior.
|
|
380
|
+
- Use \`src/modules/<module>\` for product capabilities.
|
|
381
|
+
- These are the only service architecture roots. Keep \`src/core\` flat. Put external-system adapters under \`src/platform/integrations/<provider>\`; directories immediately under \`src/app\` are limited to \`cli\`, \`jobs\`, \`mcp\`, \`scheduled-tasks\`, \`workers\`, and \`__tests__\`.
|
|
382
|
+
- Inside a module, use \`use-cases/\`, \`http/\`, and tests first. Add \`domain/\` or \`persistence/\` only when behavior requires them.
|
|
383
|
+
- Core contracts must not import Express, Firebase, database, Redis, worker, or UI code.
|
|
384
|
+
- Domain and use-case code must not import framework or vendor packages.
|
|
385
|
+
|
|
386
|
+
## Change placement
|
|
387
|
+
| Change | Canonical owner |
|
|
388
|
+
| --- | --- |
|
|
389
|
+
| Product rule, query, command, or policy | \`src/modules/<module>/use-cases\` or \`domain\` |
|
|
390
|
+
| Repository port | Owning module's \`use-cases/ports\` |
|
|
391
|
+
| MongoDB implementation | Owning module's \`persistence\` |
|
|
392
|
+
| Express route or request schema | Owning module's \`http\` |
|
|
393
|
+
| Vendor or external-system adapter | \`src/platform/integrations/<provider>\` |
|
|
394
|
+
| Database, Redis, authentication, or HTTP mechanism | \`src/platform\` |
|
|
395
|
+
| Process lifecycle, registration, schedule, or consumer | \`src/app/<entry-point-kind>\` |
|
|
396
|
+
| Cross-entry-point technology-neutral contract | Flat \`src/core\` |
|
|
397
|
+
| Root executable | Bootstrap of matching \`src/app\` assembly only |
|
|
398
|
+
|
|
399
|
+
Product vocabulary stays in the module that owns its meaning; never move it into \`core\`. A module may import another only through a named file directly under the provider's \`use-cases/\`; its domain, HTTP, persistence, ports, and tests remain private, and cross-module use-case dependencies must be acyclic.
|
|
400
|
+
|
|
401
|
+
## Public API contract
|
|
402
|
+
- Use singular RPC-shaped \`POST /api/v1/<module>.<operation>\` operations and the \`{ result: ... }\` success envelope.
|
|
403
|
+
- Implement only requested operations; do not create speculative CRUD.
|
|
404
|
+
|
|
405
|
+
## Module ownership
|
|
406
|
+
- Create architecture files only with \`tailframe generate\`; hand-created architecture files are a conformance violation.
|
|
407
|
+
- Colocate capability code under \`src/modules/<module>\`.
|
|
408
|
+
- Start with \`use-cases/\`, required \`http/\` translation, and tests. Add \`domain/\` or \`persistence/\` only when real behavior earns them.
|
|
409
|
+
- Use cases expose \`execute(context, input)\` and remain callable from HTTP, jobs, workers, scheduled tasks, or CLIs.
|
|
410
|
+
- Do not require controllers that only validate and delegate.
|
|
411
|
+
|
|
412
|
+
## Trusted request context
|
|
413
|
+
- Keep trusted \`RequestContext\` separate from client input.
|
|
414
|
+
- Entry points construct context; HTTP schemas validate only client input.${options.auth === "firebase" ? " Protected entry points verify Firebase tokens before constructing authenticated context." : " This scaffold is anonymous; add authentication only when the product requires it."}
|
|
415
|
+
- Put ownership, RBAC, entitlements, and domain policies in use-case or domain code.
|
|
416
|
+
- Do not require RBAC when ownership, public/private visibility, or another smaller policy fits.
|
|
417
|
+
|
|
418
|
+
## Persistence ownership
|
|
419
|
+
- Define repository contracts under the owning module's \`use-cases/ports\`.
|
|
420
|
+
- Implement database-specific adapters under that module's \`persistence/\` directory.
|
|
421
|
+
- 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.
|
|
422
|
+
- Repository ports and use cases MUST NOT expose database identifier types. Persistence adapters SHOULD define persistence-only document types and MUST NOT use \`any\` to bypass the domain/persistence distinction. MongoDB \`ObjectId\` conversion belongs inside the MongoDB persistence adapter.
|
|
423
|
+
- Add appropriate schema or validation, indexes, initialization or migrations, and focused tests.
|
|
424
|
+
|
|
425
|
+
## Project-specific persistence
|
|
426
|
+
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.
|
|
427
|
+
|
|
428
|
+
## Queries and commands
|
|
429
|
+
- Commands enforce invariants and change state.
|
|
430
|
+
- Queries may return efficient client-facing projections without forcing reads through rich aggregates.
|
|
431
|
+
|
|
432
|
+
## Entry points and integrations
|
|
433
|
+
- HTTP routes, jobs, workers, scheduled tasks, and CLIs call use cases directly; they do not call each other.
|
|
434
|
+
- Keep external-system clients under \`src/platform/integrations/<provider>\` and adapt them through module-owned ports.
|
|
435
|
+
- 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.
|
|
436
|
+
- Register dependencies, routes, workers, and jobs explicitly.
|
|
437
|
+
${options.ui ? "- In production, serve the compiled UI from `dist/public` with a non-API SPA fallback. Never return the SPA for `/api` requests." : ""}
|
|
438
|
+
|
|
439
|
+
## Tests and validation
|
|
440
|
+
Run \`npm run validate:architecture\` after changing files or imports. The generated service also includes unit tests and Mongo-backed integration tests. Run \`npm run test:db:up\`, \`npm test\`, and \`npm run test:db:down\` for the full service check. Test use cases, policies, Mongo repository adapters, and HTTP boundaries where behavior lives. Every public operation needs success and error-envelope coverage; authenticated operations need trusted-identity coverage; persisted capabilities need Mongo ownership/query coverage; UI-enabled services must verify that \`/api\` paths never fall through to the SPA. All AWS/ECR and Docker commands, including \`npm run docker:push\`, must run outside the sandbox from the first attempt.
|
|
441
|
+
`);
|
|
442
|
+
|
|
443
|
+
if (options.ui) {
|
|
444
|
+
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" } : {}) };
|
|
445
|
+
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" };
|
|
446
|
+
add(`${ui}/package.json`, JSON.stringify({ name: ui, version: "0.1.0", private: true, type: "module", scripts: { dev: "vite --host 0.0.0.0", build: "run-p type-check \"build-only {@}\" --", "build-only": "vite build", "type-check": "vue-tsc --build", test: "vitest", "validate:architecture": "node scripts/validate-architecture.mjs --kind ui .", format: "prettier --write src/" }, dependencies: uiDeps, devDependencies: uiDevDeps }, null, "\t"));
|
|
447
|
+
add(`${ui}/scripts/validate-architecture.mjs`, architectureValidatorSource);
|
|
448
|
+
add(`${ui}/.prettierrc.json`, JSON.stringify(prettierConfig, null, "\t"));
|
|
449
|
+
add(`${ui}/tsconfig.json`, JSON.stringify({ files: [], references: [{ path: "./tsconfig.app.json" }, { path: "./tsconfig.node.json" }] }, null, "\t"));
|
|
450
|
+
add(`${ui}/tsconfig.app.json`, JSON.stringify({ extends: "@vue/tsconfig/tsconfig.dom.json", include: ["env.d.ts", "src/**/*", "src/**/*.vue"], compilerOptions: { composite: true, baseUrl: ".", paths: { "@/*": ["./src/*"] } } }, null, "\t"));
|
|
451
|
+
add(`${ui}/tsconfig.node.json`, JSON.stringify({ extends: "@tsconfig/node22/tsconfig.json", include: ["vite.config.*"], compilerOptions: { composite: true, types: ["node"] } }, null, "\t"));
|
|
452
|
+
add(`${ui}/vite.config.ts`, `import { fileURLToPath, URL } from "node:url";\nimport { defineConfig } from "vitest/config";\nimport vue from "@vitejs/plugin-vue";\nimport tailwindcss from "@tailwindcss/vite";\nexport default defineConfig({ plugins: [vue(), tailwindcss()], resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) } }, server: { proxy: { "/api": { target: "http://localhost:3000", changeOrigin: true } } }, test: { environment: "jsdom", globals: true } });`);
|
|
453
|
+
add(`${ui}/env.d.ts`, `/// <reference types="vite/client" />`);
|
|
454
|
+
add(`${ui}/index.html`, `<!doctype html>
|
|
455
|
+
<html lang="en">
|
|
456
|
+
<head>
|
|
457
|
+
<meta charset="UTF-8" />
|
|
458
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
459
|
+
<title>${title}</title>
|
|
460
|
+
</head>
|
|
461
|
+
<body>
|
|
462
|
+
<div id="app"></div>
|
|
463
|
+
<script type="module" src="/src/main.ts"></script>
|
|
464
|
+
</body>
|
|
465
|
+
</html>
|
|
466
|
+
`);
|
|
467
|
+
add(`${ui}/.gitignore`, `node_modules/\ndist/\n.env*\n!.env.example\ncerts/\n*.log\n`);
|
|
468
|
+
if (options.auth === "firebase") add(`${ui}/.env.example`, `VITE_FIREBASE_API_KEY=\nVITE_FIREBASE_AUTH_DOMAIN=\nVITE_FIREBASE_PROJECT_ID=\nVITE_FIREBASE_APP_ID=\n`);
|
|
469
|
+
add(`${ui}/src/core/rpc.ts`, `export interface RpcResponse<T> { result: T; }\nexport interface RpcError { error: { message: string; code?: string }; }`);
|
|
470
|
+
if (options.auth === "firebase") {
|
|
471
|
+
add(`${ui}/src/platform/firebase.ts`, `import { initializeApp } from "firebase/app";\nexport const firebaseApp = initializeApp({ apiKey: import.meta.env.VITE_FIREBASE_API_KEY, authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN, projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID, appId: import.meta.env.VITE_FIREBASE_APP_ID });`);
|
|
472
|
+
add(`${ui}/src/app/stores/auth.ts`, `import { defineStore } from "pinia";\nimport { getAuth, onAuthStateChanged, signOut, type User } from "firebase/auth";\nimport { ref } from "vue";\nimport { firebaseApp } from "@/platform/firebase";\nexport const useAuthStore = defineStore("auth", () => { const firebaseUser = ref<User|null>(null); const ready = ref(false); const auth = getAuth(firebaseApp); onAuthStateChanged(auth, user => { firebaseUser.value = user; ready.value = true; }); const getIdToken = () => firebaseUser.value?.getIdToken(); const logout = () => signOut(auth); return { firebaseUser, ready, getIdToken, logout }; });`);
|
|
473
|
+
}
|
|
474
|
+
add(`${ui}/src/app/router.ts`, `import { createRouter, createWebHistory } from "vue-router";\nexport enum RouteNames { HOME = "Home" }\nexport default createRouter({ history: createWebHistory(), routes: [{ path: "/", name: RouteNames.HOME, component: () => import("@/app/views/HomeView.vue") }] });`);
|
|
475
|
+
add(`${ui}/src/platform/http.ts`, `import axios from "axios";\nexport const http = axios.create({ baseURL: \`\${window.location.origin}/api/v1\`, headers: { "Content-Type": "application/json" } });`);
|
|
476
|
+
if (options.auth === "firebase") add(`${ui}/src/app/configureHttp.ts`, `import type { Pinia } from "pinia";\nimport router, { RouteNames } from "@/app/router";\nimport { useAuthStore } from "@/app/stores/auth";\nimport { http } from "@/platform/http";\nexport function configureHttp(pinia: Pinia) {\n\tconst auth = useAuthStore(pinia);\n\thttp.interceptors.request.use(async config => { const token = await auth.getIdToken(); if (token) config.headers.Authorization = \`Bearer \${token}\`; return config; });\n\thttp.interceptors.response.use(response => response, async error => { if (error?.response?.status === 401) { await auth.logout(); await router.push({ name: RouteNames.HOME }); } return Promise.reject(error); });\n}\n`);
|
|
477
|
+
add(`${ui}/src/modules/health/api/health.api.ts`, `import { http } from "@/platform/http";\nimport type { RpcResponse } from "@/core/rpc";\nexport async function getHealth() { return (await http.post<RpcResponse<{ status: string }>>("health.get")).data.result; }`);
|
|
478
|
+
add(`${ui}/src/app/views/HomeView.vue`, `<template><main class="mx-auto flex min-h-screen max-w-5xl flex-col justify-center gap-4 px-6 py-16"><p class="font-bold uppercase tracking-[0.12em] text-emerald-800">Project foundation</p><h1 class="m-0 text-5xl font-bold sm:text-7xl">${title}</h1><p>The product domain is intentionally undefined.</p></main></template>`);
|
|
479
|
+
add(`${ui}/src/app/App.vue`, `<template><RouterView /></template><script setup lang="ts">import { RouterView } from "vue-router";</script>`);
|
|
480
|
+
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`);
|
|
481
|
+
add(`${ui}/src/main.ts`, `import { startApplication } from "@/app/startApplication";\nstartApplication();\n`);
|
|
482
|
+
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; }`);
|
|
483
|
+
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); }); });`);
|
|
484
|
+
add(`${ui}/AGENTS.md`, `# ${title} UI guidance
|
|
485
|
+
|
|
486
|
+
## Scope
|
|
487
|
+
These conventions apply to \`${ui}/src\`. Inspect the nearest working module before adding files.
|
|
488
|
+
|
|
489
|
+
## Architecture vocabulary
|
|
490
|
+
- Use \`src/app\` for Vue bootstrap, the application shell, routing, global styles, and truly application-wide state.
|
|
491
|
+
- Use \`src/core\` only for small technology-neutral client contracts.
|
|
492
|
+
- Use \`src/platform\` for Axios, Firebase, browser APIs, and other technical adapters.
|
|
493
|
+
- Use \`src/modules/<module>\` for every product capability. Never substitute \`src/features\` or global \`apis\`, \`views\`, \`services\`, \`stores\`, or \`types\` directories.
|
|
494
|
+
- These are the only UI architecture roots. Keep \`src/core\` flat. Shared capability code belongs to the module that owns it; reusable application-shell presentation belongs under \`src/app\`, whose optional directories are limited to \`components\`, \`stores\`, \`views\`, and \`__tests__\`.
|
|
495
|
+
- Keep application-wide theme state at \`src/app/stores/theme.store.ts\`, its toggle at \`src/app/components/ThemeToggle.vue\`, and its tests under \`src/app/__tests__\`. Never create \`src/modules/theme\`.
|
|
496
|
+
|
|
497
|
+
## Change placement
|
|
498
|
+
| Change | Canonical owner |
|
|
499
|
+
| --- | --- |
|
|
500
|
+
| Capability API, component, composable, state, type, or view | \`src/modules/<module>\` |
|
|
501
|
+
| Axios, Firebase, browser, or vendor mechanism | \`src/platform\` |
|
|
502
|
+
| Shell, router, global state/theme, or cross-capability composition | \`src/app\` |
|
|
503
|
+
| Technology-neutral client contract | Flat \`src/core\` |
|
|
504
|
+
| Root executable | Bootstrap of \`src/app\` only |
|
|
505
|
+
|
|
506
|
+
UI modules never import one another. Compose capabilities in \`src/app\`; platform code never imports app or module code.
|
|
507
|
+
|
|
508
|
+
## Module shape
|
|
509
|
+
Under \`src/modules/<module>\`, add only needed \`api/\`, \`components/\`, \`composables/\`, \`routes/\`, \`stores/\`, \`types/\`, \`views/\`, and \`__tests__/\` directories. Do not invent alternative layer names or generate speculative CRUD. Create module files only with \`tailframe generate\`.
|
|
510
|
+
|
|
511
|
+
## API contract
|
|
512
|
+
- Use the shared Axios client and singular \`POST <module>.<operation>\` calls.
|
|
513
|
+
- Return typed \`response.data.result\` values and keep payloads, authentication, and types aligned with the service.
|
|
514
|
+
- Frontend checks do not replace backend authorization or validation.
|
|
515
|
+
|
|
516
|
+
## Routing and authentication
|
|
517
|
+
Use named routes.${options.auth === "firebase" ? " Preserve Firebase authentication readiness and global `401` behavior, and test redirect ordering." : " This scaffold has no authentication; do not add auth state or redirects until the product requires them."}
|
|
518
|
+
|
|
519
|
+
## State management
|
|
520
|
+
Keep local state local. Use Pinia only for state shared across routes or unrelated components.
|
|
521
|
+
|
|
522
|
+
## UI behavior
|
|
523
|
+
Reuse Vue, PrimeVue, Tailwind, loading, error, and accessibility conventions. Provide explicit loading, empty, error, and authorization states.
|
|
524
|
+
|
|
525
|
+
## Styling
|
|
526
|
+
- Prefer Tailwind utility classes in Vue templates over custom selectors and component-scoped CSS.
|
|
527
|
+
- Prefer flex or grid containers with \`gap\` and container padding over sibling margins for layout and spacing.
|
|
528
|
+
- Keep \`src/app/styles.css\` limited to Tailwind imports, theme tokens, and true global base behavior.
|
|
529
|
+
|
|
530
|
+
## Production packaging
|
|
531
|
+
The UI runs through Vite in development but has no production container. The service's multi-stage Dockerfile builds this repository and copies its output into \`dist/public\`; Express serves it with SPA fallback. AWS ECR stores the combined production image.
|
|
532
|
+
|
|
533
|
+
## Product language
|
|
534
|
+
The product domain is undefined. Do not invent entities, workflows, roles, claims, navigation, or customer-facing promises.
|
|
535
|
+
|
|
536
|
+
## Validation
|
|
537
|
+
Run \`npm run validate:architecture\`, then add focused Vitest coverage and run type-check, build, and tests as applicable.
|
|
538
|
+
`);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
add(`${svc}/Dockerfile`, options.ui ? `FROM node:22.13-bookworm AS service-build
|
|
542
|
+
WORKDIR /app/service
|
|
543
|
+
COPY ${svc}/package*.json ./
|
|
544
|
+
RUN npm ci
|
|
545
|
+
COPY ${svc}/ ./
|
|
546
|
+
RUN npm run build
|
|
547
|
+
|
|
548
|
+
FROM node:22.13-bookworm AS ui-build
|
|
549
|
+
WORKDIR /app/ui
|
|
550
|
+
COPY ${ui}/package*.json ./
|
|
551
|
+
RUN npm ci
|
|
552
|
+
COPY ${ui}/ ./
|
|
553
|
+
RUN npm run build
|
|
554
|
+
|
|
555
|
+
FROM node:22.13-bookworm-slim
|
|
556
|
+
WORKDIR /app
|
|
557
|
+
ENV NODE_ENV=production
|
|
558
|
+
COPY --from=service-build /app/service/dist ./dist
|
|
559
|
+
COPY --from=service-build /app/service/package*.json ./
|
|
560
|
+
COPY --from=service-build /app/service/node_modules ./node_modules
|
|
561
|
+
COPY --from=ui-build /app/ui/dist ./dist/public
|
|
562
|
+
EXPOSE 3000
|
|
563
|
+
CMD ["node", "dist/server.js"]
|
|
564
|
+
` : `FROM node:22.13-bookworm AS build
|
|
565
|
+
WORKDIR /app
|
|
566
|
+
COPY package*.json ./
|
|
567
|
+
RUN npm ci
|
|
568
|
+
COPY . .
|
|
569
|
+
RUN npm run build
|
|
570
|
+
|
|
571
|
+
FROM node:22.13-bookworm-slim
|
|
572
|
+
WORKDIR /app
|
|
573
|
+
ENV NODE_ENV=production
|
|
574
|
+
COPY --from=build /app/dist ./dist
|
|
575
|
+
COPY --from=build /app/package*.json ./
|
|
576
|
+
COPY --from=build /app/node_modules ./node_modules
|
|
577
|
+
EXPOSE 3000
|
|
578
|
+
CMD ["node", "dist/server.js"]
|
|
579
|
+
`);
|
|
580
|
+
add(`${svc}/Dockerfile.dev`, `FROM node:22.13-alpine
|
|
581
|
+
WORKDIR /app
|
|
582
|
+
COPY package*.json ./
|
|
583
|
+
RUN npm install
|
|
584
|
+
COPY . .
|
|
585
|
+
EXPOSE 3000
|
|
586
|
+
CMD ["npm", "run", "dev"]
|
|
587
|
+
`);
|
|
588
|
+
add(`${svc}/scripts/build_and_push.sh`, `#!/usr/bin/env bash
|
|
589
|
+
set -euo pipefail
|
|
590
|
+
|
|
591
|
+
: "\${AWS_ACCOUNT_ID:?Set AWS_ACCOUNT_ID}"
|
|
592
|
+
: "\${AWS_REGION:?Set AWS_REGION}"
|
|
593
|
+
|
|
594
|
+
ECR_REPOSITORY="\${ECR_REPOSITORY:-${options.name}}"
|
|
595
|
+
IMAGE_TAG="\${IMAGE_TAG:-latest}"
|
|
596
|
+
REGISTRY="\${AWS_ACCOUNT_ID}.dkr.ecr.\${AWS_REGION}.amazonaws.com"
|
|
597
|
+
IMAGE="\${REGISTRY}/\${ECR_REPOSITORY}:\${IMAGE_TAG}"
|
|
598
|
+
SVC_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")/.." && pwd)"
|
|
599
|
+
BUILD_CONTEXT="${options.ui ? `$(cd "\${SVC_DIR}/.." && pwd)` : `\${SVC_DIR}`}"
|
|
600
|
+
DOCKERFILE="\${SVC_DIR}/Dockerfile"
|
|
601
|
+
|
|
602
|
+
aws ecr get-login-password --region "\${AWS_REGION}" |
|
|
603
|
+
docker login --username AWS --password-stdin "\${REGISTRY}"
|
|
604
|
+
|
|
605
|
+
docker build --file "\${DOCKERFILE}" --tag "\${IMAGE}" "\${BUILD_CONTEXT}"
|
|
606
|
+
docker push "\${IMAGE}"
|
|
607
|
+
|
|
608
|
+
echo "Pushed \${IMAGE}"
|
|
609
|
+
`);
|
|
610
|
+
|
|
611
|
+
const serviceVolumes = [
|
|
612
|
+
...(options.auth === "firebase" ? [" - ./firebase-service-account.production.json:/app/firebase-service-account.production.json:ro"] : [])
|
|
613
|
+
];
|
|
614
|
+
const devVolumes = [
|
|
615
|
+
" - .:/app",
|
|
616
|
+
" - /app/node_modules",
|
|
617
|
+
...(options.auth === "firebase" ? [" - ./firebase-service-account.development.json:/app/firebase-service-account.development.json:ro"] : [])
|
|
618
|
+
];
|
|
619
|
+
const dependencies = [
|
|
620
|
+
["mongo-init", "service_completed_successfully"],
|
|
621
|
+
...(options.redis ? [["redis", "service_started"]] : [])
|
|
622
|
+
];
|
|
623
|
+
const dependsBlock = `\n depends_on:\n${dependencies.map(([dependency, condition]) => ` ${dependency}:\n condition: ${condition}`).join("\n")}`;
|
|
624
|
+
const databaseEnvironmentBlock = `\n environment:\n MONGODB_URI: "mongodb://mongodb:27017/?replicaSet=rs0&directConnection=true"`;
|
|
625
|
+
const volumeBlock = serviceVolumes.length ? `\n volumes:\n${serviceVolumes.join("\n")}` : "";
|
|
626
|
+
const workerService = options.worker ? `\n worker:
|
|
627
|
+
image: \${ECR_IMAGE:?Set ECR_IMAGE}:\${IMAGE_TAG:-latest}
|
|
628
|
+
env_file:
|
|
629
|
+
- .env.production
|
|
630
|
+
command: ["node", "dist/worker.js"]${databaseEnvironmentBlock}${volumeBlock}${dependsBlock}
|
|
631
|
+
restart: unless-stopped` : "";
|
|
632
|
+
const databaseService = `\n mongodb:
|
|
633
|
+
image: mongo:8
|
|
634
|
+
command: mongod --replSet rs0 --bind_ip_all
|
|
635
|
+
volumes:
|
|
636
|
+
- mongodb-data:/data/db
|
|
637
|
+
healthcheck:
|
|
638
|
+
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
|
|
639
|
+
interval: 2s
|
|
640
|
+
timeout: 2s
|
|
641
|
+
retries: 30
|
|
642
|
+
restart: unless-stopped
|
|
643
|
+
mongo-init:
|
|
644
|
+
image: mongo:8
|
|
645
|
+
depends_on:
|
|
646
|
+
mongodb:
|
|
647
|
+
condition: service_healthy
|
|
648
|
+
restart: "no"
|
|
649
|
+
entrypoint:
|
|
650
|
+
- bash
|
|
651
|
+
- -c
|
|
652
|
+
- |
|
|
653
|
+
mongosh --host mongodb --quiet --eval '
|
|
654
|
+
try {
|
|
655
|
+
rs.status().ok
|
|
656
|
+
} catch (error) {
|
|
657
|
+
rs.initiate({ _id: "rs0", members: [{ _id: 0, host: "localhost:27017" }] })
|
|
658
|
+
}
|
|
659
|
+
'`;
|
|
660
|
+
const redisService = options.redis ? `\n redis:
|
|
661
|
+
image: redis:7-alpine
|
|
662
|
+
volumes:
|
|
663
|
+
- redis-data:/data
|
|
664
|
+
restart: unless-stopped` : "";
|
|
665
|
+
const namedVolumes = [
|
|
666
|
+
" mongodb-data:",
|
|
667
|
+
...(options.redis ? [" redis-data:"] : [])
|
|
668
|
+
];
|
|
669
|
+
add(`${svc}/docker-compose.yml`, `name: ${options.name}-prod
|
|
670
|
+
services:
|
|
671
|
+
${options.name}:
|
|
672
|
+
image: \${ECR_IMAGE:?Set ECR_IMAGE}:\${IMAGE_TAG:-latest}
|
|
673
|
+
env_file:
|
|
674
|
+
- .env.production
|
|
675
|
+
ports:
|
|
676
|
+
- "3000:3000"${databaseEnvironmentBlock}${volumeBlock}${dependsBlock}
|
|
677
|
+
restart: unless-stopped${workerService}${databaseService}${redisService}
|
|
678
|
+
volumes:
|
|
679
|
+
${namedVolumes.join("\n")}
|
|
680
|
+
`);
|
|
681
|
+
|
|
682
|
+
const devWorker = options.worker ? `\n worker:
|
|
683
|
+
build:
|
|
684
|
+
context: .
|
|
685
|
+
dockerfile: Dockerfile.dev
|
|
686
|
+
env_file:
|
|
687
|
+
- .env.development
|
|
688
|
+
command: ["npm", "run", "dev:worker"]${databaseEnvironmentBlock}
|
|
689
|
+
volumes:
|
|
690
|
+
${devVolumes.join("\n")}${dependsBlock}` : "";
|
|
691
|
+
const devDatabase = `\n mongodb:
|
|
692
|
+
image: mongo:8
|
|
693
|
+
command: mongod --replSet rs0 --bind_ip_all
|
|
694
|
+
ports:
|
|
695
|
+
- "27017:27017"
|
|
696
|
+
volumes:
|
|
697
|
+
- mongodb-dev-data:/data/db
|
|
698
|
+
healthcheck:
|
|
699
|
+
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
|
|
700
|
+
interval: 2s
|
|
701
|
+
timeout: 2s
|
|
702
|
+
retries: 30
|
|
703
|
+
mongo-init:
|
|
704
|
+
image: mongo:8
|
|
705
|
+
depends_on:
|
|
706
|
+
mongodb:
|
|
707
|
+
condition: service_healthy
|
|
708
|
+
restart: "no"
|
|
709
|
+
entrypoint:
|
|
710
|
+
- bash
|
|
711
|
+
- -c
|
|
712
|
+
- |
|
|
713
|
+
mongosh --host mongodb --quiet --eval '
|
|
714
|
+
try {
|
|
715
|
+
rs.status().ok
|
|
716
|
+
} catch (error) {
|
|
717
|
+
rs.initiate({ _id: "rs0", members: [{ _id: 0, host: "localhost:27017" }] })
|
|
718
|
+
}
|
|
719
|
+
'`;
|
|
720
|
+
const devRedis = options.redis ? `\n redis:
|
|
721
|
+
image: redis:7-alpine
|
|
722
|
+
ports:
|
|
723
|
+
- "6379:6379"
|
|
724
|
+
volumes:
|
|
725
|
+
- redis-dev-data:/data` : "";
|
|
726
|
+
const devNamedVolumes = [
|
|
727
|
+
" mongodb-dev-data:",
|
|
728
|
+
...(options.redis ? [" redis-dev-data:"] : [])
|
|
729
|
+
];
|
|
730
|
+
add(`${svc}/docker-compose.dev.yml`, `name: ${options.name}-dev
|
|
731
|
+
services:
|
|
732
|
+
${options.name}:
|
|
733
|
+
build:
|
|
734
|
+
context: .
|
|
735
|
+
dockerfile: Dockerfile.dev
|
|
736
|
+
env_file:
|
|
737
|
+
- .env.development
|
|
738
|
+
ports:
|
|
739
|
+
- "3000:3000"${databaseEnvironmentBlock}
|
|
740
|
+
volumes:
|
|
741
|
+
${devVolumes.join("\n")}${dependsBlock}${devWorker}${devDatabase}${devRedis}
|
|
742
|
+
volumes:
|
|
743
|
+
${devNamedVolumes.join("\n")}
|
|
744
|
+
`);
|
|
745
|
+
|
|
746
|
+
for (const [relative, content] of Object.entries(files)) {
|
|
747
|
+
const destination = path.join(root, relative);
|
|
748
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
749
|
+
fs.writeFileSync(destination, content);
|
|
750
|
+
if (relative.endsWith(".sh")) fs.chmodSync(destination, 0o755);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
return { root, name: options.name, database: "mongo", authentication: options.auth, ui: options.ui, redis: options.redis, worker: options.worker, repositories: [svc, ...(options.ui ? [ui] : [])], files: Object.keys(files).length };
|
|
754
|
+
}
|