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.
- package/README.md +12 -4
- package/package.json +1 -1
- package/src/cli/help.mjs +3 -2
- package/src/cli/options.mjs +142 -121
- package/src/cli/options.test.mjs +13 -10
- package/src/constants.mjs +11 -9
- package/src/core/docs.mjs +44 -23
- package/src/core/docs.test.mjs +21 -15
- package/src/core/scaffold.mjs +27 -15
- package/src/modules/db-prisma.mjs +108 -31
- package/src/modules/executor.test.mjs +51 -13
- package/src/modules/i18n.mjs +10 -27
- package/src/modules/logger.mjs +4 -1
- package/src/modules/swagger.mjs +7 -2
- package/src/presets/i18n.mjs +63 -40
- package/src/run-add-module.mjs +87 -17
- package/src/run-create-forgeon.mjs +33 -24
- package/templates/base/README.md +16 -3
- package/templates/base/apps/api/Dockerfile +6 -11
- package/templates/base/apps/api/package.json +13 -24
- package/templates/base/apps/api/src/app.module.ts +3 -5
- package/templates/base/apps/api/src/health/health.controller.ts +1 -19
- package/templates/base/apps/web/src/App.tsx +0 -5
- package/templates/base/docs/AI/MODULE_CHECKS.md +1 -1
- package/templates/base/docs/AI/ROADMAP.md +1 -1
- package/templates/base/infra/docker/.env.example +1 -6
- package/templates/base/infra/docker/compose.caddy.yml +13 -37
- package/templates/base/infra/docker/compose.nginx.yml +13 -37
- package/templates/base/infra/docker/compose.none.yml +8 -32
- package/templates/base/infra/docker/compose.yml +16 -40
- package/templates/base/package.json +12 -9
- package/templates/base/scripts/forgeon-sync-integrations.mjs +399 -0
- package/templates/docs-fragments/AI_ARCHITECTURE/20_env_base.md +0 -1
- package/templates/docs-fragments/AI_ARCHITECTURE/20b_env_db_prisma.md +1 -0
- package/templates/docs-fragments/AI_ARCHITECTURE/30_default_db.md +2 -2
- package/templates/docs-fragments/AI_ARCHITECTURE/30_default_db_none.md +7 -0
- package/templates/docs-fragments/AI_PROJECT/20_structure_base.md +0 -1
- package/templates/docs-fragments/AI_PROJECT/20b_structure_db_none.md +2 -0
- package/templates/docs-fragments/AI_PROJECT/20b_structure_db_prisma.md +1 -0
- package/templates/docs-fragments/README/10_stack.md +4 -4
- package/templates/docs-fragments/README/21_quick_start_dev_no_db.md +6 -0
- package/templates/module-presets/i18n/apps/web/src/App.tsx +0 -5
- package/templates/module-presets/jwt-auth/packages/auth-api/src/auth.controller.ts +1 -1
- package/templates/module-presets/jwt-auth/packages/auth-api/src/auth.service.ts +11 -2
- package/templates/module-presets/jwt-auth/packages/auth-api/src/dto/login.dto.ts +1 -1
- package/templates/module-presets/jwt-auth/packages/auth-api/src/dto/refresh.dto.ts +1 -1
- /package/templates/{base → module-presets/db-prisma}/apps/api/prisma/migrations/0001_init/migration.sql +0 -0
- /package/templates/{base → module-presets/db-prisma}/apps/api/prisma/migrations/migration_lock.toml +0 -0
- /package/templates/{base → module-presets/db-prisma}/apps/api/prisma/schema.prisma +0 -0
- /package/templates/{base → module-presets/db-prisma}/apps/api/prisma/seed.ts +0 -0
- /package/templates/{base → module-presets/db-prisma}/packages/db-prisma/README.md +0 -0
- /package/templates/{base → module-presets/db-prisma}/packages/db-prisma/package.json +0 -0
- /package/templates/{base → module-presets/db-prisma}/packages/db-prisma/src/db-prisma-config.loader.ts +0 -0
- /package/templates/{base → module-presets/db-prisma}/packages/db-prisma/src/db-prisma-config.service.ts +0 -0
- /package/templates/{base → module-presets/db-prisma}/packages/db-prisma/src/db-prisma-env.schema.ts +0 -0
- /package/templates/{base → module-presets/db-prisma}/packages/db-prisma/src/db-prisma.module.ts +0 -0
- /package/templates/{base → module-presets/db-prisma}/packages/db-prisma/src/index.ts +0 -0
- /package/templates/{base → module-presets/db-prisma}/packages/db-prisma/src/prisma.service.ts +0 -0
- /package/templates/{base → module-presets/db-prisma}/packages/db-prisma/tsconfig.json +0 -0
|
@@ -1,45 +1,21 @@
|
|
|
1
|
-
services:
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
+
}
|
|
@@ -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
|
|
8
|
-
-
|
|
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.
|
|
@@ -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 @@
|
|
|
1
|
+
- `packages/db-prisma` - DB module (`DbPrismaModule`, Prisma service + config)
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
## Generated Preset
|
|
2
2
|
|
|
3
|
-
- Stack: `NestJS + React +
|
|
4
|
-
- Frontend: `{{FRONTEND_LABEL}}` (fixed)
|
|
5
|
-
- Database: `{{DB_LABEL}}` (
|
|
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`)
|
|
@@ -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
|
);
|