create-forgeon 0.1.37 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +12 -4
  2. package/package.json +1 -1
  3. package/src/cli/help.mjs +3 -2
  4. package/src/cli/options.mjs +142 -121
  5. package/src/cli/options.test.mjs +13 -10
  6. package/src/constants.mjs +11 -9
  7. package/src/core/docs.mjs +44 -23
  8. package/src/core/docs.test.mjs +21 -15
  9. package/src/core/scaffold.mjs +27 -15
  10. package/src/modules/db-prisma.mjs +108 -31
  11. package/src/modules/executor.test.mjs +51 -13
  12. package/src/modules/i18n.mjs +10 -27
  13. package/src/modules/logger.mjs +4 -1
  14. package/src/modules/swagger.mjs +7 -2
  15. package/src/presets/i18n.mjs +63 -40
  16. package/src/run-add-module.mjs +87 -17
  17. package/src/run-create-forgeon.mjs +33 -24
  18. package/templates/base/README.md +16 -3
  19. package/templates/base/apps/api/Dockerfile +6 -11
  20. package/templates/base/apps/api/package.json +13 -24
  21. package/templates/base/apps/api/src/app.module.ts +3 -5
  22. package/templates/base/apps/api/src/health/health.controller.ts +1 -19
  23. package/templates/base/apps/web/src/App.tsx +0 -5
  24. package/templates/base/docs/AI/MODULE_CHECKS.md +1 -1
  25. package/templates/base/docs/AI/ROADMAP.md +1 -1
  26. package/templates/base/infra/docker/.env.example +1 -6
  27. package/templates/base/infra/docker/compose.caddy.yml +13 -37
  28. package/templates/base/infra/docker/compose.nginx.yml +13 -37
  29. package/templates/base/infra/docker/compose.none.yml +8 -32
  30. package/templates/base/infra/docker/compose.yml +16 -40
  31. package/templates/base/package.json +12 -9
  32. package/templates/base/scripts/forgeon-sync-integrations.mjs +399 -0
  33. package/templates/docs-fragments/AI_ARCHITECTURE/20_env_base.md +0 -1
  34. package/templates/docs-fragments/AI_ARCHITECTURE/20b_env_db_prisma.md +1 -0
  35. package/templates/docs-fragments/AI_ARCHITECTURE/30_default_db.md +2 -2
  36. package/templates/docs-fragments/AI_ARCHITECTURE/30_default_db_none.md +7 -0
  37. package/templates/docs-fragments/AI_PROJECT/20_structure_base.md +0 -1
  38. package/templates/docs-fragments/AI_PROJECT/20b_structure_db_none.md +2 -0
  39. package/templates/docs-fragments/AI_PROJECT/20b_structure_db_prisma.md +1 -0
  40. package/templates/docs-fragments/README/10_stack.md +4 -4
  41. package/templates/docs-fragments/README/21_quick_start_dev_no_db.md +6 -0
  42. package/templates/module-presets/i18n/apps/web/src/App.tsx +0 -5
  43. package/templates/module-presets/jwt-auth/packages/auth-api/src/auth.controller.ts +1 -1
  44. package/templates/module-presets/jwt-auth/packages/auth-api/src/auth.service.ts +11 -2
  45. package/templates/module-presets/jwt-auth/packages/auth-api/src/dto/login.dto.ts +1 -1
  46. package/templates/module-presets/jwt-auth/packages/auth-api/src/dto/refresh.dto.ts +1 -1
  47. /package/templates/{base → module-presets/db-prisma}/apps/api/prisma/migrations/0001_init/migration.sql +0 -0
  48. /package/templates/{base → module-presets/db-prisma}/apps/api/prisma/migrations/migration_lock.toml +0 -0
  49. /package/templates/{base → module-presets/db-prisma}/apps/api/prisma/schema.prisma +0 -0
  50. /package/templates/{base → module-presets/db-prisma}/apps/api/prisma/seed.ts +0 -0
  51. /package/templates/{base → module-presets/db-prisma}/packages/db-prisma/README.md +0 -0
  52. /package/templates/{base → module-presets/db-prisma}/packages/db-prisma/package.json +0 -0
  53. /package/templates/{base → module-presets/db-prisma}/packages/db-prisma/src/db-prisma-config.loader.ts +0 -0
  54. /package/templates/{base → module-presets/db-prisma}/packages/db-prisma/src/db-prisma-config.service.ts +0 -0
  55. /package/templates/{base → module-presets/db-prisma}/packages/db-prisma/src/db-prisma-env.schema.ts +0 -0
  56. /package/templates/{base → module-presets/db-prisma}/packages/db-prisma/src/db-prisma.module.ts +0 -0
  57. /package/templates/{base → module-presets/db-prisma}/packages/db-prisma/src/index.ts +0 -0
  58. /package/templates/{base → module-presets/db-prisma}/packages/db-prisma/src/prisma.service.ts +0 -0
  59. /package/templates/{base → module-presets/db-prisma}/packages/db-prisma/tsconfig.json +0 -0
@@ -1,45 +1,21 @@
1
- services:
2
- db:
3
- image: postgres:16-alpine
4
- restart: unless-stopped
5
- environment:
6
- POSTGRES_USER: ${POSTGRES_USER}
7
- POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
8
- POSTGRES_DB: ${POSTGRES_DB}
9
- ports:
10
- - "5432:5432"
11
- volumes:
12
- - db_data:/var/lib/postgresql/data
13
- healthcheck:
14
- test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
15
- interval: 10s
16
- timeout: 5s
17
- retries: 10
18
-
19
- api:
20
- build:
21
- context: ../..
22
- dockerfile: apps/api/Dockerfile
23
- restart: unless-stopped
1
+ services:
2
+ api:
3
+ build:
4
+ context: ../..
5
+ dockerfile: apps/api/Dockerfile
6
+ restart: unless-stopped
24
7
  environment:
25
8
  PORT: ${PORT}
26
9
  API_PREFIX: ${API_PREFIX}
27
- DATABASE_URL: ${DATABASE_URL}
28
10
  I18N_DEFAULT_LANG: ${I18N_DEFAULT_LANG}
29
11
  I18N_FALLBACK_LANG: ${I18N_FALLBACK_LANG}
30
- depends_on:
31
- db:
32
- condition: service_healthy
33
-
34
- nginx:
12
+
13
+ nginx:
35
14
  build:
36
15
  context: ../..
37
16
  dockerfile: infra/docker/nginx.Dockerfile
38
- restart: unless-stopped
39
- depends_on:
40
- - api
41
- ports:
42
- - "8080:80"
43
-
44
- volumes:
45
- db_data:
17
+ restart: unless-stopped
18
+ depends_on:
19
+ - api
20
+ ports:
21
+ - "8080:80"
@@ -1,37 +1,13 @@
1
- services:
2
- db:
3
- image: postgres:16-alpine
4
- restart: unless-stopped
5
- environment:
6
- POSTGRES_USER: ${POSTGRES_USER}
7
- POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
8
- POSTGRES_DB: ${POSTGRES_DB}
9
- ports:
10
- - "5432:5432"
11
- volumes:
12
- - db_data:/var/lib/postgresql/data
13
- healthcheck:
14
- test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
15
- interval: 10s
16
- timeout: 5s
17
- retries: 10
18
-
19
- api:
20
- build:
21
- context: ../..
22
- dockerfile: apps/api/Dockerfile
23
- restart: unless-stopped
1
+ services:
2
+ api:
3
+ build:
4
+ context: ../..
5
+ dockerfile: apps/api/Dockerfile
6
+ restart: unless-stopped
24
7
  environment:
25
8
  PORT: ${PORT}
26
9
  API_PREFIX: ${API_PREFIX}
27
- DATABASE_URL: ${DATABASE_URL}
28
10
  I18N_DEFAULT_LANG: ${I18N_DEFAULT_LANG}
29
11
  I18N_FALLBACK_LANG: ${I18N_FALLBACK_LANG}
30
- depends_on:
31
- db:
32
- condition: service_healthy
33
- ports:
34
- - "3000:3000"
35
-
36
- volumes:
37
- db_data:
12
+ ports:
13
+ - "3000:3000"
@@ -1,45 +1,21 @@
1
- services:
2
- db:
3
- image: postgres:16-alpine
4
- restart: unless-stopped
5
- environment:
6
- POSTGRES_USER: ${POSTGRES_USER}
7
- POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
8
- POSTGRES_DB: ${POSTGRES_DB}
9
- ports:
10
- - "5432:5432"
11
- volumes:
12
- - db_data:/var/lib/postgresql/data
13
- healthcheck:
14
- test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
15
- interval: 10s
16
- timeout: 5s
17
- retries: 10
18
-
19
- api:
20
- build:
21
- context: ../..
22
- dockerfile: apps/api/Dockerfile
23
- restart: unless-stopped
1
+ services:
2
+ api:
3
+ build:
4
+ context: ../..
5
+ dockerfile: apps/api/Dockerfile
6
+ restart: unless-stopped
24
7
  environment:
25
8
  PORT: ${PORT}
26
9
  API_PREFIX: ${API_PREFIX}
27
- DATABASE_URL: ${DATABASE_URL}
28
10
  I18N_DEFAULT_LANG: ${I18N_DEFAULT_LANG}
29
11
  I18N_FALLBACK_LANG: ${I18N_FALLBACK_LANG}
30
- depends_on:
31
- db:
32
- condition: service_healthy
33
-
34
- nginx:
35
- build:
36
- context: ../..
37
- dockerfile: infra/docker/nginx.Dockerfile
38
- restart: unless-stopped
39
- depends_on:
40
- - api
41
- ports:
42
- - "8080:80"
43
-
44
- volumes:
45
- db_data:
12
+
13
+ caddy:
14
+ build:
15
+ context: ../..
16
+ dockerfile: infra/docker/caddy.Dockerfile
17
+ restart: unless-stopped
18
+ depends_on:
19
+ - api
20
+ ports:
21
+ - "8080:80"
@@ -3,15 +3,18 @@
3
3
  "version": "0.1.0",
4
4
  "private": true,
5
5
  "packageManager": "pnpm@10.0.0",
6
- "scripts": {
7
- "dev": "pnpm --parallel --filter @forgeon/api --filter @forgeon/web dev",
8
- "build": "pnpm -r build",
9
- "postinstall": "pnpm --filter @forgeon/api prisma:generate",
10
- "create:forgeon": "node scripts/create-forgeon.mjs",
11
- "docker:up": "docker compose --env-file infra/docker/.env.example -f infra/docker/compose.yml up --build",
12
- "docker:down": "docker compose -f infra/docker/compose.yml down -v"
13
- },
14
- "pnpm": {
6
+ "scripts": {
7
+ "dev": "pnpm --parallel --filter @forgeon/api --filter @forgeon/web dev",
8
+ "build": "pnpm -r build",
9
+ "forgeon:sync-integrations": "node scripts/forgeon-sync-integrations.mjs",
10
+ "create:forgeon": "node scripts/create-forgeon.mjs",
11
+ "docker:up": "docker compose --env-file infra/docker/.env.example -f infra/docker/compose.yml up --build",
12
+ "docker:down": "docker compose -f infra/docker/compose.yml down -v"
13
+ },
14
+ "devDependencies": {
15
+ "ts-morph": "^24.0.0"
16
+ },
17
+ "pnpm": {
15
18
  "onlyBuiltDependencies": [
16
19
  "@nestjs/core",
17
20
  "@prisma/client",
@@ -0,0 +1,399 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { Project, QuoteKind } from 'ts-morph';
6
+
7
+ function hasAnyImport(sourceFile, moduleSpecifier) {
8
+ return sourceFile.getImportDeclarations().some((item) => item.getModuleSpecifierValue() === moduleSpecifier);
9
+ }
10
+
11
+ function ensureNamedImports(sourceFile, moduleSpecifier, names) {
12
+ const declaration = sourceFile
13
+ .getImportDeclarations()
14
+ .find((item) => item.getModuleSpecifierValue() === moduleSpecifier);
15
+
16
+ if (!declaration) {
17
+ sourceFile.addImportDeclaration({
18
+ moduleSpecifier,
19
+ namedImports: [...names].sort(),
20
+ });
21
+ return;
22
+ }
23
+
24
+ const existing = new Set(declaration.getNamedImports().map((item) => item.getName()));
25
+ for (const name of names) {
26
+ if (!existing.has(name)) {
27
+ declaration.addNamedImport(name);
28
+ }
29
+ }
30
+ }
31
+
32
+ function hasDecorator(node, decoratorName) {
33
+ return node.getDecorators().some((item) => item.getName() === decoratorName);
34
+ }
35
+
36
+ function addDecoratorIfMissing(node, decoratorName, args = []) {
37
+ if (hasDecorator(node, decoratorName)) {
38
+ return false;
39
+ }
40
+ node.addDecorator({
41
+ name: decoratorName,
42
+ arguments: args,
43
+ });
44
+ return true;
45
+ }
46
+
47
+ function syncJwtSwagger({ rootDir, changedFiles }) {
48
+ const controllerPath = path.join(
49
+ rootDir,
50
+ 'packages',
51
+ 'auth-api',
52
+ 'src',
53
+ 'auth.controller.ts',
54
+ );
55
+ const loginDtoPath = path.join(rootDir, 'packages', 'auth-api', 'src', 'dto', 'login.dto.ts');
56
+ const refreshDtoPath = path.join(rootDir, 'packages', 'auth-api', 'src', 'dto', 'refresh.dto.ts');
57
+
58
+ if (!fs.existsSync(controllerPath) || !fs.existsSync(loginDtoPath) || !fs.existsSync(refreshDtoPath)) {
59
+ return { applied: false, reason: 'jwt-auth source files are missing' };
60
+ }
61
+
62
+ const project = new Project({
63
+ manipulationSettings: { quoteKind: QuoteKind.Single },
64
+ skipAddingFilesFromTsConfig: true,
65
+ });
66
+
67
+ const controller = project.addSourceFileAtPath(controllerPath);
68
+ const loginDto = project.addSourceFileAtPath(loginDtoPath);
69
+ const refreshDto = project.addSourceFileAtPath(refreshDtoPath);
70
+
71
+ const controllerDecorators = [
72
+ 'ApiBearerAuth',
73
+ 'ApiBody',
74
+ 'ApiOkResponse',
75
+ 'ApiOperation',
76
+ 'ApiTags',
77
+ 'ApiUnauthorizedResponse',
78
+ ];
79
+ const dtoDecorators = ['ApiProperty'];
80
+
81
+ ensureNamedImports(controller, '@nestjs/swagger', controllerDecorators);
82
+ ensureNamedImports(loginDto, '@nestjs/swagger', dtoDecorators);
83
+ ensureNamedImports(refreshDto, '@nestjs/swagger', dtoDecorators);
84
+
85
+ const authController = controller.getClass('AuthController');
86
+ if (!authController) {
87
+ return { applied: false, reason: 'AuthController not found' };
88
+ }
89
+
90
+ addDecoratorIfMissing(authController, 'ApiTags', ["'auth'"]);
91
+
92
+ const loginMethod = authController.getMethod('login');
93
+ const refreshMethod = authController.getMethod('refresh');
94
+ const logoutMethod = authController.getMethod('logout');
95
+ const meMethod = authController.getMethod('me');
96
+
97
+ if (loginMethod) {
98
+ addDecoratorIfMissing(loginMethod, 'ApiOperation', ["{ summary: 'Authenticate demo user' }"]);
99
+ addDecoratorIfMissing(loginMethod, 'ApiBody', ['{ type: LoginDto }']);
100
+ addDecoratorIfMissing(loginMethod, 'ApiOkResponse', ["{ description: 'JWT token pair' }"]);
101
+ addDecoratorIfMissing(loginMethod, 'ApiUnauthorizedResponse', ["{ description: 'Invalid credentials' }"]);
102
+ }
103
+
104
+ if (refreshMethod) {
105
+ addDecoratorIfMissing(refreshMethod, 'ApiOperation', ["{ summary: 'Refresh access token' }"]);
106
+ addDecoratorIfMissing(refreshMethod, 'ApiBody', ['{ type: RefreshDto }']);
107
+ addDecoratorIfMissing(refreshMethod, 'ApiOkResponse', ["{ description: 'New JWT token pair' }"]);
108
+ addDecoratorIfMissing(refreshMethod, 'ApiUnauthorizedResponse', [
109
+ "{ description: 'Refresh token is invalid or expired' }",
110
+ ]);
111
+ }
112
+
113
+ if (logoutMethod) {
114
+ addDecoratorIfMissing(logoutMethod, 'ApiBearerAuth');
115
+ addDecoratorIfMissing(logoutMethod, 'ApiOperation', ["{ summary: 'Logout and clear refresh token state' }"]);
116
+ addDecoratorIfMissing(logoutMethod, 'ApiOkResponse', ["{ description: 'Logout accepted' }"]);
117
+ }
118
+
119
+ if (meMethod) {
120
+ addDecoratorIfMissing(meMethod, 'ApiBearerAuth');
121
+ addDecoratorIfMissing(meMethod, 'ApiOperation', ["{ summary: 'Get current authenticated user' }"]);
122
+ addDecoratorIfMissing(meMethod, 'ApiOkResponse', ["{ description: 'Current user payload' }"]);
123
+ }
124
+
125
+ const loginDtoClass = loginDto.getClass('LoginDto');
126
+ if (loginDtoClass) {
127
+ const emailProp = loginDtoClass.getProperty('email');
128
+ const passwordProp = loginDtoClass.getProperty('password');
129
+ if (emailProp) {
130
+ addDecoratorIfMissing(emailProp, 'ApiProperty', [
131
+ "{ example: 'demo@forgeon.local', description: 'Demo account email' }",
132
+ ]);
133
+ }
134
+ if (passwordProp) {
135
+ addDecoratorIfMissing(passwordProp, 'ApiProperty', [
136
+ "{ example: 'forgeon-demo-password', description: 'Demo account password' }",
137
+ ]);
138
+ }
139
+ }
140
+
141
+ const refreshDtoClass = refreshDto.getClass('RefreshDto');
142
+ if (refreshDtoClass) {
143
+ const tokenProp = refreshDtoClass.getProperty('refreshToken');
144
+ if (tokenProp) {
145
+ addDecoratorIfMissing(tokenProp, 'ApiProperty', [
146
+ "{ example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', description: 'Refresh token' }",
147
+ ]);
148
+ }
149
+ }
150
+
151
+ project.saveSync();
152
+ changedFiles.add(controllerPath);
153
+ changedFiles.add(loginDtoPath);
154
+ changedFiles.add(refreshDtoPath);
155
+
156
+ return { applied: true };
157
+ }
158
+
159
+ const PRISMA_AUTH_STORE_CONTENT = `import {
160
+ AuthRefreshTokenStore,
161
+ } from '@forgeon/auth-api';
162
+ import { PrismaService } from '@forgeon/db-prisma';
163
+ import { Injectable } from '@nestjs/common';
164
+
165
+ @Injectable()
166
+ export class PrismaAuthRefreshTokenStore implements AuthRefreshTokenStore {
167
+ readonly kind = 'prisma';
168
+
169
+ constructor(private readonly prisma: PrismaService) {}
170
+
171
+ async saveRefreshTokenHash(subject: string, hash: string): Promise<void> {
172
+ await this.prisma.user.upsert({
173
+ where: { email: subject },
174
+ create: { email: subject, refreshTokenHash: hash },
175
+ update: { refreshTokenHash: hash },
176
+ select: { id: true },
177
+ });
178
+ }
179
+
180
+ async getRefreshTokenHash(subject: string): Promise<string | null> {
181
+ const user = await this.prisma.user.findUnique({
182
+ where: { email: subject },
183
+ select: { refreshTokenHash: true },
184
+ });
185
+ return user?.refreshTokenHash ?? null;
186
+ }
187
+
188
+ async removeRefreshTokenHash(subject: string): Promise<void> {
189
+ await this.prisma.user.updateMany({
190
+ where: { email: subject },
191
+ data: { refreshTokenHash: null },
192
+ });
193
+ }
194
+ }
195
+ `;
196
+
197
+ const PRISMA_AUTH_MIGRATION_CONTENT = `-- AlterTable
198
+ ALTER TABLE "User"
199
+ ADD COLUMN "refreshTokenHash" TEXT;
200
+ `;
201
+
202
+ function syncJwtDbPrisma({ rootDir, changedFiles }) {
203
+ const appModulePath = path.join(rootDir, 'apps', 'api', 'src', 'app.module.ts');
204
+ const schemaPath = path.join(rootDir, 'apps', 'api', 'prisma', 'schema.prisma');
205
+ const storePath = path.join(rootDir, 'apps', 'api', 'src', 'auth', 'prisma-auth-refresh-token.store.ts');
206
+ const migrationPath = path.join(
207
+ rootDir,
208
+ 'apps',
209
+ 'api',
210
+ 'prisma',
211
+ 'migrations',
212
+ '0002_auth_refresh_token_hash',
213
+ 'migration.sql',
214
+ );
215
+ const readmePath = path.join(rootDir, 'README.md');
216
+
217
+ if (!fs.existsSync(appModulePath) || !fs.existsSync(schemaPath)) {
218
+ return { applied: false, reason: 'app module or prisma schema is missing' };
219
+ }
220
+
221
+ let touched = false;
222
+
223
+ if (!fs.existsSync(storePath)) {
224
+ fs.mkdirSync(path.dirname(storePath), { recursive: true });
225
+ fs.writeFileSync(storePath, PRISMA_AUTH_STORE_CONTENT, 'utf8');
226
+ changedFiles.add(storePath);
227
+ touched = true;
228
+ }
229
+
230
+ let appModule = fs.readFileSync(appModulePath, 'utf8').replace(/\r\n/g, '\n');
231
+ const originalAppModule = appModule;
232
+
233
+ if (appModule.includes("import { AUTH_REFRESH_TOKEN_STORE,")) {
234
+ // already includes token symbol
235
+ } else {
236
+ appModule = appModule.replace(
237
+ /import\s*\{([^}]*)\}\s*from '@forgeon\/auth-api';/m,
238
+ (full, namesRaw) => {
239
+ const names = namesRaw
240
+ .split(',')
241
+ .map((item) => item.trim())
242
+ .filter(Boolean);
243
+ if (!names.includes('AUTH_REFRESH_TOKEN_STORE')) {
244
+ names.unshift('AUTH_REFRESH_TOKEN_STORE');
245
+ }
246
+ return `import { ${names.join(', ')} } from '@forgeon/auth-api';`;
247
+ },
248
+ );
249
+ }
250
+
251
+ const storeImportLine = "import { PrismaAuthRefreshTokenStore } from './auth/prisma-auth-refresh-token.store';";
252
+ if (!appModule.includes(storeImportLine)) {
253
+ const controllerImport = "import { HealthController } from './health/health.controller';";
254
+ if (appModule.includes(controllerImport)) {
255
+ appModule = appModule.replace(controllerImport, `${storeImportLine}\n${controllerImport}`);
256
+ } else {
257
+ appModule = `${appModule.trimEnd()}\n${storeImportLine}\n`;
258
+ }
259
+ }
260
+
261
+ const authRegisterWithPrisma = `ForgeonAuthModule.register({
262
+ imports: [DbPrismaModule],
263
+ refreshTokenStoreProvider: {
264
+ provide: AUTH_REFRESH_TOKEN_STORE,
265
+ useClass: PrismaAuthRefreshTokenStore,
266
+ },
267
+ }),`;
268
+
269
+ if (!appModule.includes('refreshTokenStoreProvider')) {
270
+ if (/ForgeonAuthModule\.register\(\s*\),/.test(appModule)) {
271
+ appModule = appModule.replace(/ForgeonAuthModule\.register\(\s*\),/, authRegisterWithPrisma);
272
+ } else if (/ForgeonAuthModule\.register\(\{[\s\S]*?\}\),/m.test(appModule)) {
273
+ appModule = appModule.replace(/ForgeonAuthModule\.register\(\{[\s\S]*?\}\),/m, authRegisterWithPrisma);
274
+ }
275
+ }
276
+
277
+ if (appModule !== originalAppModule) {
278
+ fs.writeFileSync(appModulePath, `${appModule.trimEnd()}\n`, 'utf8');
279
+ changedFiles.add(appModulePath);
280
+ touched = true;
281
+ }
282
+
283
+ let schema = fs.readFileSync(schemaPath, 'utf8').replace(/\r\n/g, '\n');
284
+ const originalSchema = schema;
285
+ if (!schema.includes('refreshTokenHash')) {
286
+ schema = schema.replace(
287
+ /email\s+String\s+@unique/g,
288
+ 'email String @unique\n refreshTokenHash String?',
289
+ );
290
+ }
291
+ if (schema !== originalSchema) {
292
+ fs.writeFileSync(schemaPath, `${schema.trimEnd()}\n`, 'utf8');
293
+ changedFiles.add(schemaPath);
294
+ touched = true;
295
+ }
296
+
297
+ if (!fs.existsSync(migrationPath)) {
298
+ fs.mkdirSync(path.dirname(migrationPath), { recursive: true });
299
+ fs.writeFileSync(migrationPath, PRISMA_AUTH_MIGRATION_CONTENT, 'utf8');
300
+ changedFiles.add(migrationPath);
301
+ touched = true;
302
+ }
303
+
304
+ if (fs.existsSync(readmePath)) {
305
+ let readme = fs.readFileSync(readmePath, 'utf8').replace(/\r\n/g, '\n');
306
+ const originalReadme = readme;
307
+ readme = readme.replace(
308
+ '- refresh token persistence: disabled (no supported DB adapter found)',
309
+ '- refresh token persistence: enabled (`db-prisma` adapter)',
310
+ );
311
+ readme = readme.replace(
312
+ /- to enable persistence later:[\s\S]*?2\. run `create-forgeon add jwt-auth --project \.` again to auto-wire the adapter\./m,
313
+ '- migration: `apps/api/prisma/migrations/0002_auth_refresh_token_hash`',
314
+ );
315
+ if (readme !== originalReadme) {
316
+ fs.writeFileSync(readmePath, `${readme.trimEnd()}\n`, 'utf8');
317
+ changedFiles.add(readmePath);
318
+ touched = true;
319
+ }
320
+ }
321
+
322
+ if (!touched) {
323
+ return { applied: false, reason: 'already synced' };
324
+ }
325
+ return { applied: true };
326
+ }
327
+
328
+ function detectModules(rootDir) {
329
+ const appModulePath = path.join(rootDir, 'apps', 'api', 'src', 'app.module.ts');
330
+ const appModuleText = fs.existsSync(appModulePath) ? fs.readFileSync(appModulePath, 'utf8') : '';
331
+
332
+ return {
333
+ swagger:
334
+ fs.existsSync(path.join(rootDir, 'packages', 'swagger', 'package.json')) ||
335
+ appModuleText.includes("from '@forgeon/swagger'"),
336
+ jwtAuth:
337
+ fs.existsSync(path.join(rootDir, 'packages', 'auth-api', 'package.json')) ||
338
+ appModuleText.includes("from '@forgeon/auth-api'"),
339
+ dbPrisma:
340
+ fs.existsSync(path.join(rootDir, 'packages', 'db-prisma', 'package.json')) ||
341
+ appModuleText.includes("from '@forgeon/db-prisma'"),
342
+ };
343
+ }
344
+
345
+ function run() {
346
+ const rootDir = process.cwd();
347
+ const changedFiles = new Set();
348
+ const detected = detectModules(rootDir);
349
+ const summary = [];
350
+
351
+ if (detected.swagger && detected.jwtAuth) {
352
+ summary.push({
353
+ feature: 'jwt-auth + swagger',
354
+ result: syncJwtSwagger({ rootDir, changedFiles }),
355
+ });
356
+ } else {
357
+ summary.push({
358
+ feature: 'jwt-auth + swagger',
359
+ result: { applied: false, reason: 'required modules are not both installed' },
360
+ });
361
+ }
362
+
363
+ if (detected.jwtAuth && detected.dbPrisma) {
364
+ summary.push({
365
+ feature: 'jwt-auth + db-prisma',
366
+ result: syncJwtDbPrisma({ rootDir, changedFiles }),
367
+ });
368
+ } else {
369
+ summary.push({
370
+ feature: 'jwt-auth + db-prisma',
371
+ result: { applied: false, reason: 'required modules are not both installed' },
372
+ });
373
+ }
374
+
375
+ console.log('[forgeon:sync-integrations] done');
376
+ for (const item of summary) {
377
+ if (item.result.applied) {
378
+ console.log(`- ${item.feature}: applied`);
379
+ } else {
380
+ console.log(`- ${item.feature}: skipped (${item.result.reason})`);
381
+ }
382
+ }
383
+
384
+ if (changedFiles.size > 0) {
385
+ console.log('- changed files:');
386
+ for (const filePath of [...changedFiles].sort()) {
387
+ const relative = path.relative(rootDir, filePath);
388
+ console.log(` - ${relative}`);
389
+ }
390
+ }
391
+ }
392
+
393
+ const isMain =
394
+ process.argv[1] &&
395
+ path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
396
+
397
+ if (isMain) {
398
+ run();
399
+ }
@@ -2,7 +2,6 @@
2
2
 
3
3
  - `PORT` - API port (default 3000)
4
4
  - `API_PREFIX` - global API prefix (default `api`)
5
- - `DATABASE_URL` - DB connection string for Prisma
6
5
 
7
6
  ## Config Strategy
8
7
 
@@ -0,0 +1 @@
1
+ - `DATABASE_URL` - DB connection string for Prisma
@@ -4,6 +4,6 @@ Current default stack is `{{DB_LABEL}}`.
4
4
 
5
5
  - Prisma schema and migrations live in `apps/api/prisma`
6
6
  - DB access is encapsulated via `DbPrismaModule` in `@forgeon/db-prisma`
7
- - `db-prisma` is currently default-applied during scaffold generation.
8
- - Future direction: this DB layer may be extracted into an explicit add-module/preset and optionally exposed via CLI flag(s).
7
+ - `db-prisma` is a full add-module and is enabled by default during scaffold generation (`db-prisma=true`).
8
+ - It can be disabled at generation time and added later via `create-forgeon add db-prisma --project .`.
9
9
  - Additional DB presets are intentionally out of scope for the current milestone.
@@ -0,0 +1,7 @@
1
+ ## Default DB Stack
2
+
3
+ This preset was generated without `db-prisma`.
4
+
5
+ - API runs in DB-neutral mode.
6
+ - Add Prisma later via: `create-forgeon add db-prisma --project .`
7
+ - Future DB presets remain out of scope for this milestone.
@@ -3,4 +3,3 @@
3
3
  - `apps/api` - NestJS backend
4
4
  - `apps/web` - React frontend (`{{FRONTEND_LABEL}}`, fixed)
5
5
  - `packages/core` - shared backend core package (`core-config`, `core-errors`, `core-validation`)
6
- - `packages/db-prisma` - default DB module (`DbPrismaModule`, Prisma service + config)
@@ -0,0 +1,2 @@
1
+ - `db-prisma` is not installed in this preset (`db-prisma=false`)
2
+ - add later with: `create-forgeon add db-prisma --project .`
@@ -0,0 +1 @@
1
+ - `packages/db-prisma` - DB module (`DbPrismaModule`, Prisma service + config)
@@ -1,8 +1,8 @@
1
1
  ## Generated Preset
2
2
 
3
- - Stack: `NestJS + React + Prisma/Postgres + Docker`
4
- - Frontend: `{{FRONTEND_LABEL}}` (fixed)
5
- - Database: `{{DB_LABEL}}` (fixed)
6
- - i18n: `{{I18N_STATUS}}`
3
+ - Stack: `NestJS + React + Docker`
4
+ - Frontend: `{{FRONTEND_LABEL}}` (fixed)
5
+ - Database: `{{DB_LABEL}}` (`db-prisma`: `{{DB_PRISMA_STATUS}}`)
6
+ - i18n: `{{I18N_STATUS}}`
7
7
  - Docker/infra: `enabled` (fixed)
8
8
  - Reverse proxy: `{{PROXY_LABEL}}` (`caddy|nginx|none`)
@@ -0,0 +1,6 @@
1
+ 2. No DB module is enabled by default in this preset.
2
+ - API/web start without database dependencies.
3
+ - Add Prisma later when needed:
4
+ ```bash
5
+ npx create-forgeon@latest add db-prisma --project .
6
+ ```
@@ -17,7 +17,6 @@ export default function App() {
17
17
  const [healthResult, setHealthResult] = useState<ProbeResult | null>(null);
18
18
  const [errorProbeResult, setErrorProbeResult] = useState<ProbeResult | null>(null);
19
19
  const [validationProbeResult, setValidationProbeResult] = useState<ProbeResult | null>(null);
20
- const [dbProbeResult, setDbProbeResult] = useState<ProbeResult | null>(null);
21
20
  const [networkError, setNetworkError] = useState<string | null>(null);
22
21
 
23
22
  const changeLocale = (nextLocale: I18nLocale) => {
@@ -93,14 +92,10 @@ export default function App() {
93
92
  <button onClick={() => runProbe(setValidationProbeResult, '/health/validation')}>
94
93
  Check validation (expect 400)
95
94
  </button>
96
- <button onClick={() => runProbe(setDbProbeResult, '/health/db', { method: 'POST' })}>
97
- Check database (create user)
98
- </button>
99
95
  </div>
100
96
  {renderResult('Health response', healthResult)}
101
97
  {renderResult('Error probe response', errorProbeResult)}
102
98
  {renderResult('Validation probe response', validationProbeResult)}
103
- {renderResult('DB probe response', dbProbeResult)}
104
99
  {networkError ? <p className="error">{networkError}</p> : null}
105
100
  </main>
106
101
  );