create-forgeon 0.1.34 → 0.1.35

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-forgeon",
3
- "version": "0.1.34",
3
+ "version": "0.1.35",
4
4
  "description": "Forgeon project generator CLI",
5
5
  "license": "MIT",
6
6
  "author": "Forgeon",
@@ -0,0 +1,401 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { copyRecursive, writeJson } from '../utils/fs.mjs';
4
+
5
+ function copyFromBase(packageRoot, targetRoot, relativePath) {
6
+ const source = path.join(packageRoot, 'templates', 'base', relativePath);
7
+ if (!fs.existsSync(source)) {
8
+ throw new Error(`Missing db-prisma source template: ${source}`);
9
+ }
10
+ const destination = path.join(targetRoot, relativePath);
11
+ copyRecursive(source, destination);
12
+ }
13
+
14
+ function ensureDependency(packageJson, name, version) {
15
+ if (!packageJson.dependencies) {
16
+ packageJson.dependencies = {};
17
+ }
18
+ packageJson.dependencies[name] = version;
19
+ }
20
+
21
+ function ensureDevDependency(packageJson, name, version) {
22
+ if (!packageJson.devDependencies) {
23
+ packageJson.devDependencies = {};
24
+ }
25
+ packageJson.devDependencies[name] = version;
26
+ }
27
+
28
+ function ensureScript(packageJson, name, command) {
29
+ if (!packageJson.scripts) {
30
+ packageJson.scripts = {};
31
+ }
32
+ packageJson.scripts[name] = command;
33
+ }
34
+
35
+ function ensureBuildSteps(packageJson, scriptName, requiredCommands) {
36
+ if (!packageJson.scripts) {
37
+ packageJson.scripts = {};
38
+ }
39
+
40
+ const current = packageJson.scripts[scriptName];
41
+ const steps =
42
+ typeof current === 'string' && current.trim().length > 0
43
+ ? current
44
+ .split('&&')
45
+ .map((item) => item.trim())
46
+ .filter(Boolean)
47
+ : [];
48
+
49
+ for (const command of requiredCommands) {
50
+ if (!steps.includes(command)) {
51
+ steps.push(command);
52
+ }
53
+ }
54
+
55
+ if (steps.length > 0) {
56
+ packageJson.scripts[scriptName] = steps.join(' && ');
57
+ }
58
+ }
59
+
60
+ function ensureLineAfter(content, anchorLine, lineToInsert) {
61
+ if (content.includes(lineToInsert)) {
62
+ return content;
63
+ }
64
+
65
+ const index = content.indexOf(anchorLine);
66
+ if (index < 0) {
67
+ return `${content.trimEnd()}\n${lineToInsert}\n`;
68
+ }
69
+
70
+ const insertAt = index + anchorLine.length;
71
+ return `${content.slice(0, insertAt)}\n${lineToInsert}${content.slice(insertAt)}`;
72
+ }
73
+
74
+ function ensureLineBefore(content, anchorLine, lineToInsert) {
75
+ if (content.includes(lineToInsert)) {
76
+ return content;
77
+ }
78
+
79
+ const index = content.indexOf(anchorLine);
80
+ if (index < 0) {
81
+ return `${content.trimEnd()}\n${lineToInsert}\n`;
82
+ }
83
+
84
+ return `${content.slice(0, index)}${lineToInsert}\n${content.slice(index)}`;
85
+ }
86
+
87
+ function upsertEnvLines(filePath, lines) {
88
+ let content = '';
89
+ if (fs.existsSync(filePath)) {
90
+ content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
91
+ }
92
+
93
+ const keys = new Set(
94
+ content
95
+ .split('\n')
96
+ .filter(Boolean)
97
+ .map((line) => line.split('=')[0]),
98
+ );
99
+
100
+ const append = [];
101
+ for (const line of lines) {
102
+ const key = line.split('=')[0];
103
+ if (!keys.has(key)) {
104
+ append.push(line);
105
+ }
106
+ }
107
+
108
+ const next =
109
+ append.length > 0 ? `${content.trimEnd()}\n${append.join('\n')}\n` : `${content.trimEnd()}\n`;
110
+ fs.writeFileSync(filePath, next.replace(/^\n/, ''), 'utf8');
111
+ }
112
+
113
+ function ensureLoadItem(content, itemName) {
114
+ const pattern = /load:\s*\[([^\]]*)\]/m;
115
+ const match = content.match(pattern);
116
+ if (!match) {
117
+ return content;
118
+ }
119
+
120
+ const rawList = match[1];
121
+ const items = rawList
122
+ .split(',')
123
+ .map((item) => item.trim())
124
+ .filter(Boolean);
125
+
126
+ if (!items.includes(itemName)) {
127
+ items.push(itemName);
128
+ }
129
+
130
+ const next = `load: [${items.join(', ')}]`;
131
+ return content.replace(pattern, next);
132
+ }
133
+
134
+ function ensureValidatorSchema(content, schemaName) {
135
+ const pattern = /validate:\s*createEnvValidator\(\[([^\]]*)\]\)/m;
136
+ const match = content.match(pattern);
137
+ if (!match) {
138
+ return content;
139
+ }
140
+
141
+ const rawList = match[1];
142
+ const items = rawList
143
+ .split(',')
144
+ .map((item) => item.trim())
145
+ .filter(Boolean);
146
+
147
+ if (!items.includes(schemaName)) {
148
+ items.push(schemaName);
149
+ }
150
+
151
+ const next = `validate: createEnvValidator([${items.join(', ')}])`;
152
+ return content.replace(pattern, next);
153
+ }
154
+
155
+ function patchApiPackage(targetRoot) {
156
+ const packagePath = path.join(targetRoot, 'apps', 'api', 'package.json');
157
+ if (!fs.existsSync(packagePath)) {
158
+ return;
159
+ }
160
+
161
+ const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
162
+ ensureDependency(packageJson, '@forgeon/db-prisma', 'workspace:*');
163
+ ensureDependency(packageJson, '@prisma/client', '^6.18.0');
164
+ ensureDevDependency(packageJson, 'prisma', '^6.18.0');
165
+
166
+ ensureBuildSteps(packageJson, 'predev', ['pnpm --filter @forgeon/db-prisma build']);
167
+ ensureScript(packageJson, 'prisma:generate', 'prisma generate --schema prisma/schema.prisma');
168
+ ensureScript(packageJson, 'prisma:migrate:dev', 'prisma migrate dev --schema prisma/schema.prisma');
169
+ ensureScript(
170
+ packageJson,
171
+ 'prisma:migrate:deploy',
172
+ 'prisma migrate deploy --schema prisma/schema.prisma',
173
+ );
174
+ ensureScript(packageJson, 'prisma:studio', 'prisma studio --schema prisma/schema.prisma');
175
+ ensureScript(packageJson, 'prisma:seed', 'ts-node --transpile-only prisma/seed.ts');
176
+
177
+ if (!packageJson.prisma || typeof packageJson.prisma !== 'object') {
178
+ packageJson.prisma = {};
179
+ }
180
+ packageJson.prisma.seed = 'ts-node --transpile-only prisma/seed.ts';
181
+
182
+ writeJson(packagePath, packageJson);
183
+ }
184
+
185
+ function patchRootPackage(targetRoot) {
186
+ const packagePath = path.join(targetRoot, 'package.json');
187
+ if (!fs.existsSync(packagePath)) {
188
+ return;
189
+ }
190
+
191
+ const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
192
+ if (!packageJson.scripts) {
193
+ packageJson.scripts = {};
194
+ }
195
+
196
+ const command = 'pnpm --filter @forgeon/api prisma:generate';
197
+ const current = packageJson.scripts.postinstall;
198
+ if (typeof current !== 'string' || current.trim().length === 0) {
199
+ packageJson.scripts.postinstall = command;
200
+ } else if (!current.includes(command)) {
201
+ packageJson.scripts.postinstall = `${current} && ${command}`;
202
+ }
203
+
204
+ writeJson(packagePath, packageJson);
205
+ }
206
+
207
+ function patchAppModule(targetRoot) {
208
+ const filePath = path.join(targetRoot, 'apps', 'api', 'src', 'app.module.ts');
209
+ if (!fs.existsSync(filePath)) {
210
+ return;
211
+ }
212
+
213
+ let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
214
+ if (!content.includes("from '@forgeon/db-prisma';")) {
215
+ content = ensureLineAfter(
216
+ content,
217
+ "import { ConfigModule } from '@nestjs/config';",
218
+ "import { dbPrismaConfig, dbPrismaEnvSchema, DbPrismaModule } from '@forgeon/db-prisma';",
219
+ );
220
+ }
221
+
222
+ content = ensureLoadItem(content, 'dbPrismaConfig');
223
+ content = ensureValidatorSchema(content, 'dbPrismaEnvSchema');
224
+
225
+ if (!content.includes(' DbPrismaModule,')) {
226
+ if (content.includes(' ForgeonI18nModule.register({')) {
227
+ content = ensureLineBefore(content, ' ForgeonI18nModule.register({', ' DbPrismaModule,');
228
+ } else if (content.includes(' ForgeonLoggerModule,')) {
229
+ content = ensureLineAfter(content, ' ForgeonLoggerModule,', ' DbPrismaModule,');
230
+ } else if (content.includes(' ForgeonSwaggerModule,')) {
231
+ content = ensureLineAfter(content, ' ForgeonSwaggerModule,', ' DbPrismaModule,');
232
+ } else {
233
+ content = ensureLineAfter(content, ' CoreErrorsModule,', ' DbPrismaModule,');
234
+ }
235
+ }
236
+
237
+ fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
238
+ }
239
+
240
+ function patchHealthController(targetRoot) {
241
+ const filePath = path.join(targetRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts');
242
+ if (!fs.existsSync(filePath)) {
243
+ return;
244
+ }
245
+
246
+ let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
247
+ if (!content.includes("from '@forgeon/db-prisma';")) {
248
+ const anchor = content.includes("import { I18nService } from 'nestjs-i18n';")
249
+ ? "import { I18nService } from 'nestjs-i18n';"
250
+ : "import { BadRequestException, ConflictException, Controller, Get, Post, Query } from '@nestjs/common';";
251
+ content = ensureLineAfter(content, anchor, "import { PrismaService } from '@forgeon/db-prisma';");
252
+ }
253
+
254
+ if (!content.includes('private readonly prisma: PrismaService')) {
255
+ const constructorMatch = content.match(/constructor\(([\s\S]*?)\)\s*\{/m);
256
+ if (constructorMatch) {
257
+ const original = constructorMatch[0];
258
+ const inner = constructorMatch[1].trimEnd();
259
+ const separator = inner.length > 0 ? ',' : '';
260
+ const next = `constructor(${inner}${separator}
261
+ private readonly prisma: PrismaService,
262
+ ) {`;
263
+ content = content.replace(original, next);
264
+ }
265
+ }
266
+
267
+ if (!content.includes("@Post('db')")) {
268
+ const dbMethod = `
269
+ @Post('db')
270
+ async getDbProbe() {
271
+ const token = \`\${Date.now()}-\${Math.floor(Math.random() * 1_000_000)}\`;
272
+ const email = \`health-probe-\${token}@example.local\`;
273
+ const user = await this.prisma.user.create({
274
+ data: { email },
275
+ select: { id: true, email: true, createdAt: true },
276
+ });
277
+
278
+ return {
279
+ status: 'ok',
280
+ feature: 'db-prisma',
281
+ user,
282
+ };
283
+ }
284
+ `;
285
+ const translateIndex = content.indexOf('private translate(');
286
+ if (translateIndex > -1) {
287
+ content = `${content.slice(0, translateIndex).trimEnd()}\n\n${dbMethod}\n${content.slice(translateIndex)}`;
288
+ } else {
289
+ content = `${content.trimEnd()}\n${dbMethod}\n`;
290
+ }
291
+ }
292
+
293
+ fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
294
+ }
295
+
296
+ function patchApiDockerfile(targetRoot) {
297
+ const dockerfilePath = path.join(targetRoot, 'apps', 'api', 'Dockerfile');
298
+ if (!fs.existsSync(dockerfilePath)) {
299
+ return;
300
+ }
301
+
302
+ let content = fs.readFileSync(dockerfilePath, 'utf8').replace(/\r\n/g, '\n');
303
+ content = ensureLineAfter(
304
+ content,
305
+ 'COPY apps/api/package.json apps/api/package.json',
306
+ 'COPY apps/api/prisma apps/api/prisma',
307
+ );
308
+ content = ensureLineAfter(
309
+ content,
310
+ 'COPY packages/core/package.json packages/core/package.json',
311
+ 'COPY packages/db-prisma/package.json packages/db-prisma/package.json',
312
+ );
313
+ content = ensureLineAfter(content, 'COPY packages/core packages/core', 'COPY packages/db-prisma packages/db-prisma');
314
+
315
+ content = content.replace(/^RUN pnpm --filter @forgeon\/db-prisma build\r?\n?/gm, '');
316
+ content = ensureLineBefore(
317
+ content,
318
+ 'RUN pnpm --filter @forgeon/api prisma:generate',
319
+ 'RUN pnpm --filter @forgeon/db-prisma build',
320
+ );
321
+
322
+ if (!content.includes('RUN pnpm --filter @forgeon/api prisma:generate')) {
323
+ content = ensureLineBefore(
324
+ content,
325
+ 'RUN pnpm --filter @forgeon/api build',
326
+ 'RUN pnpm --filter @forgeon/api prisma:generate',
327
+ );
328
+ }
329
+
330
+ fs.writeFileSync(dockerfilePath, `${content.trimEnd()}\n`, 'utf8');
331
+ }
332
+
333
+ function patchCompose(targetRoot) {
334
+ const composePath = path.join(targetRoot, 'infra', 'docker', 'compose.yml');
335
+ if (!fs.existsSync(composePath)) {
336
+ return;
337
+ }
338
+
339
+ let content = fs.readFileSync(composePath, 'utf8').replace(/\r\n/g, '\n');
340
+ if (!content.includes('DATABASE_URL: ${DATABASE_URL}')) {
341
+ content = content.replace(
342
+ /^(\s+API_PREFIX:.*)$/m,
343
+ `$1
344
+ DATABASE_URL: \${DATABASE_URL}`,
345
+ );
346
+ }
347
+
348
+ fs.writeFileSync(composePath, `${content.trimEnd()}\n`, 'utf8');
349
+ }
350
+
351
+ function patchReadme(targetRoot) {
352
+ const readmePath = path.join(targetRoot, 'README.md');
353
+ if (!fs.existsSync(readmePath)) {
354
+ return;
355
+ }
356
+
357
+ const marker = '## DB Prisma Module';
358
+ let content = fs.readFileSync(readmePath, 'utf8').replace(/\r\n/g, '\n');
359
+ if (content.includes(marker)) {
360
+ return;
361
+ }
362
+
363
+ const section = `## DB Prisma Module
364
+
365
+ The db-prisma add-module provides:
366
+ - \`@forgeon/db-prisma\` package wiring
367
+ - Prisma scripts in \`apps/api/package.json\`
368
+ - DB probe endpoint (\`POST /api/health/db\`)
369
+
370
+ Configuration (env):
371
+ - \`DATABASE_URL=postgresql://postgres:postgres@localhost:5432/app?schema=public\`
372
+ `;
373
+
374
+ if (content.includes('## Prisma In Docker Start')) {
375
+ content = content.replace('## Prisma In Docker Start', `${section}\n## Prisma In Docker Start`);
376
+ } else {
377
+ content = `${content.trimEnd()}\n\n${section}\n`;
378
+ }
379
+
380
+ fs.writeFileSync(readmePath, `${content.trimEnd()}\n`, 'utf8');
381
+ }
382
+
383
+ export function applyDbPrismaModule({ packageRoot, targetRoot }) {
384
+ copyFromBase(packageRoot, targetRoot, path.join('packages', 'db-prisma'));
385
+ copyFromBase(packageRoot, targetRoot, path.join('apps', 'api', 'prisma'));
386
+
387
+ patchApiPackage(targetRoot);
388
+ patchRootPackage(targetRoot);
389
+ patchAppModule(targetRoot);
390
+ patchHealthController(targetRoot);
391
+ patchApiDockerfile(targetRoot);
392
+ patchCompose(targetRoot);
393
+ patchReadme(targetRoot);
394
+
395
+ upsertEnvLines(path.join(targetRoot, 'apps', 'api', '.env.example'), [
396
+ 'DATABASE_URL=postgresql://postgres:postgres@localhost:5432/app?schema=public',
397
+ ]);
398
+ upsertEnvLines(path.join(targetRoot, 'infra', 'docker', '.env.example'), [
399
+ 'DATABASE_URL=postgresql://postgres:postgres@db:5432/app?schema=public',
400
+ ]);
401
+ }
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { ensureModuleExists } from './registry.mjs';
4
4
  import { writeModuleDocs } from './docs.mjs';
5
+ import { applyDbPrismaModule } from './db-prisma.mjs';
5
6
  import { applyI18nModule } from './i18n.mjs';
6
7
  import { applyLoggerModule } from './logger.mjs';
7
8
  import { applySwaggerModule } from './swagger.mjs';
@@ -23,6 +24,7 @@ function ensureForgeonLikeProject(targetRoot) {
23
24
  }
24
25
 
25
26
  const MODULE_APPLIERS = {
27
+ 'db-prisma': applyDbPrismaModule,
26
28
  i18n: applyI18nModule,
27
29
  logger: applyLoggerModule,
28
30
  swagger: applySwaggerModule,