create-forgeon 0.0.1 → 0.0.3

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 CHANGED
@@ -5,7 +5,14 @@ CLI package for generating Forgeon fullstack monorepo projects.
5
5
  ## Usage
6
6
 
7
7
  ```bash
8
- npx create-forgeon@latest my-app --frontend react --db prisma --i18n true --docker true
8
+ npx create-forgeon@latest my-app --frontend react --db prisma --i18n true --docker true --proxy nginx
9
9
  ```
10
10
 
11
- If flags are omitted, the CLI asks interactive questions.
11
+ If flags are omitted, the CLI asks interactive questions.
12
+
13
+ ## Notes
14
+
15
+ - Implemented presets right now:
16
+ - `--frontend react`
17
+ - `--db prisma`
18
+ - `--proxy` works only when `--docker true`
@@ -6,8 +6,9 @@ import { spawnSync } from 'node:child_process';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { stdin as input, stdout as output } from 'node:process';
8
8
 
9
- const SUPPORTED_FRONTENDS = ['react', 'angular'];
10
- const SUPPORTED_DBS = ['prisma'];
9
+ const IMPLEMENTED_FRONTENDS = ['react'];
10
+ const IMPLEMENTED_DBS = ['prisma'];
11
+ const SUPPORTED_PROXIES = ['nginx', 'caddy'];
11
12
 
12
13
  function printHelp() {
13
14
  console.log(`create-forgeon
@@ -16,10 +17,11 @@ Usage:
16
17
  npx create-forgeon@latest <project-name> [options]
17
18
 
18
19
  Options:
19
- --frontend <react|angular> Frontend preset (default: react)
20
- --db <prisma> DB preset (default: prisma)
20
+ --frontend <react|angular> Frontend preset (implemented: react)
21
+ --db <prisma> DB preset (implemented: prisma)
21
22
  --i18n <true|false> Enable i18n (default: true)
22
- --docker <true|false> Include docker/infra files (default: true)
23
+ --docker <true|false> Include Docker/infra files (default: true)
24
+ --proxy <nginx|caddy> Reverse proxy preset when docker=true (default: nginx)
23
25
  --install Run pnpm install after generation
24
26
  -y, --yes Skip prompts and use defaults
25
27
  -h, --help Show this help
@@ -38,11 +40,13 @@ function parseBoolean(value, fallback) {
38
40
  }
39
41
 
40
42
  function toKebabCase(value) {
41
- return value
42
- .trim()
43
- .toLowerCase()
44
- .replace(/[^a-z0-9]+/g, '-')
45
- .replace(/^-+|-+$/g, '') || 'forgeon-app';
43
+ return (
44
+ value
45
+ .trim()
46
+ .toLowerCase()
47
+ .replace(/[^a-z0-9]+/g, '-')
48
+ .replace(/^-+|-+$/g, '') || 'forgeon-app'
49
+ );
46
50
  }
47
51
 
48
52
  function copyRecursive(source, destination) {
@@ -63,6 +67,331 @@ function writeJson(filePath, data) {
63
67
  fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
64
68
  }
65
69
 
70
+ function escapeRegex(value) {
71
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
72
+ }
73
+
74
+ function removeMarkdownSection(content, title) {
75
+ const pattern = new RegExp(`\\n## ${escapeRegex(title)}[\\s\\S]*?(?=\\n## |$)`, 'm');
76
+ return content.replace(pattern, '');
77
+ }
78
+
79
+ function removeIfExists(targetPath) {
80
+ if (fs.existsSync(targetPath)) {
81
+ fs.rmSync(targetPath, { recursive: true, force: true });
82
+ }
83
+ }
84
+
85
+ function patchReadme(targetRoot, { dockerEnabled, i18nEnabled }) {
86
+ const readmePath = path.join(targetRoot, 'README.md');
87
+ if (!fs.existsSync(readmePath)) return;
88
+
89
+ let content = fs.readFileSync(readmePath, 'utf8').replace(/\r\n/g, '\n');
90
+
91
+ if (!dockerEnabled) {
92
+ content = removeMarkdownSection(content, 'Quick Start (Docker)');
93
+ content = removeMarkdownSection(content, 'Prisma In Docker Start');
94
+ content = content.replace(
95
+ /2\. Start local Postgres \(Docker\):[\s\S]*?```\n3\. Run API \+ web in dev mode:/m,
96
+ '2. Ensure PostgreSQL is running locally and configure `DATABASE_URL` in `apps/api/.env`.\n3. Run API + web in dev mode:',
97
+ );
98
+ content = content.replace(
99
+ /```bash\ndocker compose[\s\S]*?Open `http:\/\/localhost:8080`\.\n?/m,
100
+ '',
101
+ );
102
+ content = content.replace(
103
+ /API container starts with:[\s\S]*?This keeps container startup production-like while still simple\.\n?/m,
104
+ '',
105
+ );
106
+ }
107
+
108
+ if (!i18nEnabled) {
109
+ content = removeMarkdownSection(content, 'i18n Toggle');
110
+ content = content.replace(
111
+ 'optional i18n (enabled by default), and ',
112
+ '',
113
+ );
114
+ content = content.replace(
115
+ /Set in env:[\s\S]*?When `I18N_ENABLED=false`, API runs without loading i18n module\.\n?/m,
116
+ '',
117
+ );
118
+ }
119
+
120
+ content = content.replace(/\n{3,}/g, '\n\n');
121
+ fs.writeFileSync(readmePath, content.trimEnd() + '\n', 'utf8');
122
+ }
123
+
124
+ function patchAiDocs(targetRoot, { dockerEnabled, i18nEnabled }) {
125
+ const projectDoc = path.join(targetRoot, 'docs', 'AI', 'PROJECT.md');
126
+ if (fs.existsSync(projectDoc)) {
127
+ let content = fs.readFileSync(projectDoc, 'utf8').replace(/\r\n/g, '\n');
128
+ if (!i18nEnabled) {
129
+ content = content
130
+ .replace(/^\- `packages\/i18n`.*\r?\n/gm, '')
131
+ .replace(/^\- `resources\/i18n`.*\r?\n/gm, '');
132
+ }
133
+ if (!dockerEnabled) {
134
+ content = content.replace(
135
+ /(^|\n)### Docker mode\n[\s\S]*?(?=\n### |\n## |$)/,
136
+ '\n',
137
+ );
138
+ content = content.replace(/^\- `infra`.*\r?\n/gm, '');
139
+ }
140
+ content = content.replace(/\n{3,}/g, '\n\n');
141
+ fs.writeFileSync(projectDoc, content.trimEnd() + '\n', 'utf8');
142
+ }
143
+
144
+ const archDoc = path.join(targetRoot, 'docs', 'AI', 'ARCHITECTURE.md');
145
+ if (fs.existsSync(archDoc)) {
146
+ let content = fs.readFileSync(archDoc, 'utf8').replace(/\r\n/g, '\n');
147
+ if (!i18nEnabled) {
148
+ content = content
149
+ .replace(/^\- `I18N_ENABLED`.*\r?\n/gm, '')
150
+ .replace(/^\- `I18N_DEFAULT_LANG`.*\r?\n/gm, '')
151
+ .replace(/^\- `I18N_FALLBACK_LANG`.*\r?\n/gm, '')
152
+ .replace(/^\- `resources\/\*`.*\r?\n/gm, '');
153
+ }
154
+ if (!dockerEnabled) {
155
+ content = content.replace(/^\- `infra\/\*`.*\r?\n/gm, '');
156
+ content = content.replace(
157
+ /## Future DB Presets \(Not Implemented Yet\)[\s\S]*?(?=\n## |$)/,
158
+ `## Future DB Presets (Not Implemented Yet)
159
+
160
+ A future preset can switch DB by:
161
+ 1. Replacing \`PrismaModule\` with another DB module package (for example Mongo package).
162
+ 2. Updating \`DATABASE_URL\` and related env keys.
163
+ 3. Keeping app-level services dependent only on repository/data-access abstractions.
164
+ `,
165
+ );
166
+ }
167
+ content = content.replace(/\n{3,}/g, '\n\n');
168
+ fs.writeFileSync(archDoc, content.trimEnd() + '\n', 'utf8');
169
+ }
170
+ }
171
+
172
+ function applyProxyPreset(targetRoot, proxy) {
173
+ const dockerDir = path.join(targetRoot, 'infra', 'docker');
174
+ const composeTarget = path.join(dockerDir, 'compose.yml');
175
+ const composeSource = path.join(dockerDir, `compose.${proxy}.yml`);
176
+
177
+ if (!fs.existsSync(composeSource)) {
178
+ throw new Error(`Missing proxy compose preset: ${composeSource}`);
179
+ }
180
+
181
+ fs.copyFileSync(composeSource, composeTarget);
182
+
183
+ removeIfExists(path.join(dockerDir, 'compose.nginx.yml'));
184
+ removeIfExists(path.join(dockerDir, 'compose.caddy.yml'));
185
+
186
+ if (proxy === 'nginx') {
187
+ removeIfExists(path.join(dockerDir, 'caddy.Dockerfile'));
188
+ removeIfExists(path.join(targetRoot, 'infra', 'caddy'));
189
+ } else if (proxy === 'caddy') {
190
+ removeIfExists(path.join(dockerDir, 'nginx.Dockerfile'));
191
+ removeIfExists(path.join(targetRoot, 'infra', 'nginx'));
192
+ }
193
+ }
194
+
195
+ function applyI18nDisabled(targetRoot) {
196
+ removeIfExists(path.join(targetRoot, 'packages', 'i18n'));
197
+ removeIfExists(path.join(targetRoot, 'resources', 'i18n'));
198
+
199
+ const apiPackagePath = path.join(targetRoot, 'apps', 'api', 'package.json');
200
+ if (fs.existsSync(apiPackagePath)) {
201
+ const apiPackage = JSON.parse(fs.readFileSync(apiPackagePath, 'utf8'));
202
+
203
+ if (apiPackage.scripts) {
204
+ delete apiPackage.scripts.predev;
205
+ }
206
+
207
+ if (apiPackage.dependencies) {
208
+ delete apiPackage.dependencies['@forgeon/i18n'];
209
+ delete apiPackage.dependencies['nestjs-i18n'];
210
+ }
211
+
212
+ writeJson(apiPackagePath, apiPackage);
213
+ }
214
+
215
+ const apiDockerfile = path.join(targetRoot, 'apps', 'api', 'Dockerfile');
216
+ if (fs.existsSync(apiDockerfile)) {
217
+ let content = fs.readFileSync(apiDockerfile, 'utf8');
218
+ content = content
219
+ .replace(/^COPY packages\/i18n\/package\.json packages\/i18n\/package\.json\r?\n/gm, '')
220
+ .replace(/^COPY packages\/i18n packages\/i18n\r?\n/gm, '')
221
+ .replace(/^RUN pnpm --filter @forgeon\/i18n build\r?\n/gm, '');
222
+ fs.writeFileSync(apiDockerfile, content, 'utf8');
223
+ }
224
+
225
+ const appModulePath = path.join(targetRoot, 'apps', 'api', 'src', 'app.module.ts');
226
+ fs.writeFileSync(
227
+ appModulePath,
228
+ `import { Module } from '@nestjs/common';
229
+ import { ConfigModule } from '@nestjs/config';
230
+ import appConfig from './config/app.config';
231
+ import { HealthController } from './health/health.controller';
232
+ import { PrismaModule } from './prisma/prisma.module';
233
+ import { AppExceptionFilter } from './common/filters/app-exception.filter';
234
+
235
+ @Module({
236
+ imports: [
237
+ ConfigModule.forRoot({
238
+ isGlobal: true,
239
+ load: [appConfig],
240
+ envFilePath: '.env',
241
+ }),
242
+ PrismaModule,
243
+ ],
244
+ controllers: [HealthController],
245
+ providers: [AppExceptionFilter],
246
+ })
247
+ export class AppModule {}
248
+ `,
249
+ 'utf8',
250
+ );
251
+
252
+ const healthControllerPath = path.join(
253
+ targetRoot,
254
+ 'apps',
255
+ 'api',
256
+ 'src',
257
+ 'health',
258
+ 'health.controller.ts',
259
+ );
260
+ fs.writeFileSync(
261
+ healthControllerPath,
262
+ `import { Controller, Get, Query } from '@nestjs/common';
263
+ import { EchoQueryDto } from '../common/dto/echo-query.dto';
264
+
265
+ @Controller('health')
266
+ export class HealthController {
267
+ @Get()
268
+ getHealth(@Query('lang') _lang?: string) {
269
+ return {
270
+ status: 'ok',
271
+ message: 'OK',
272
+ };
273
+ }
274
+
275
+ @Get('echo')
276
+ getEcho(@Query() query: EchoQueryDto) {
277
+ return { value: query.value };
278
+ }
279
+ }
280
+ `,
281
+ 'utf8',
282
+ );
283
+
284
+ const filterPath = path.join(
285
+ targetRoot,
286
+ 'apps',
287
+ 'api',
288
+ 'src',
289
+ 'common',
290
+ 'filters',
291
+ 'app-exception.filter.ts',
292
+ );
293
+ fs.writeFileSync(
294
+ filterPath,
295
+ `import {
296
+ ArgumentsHost,
297
+ Catch,
298
+ ExceptionFilter,
299
+ HttpException,
300
+ HttpStatus,
301
+ Injectable,
302
+ } from '@nestjs/common';
303
+ import { Response } from 'express';
304
+
305
+ @Injectable()
306
+ @Catch()
307
+ export class AppExceptionFilter implements ExceptionFilter {
308
+ catch(exception: unknown, host: ArgumentsHost): void {
309
+ const context = host.switchToHttp();
310
+ const response = context.getResponse<Response>();
311
+
312
+ const status =
313
+ exception instanceof HttpException
314
+ ? exception.getStatus()
315
+ : HttpStatus.INTERNAL_SERVER_ERROR;
316
+
317
+ const payload =
318
+ exception instanceof HttpException
319
+ ? exception.getResponse()
320
+ : { message: 'Internal server error' };
321
+
322
+ const message =
323
+ typeof payload === 'object' && payload !== null && 'message' in payload
324
+ ? Array.isArray((payload as { message?: unknown }).message)
325
+ ? String((payload as { message: unknown[] }).message[0] ?? 'Internal server error')
326
+ : String((payload as { message?: unknown }).message ?? 'Internal server error')
327
+ : typeof payload === 'string'
328
+ ? payload
329
+ : 'Internal server error';
330
+
331
+ response.status(status).json({
332
+ error: {
333
+ code: this.resolveCode(status),
334
+ message,
335
+ },
336
+ });
337
+ }
338
+
339
+ private resolveCode(status: number): string {
340
+ switch (status) {
341
+ case HttpStatus.BAD_REQUEST:
342
+ return 'validation_error';
343
+ case HttpStatus.UNAUTHORIZED:
344
+ return 'unauthorized';
345
+ case HttpStatus.FORBIDDEN:
346
+ return 'forbidden';
347
+ case HttpStatus.NOT_FOUND:
348
+ return 'not_found';
349
+ case HttpStatus.CONFLICT:
350
+ return 'conflict';
351
+ default:
352
+ return 'internal_error';
353
+ }
354
+ }
355
+ }
356
+ `,
357
+ 'utf8',
358
+ );
359
+
360
+ const appConfigPath = path.join(targetRoot, 'apps', 'api', 'src', 'config', 'app.config.ts');
361
+ fs.writeFileSync(
362
+ appConfigPath,
363
+ `import { registerAs } from '@nestjs/config';
364
+
365
+ export default registerAs('app', () => ({
366
+ port: Number(process.env.PORT ?? 3000),
367
+ }));
368
+ `,
369
+ 'utf8',
370
+ );
371
+ }
372
+
373
+ function patchDockerEnvForI18n(targetRoot, i18nEnabled) {
374
+ const dockerEnvPath = path.join(targetRoot, 'infra', 'docker', '.env.example');
375
+ if (fs.existsSync(dockerEnvPath) && !i18nEnabled) {
376
+ const content = fs
377
+ .readFileSync(dockerEnvPath, 'utf8')
378
+ .replace(/^I18N_ENABLED=.*\r?\n/gm, '')
379
+ .replace(/^I18N_DEFAULT_LANG=.*\r?\n/gm, '')
380
+ .replace(/^I18N_FALLBACK_LANG=.*\r?\n/gm, '');
381
+ fs.writeFileSync(dockerEnvPath, content.trimEnd() + '\n', 'utf8');
382
+ }
383
+
384
+ const composePath = path.join(targetRoot, 'infra', 'docker', 'compose.yml');
385
+ if (fs.existsSync(composePath) && !i18nEnabled) {
386
+ const content = fs
387
+ .readFileSync(composePath, 'utf8')
388
+ .replace(/^\s+I18N_ENABLED:.*\r?\n/gm, '')
389
+ .replace(/^\s+I18N_DEFAULT_LANG:.*\r?\n/gm, '')
390
+ .replace(/^\s+I18N_FALLBACK_LANG:.*\r?\n/gm, '');
391
+ fs.writeFileSync(composePath, content, 'utf8');
392
+ }
393
+ }
394
+
66
395
  async function main() {
67
396
  const args = process.argv.slice(2);
68
397
  const options = {
@@ -71,6 +400,7 @@ async function main() {
71
400
  db: undefined,
72
401
  i18n: undefined,
73
402
  docker: undefined,
403
+ proxy: undefined,
74
404
  install: false,
75
405
  yes: false,
76
406
  help: false,
@@ -81,9 +411,7 @@ async function main() {
81
411
  for (let i = 0; i < args.length; i += 1) {
82
412
  const arg = args[i];
83
413
 
84
- if (arg === '--') {
85
- continue;
86
- }
414
+ if (arg === '--') continue;
87
415
 
88
416
  if (arg === '-h' || arg === '--help') {
89
417
  options.help = true;
@@ -118,7 +446,7 @@ async function main() {
118
446
  i += 1;
119
447
  }
120
448
 
121
- if (key in options) {
449
+ if (Object.prototype.hasOwnProperty.call(options, key)) {
122
450
  options[key] = value;
123
451
  }
124
452
 
@@ -161,6 +489,12 @@ async function main() {
161
489
  (await rl.question('Include Docker/infra (true/false) [true]: ')) || 'true';
162
490
  }
163
491
 
492
+ const dockerEnabledPre = parseBoolean(options.docker, true);
493
+ if (dockerEnabledPre && !options.yes && !options.proxy) {
494
+ options.proxy =
495
+ (await rl.question('Reverse proxy preset (nginx/caddy) [nginx]: ')) || 'nginx';
496
+ }
497
+
164
498
  await rl.close();
165
499
 
166
500
  if (!options.name || options.name.trim().length === 0) {
@@ -168,22 +502,27 @@ async function main() {
168
502
  process.exit(1);
169
503
  }
170
504
 
171
- const frontendRaw = (options.frontend ?? 'react').toString().toLowerCase();
172
- const dbRaw = (options.db ?? 'prisma').toString().toLowerCase();
505
+ const frontend = (options.frontend ?? 'react').toString().toLowerCase();
506
+ const db = (options.db ?? 'prisma').toString().toLowerCase();
173
507
  const i18nEnabled = parseBoolean(options.i18n, true);
174
508
  const dockerEnabled = parseBoolean(options.docker, true);
509
+ const proxy = dockerEnabled
510
+ ? (options.proxy ?? 'nginx').toString().toLowerCase()
511
+ : 'none';
175
512
 
176
- const frontend = SUPPORTED_FRONTENDS.includes(frontendRaw) ? frontendRaw : 'react';
177
- if (frontendRaw !== frontend) {
178
- console.warn(`Unsupported frontend "${frontendRaw}". Falling back to "react".`);
513
+ if (!IMPLEMENTED_FRONTENDS.includes(frontend)) {
514
+ if (frontend === 'angular') {
515
+ throw new Error('Frontend preset "angular" is not implemented yet. Use --frontend react.');
516
+ }
517
+ throw new Error(`Unsupported frontend preset: ${frontend}`);
179
518
  }
180
- if (frontend === 'angular') {
181
- console.warn('Angular preset is planned, but not implemented yet. Falling back to React.');
519
+
520
+ if (!IMPLEMENTED_DBS.includes(db)) {
521
+ throw new Error(`Unsupported db preset: ${db}. Currently implemented: prisma.`);
182
522
  }
183
523
 
184
- const db = SUPPORTED_DBS.includes(dbRaw) ? dbRaw : 'prisma';
185
- if (dbRaw !== db) {
186
- console.warn(`Unsupported db preset "${dbRaw}". Falling back to "prisma".`);
524
+ if (dockerEnabled && !SUPPORTED_PROXIES.includes(proxy)) {
525
+ throw new Error(`Unsupported proxy preset: ${proxy}. Use nginx or caddy.`);
187
526
  }
188
527
 
189
528
  const projectName = options.name.trim();
@@ -215,28 +554,34 @@ async function main() {
215
554
  writeJson(rootPackageJsonPath, rootPackageJson);
216
555
 
217
556
  if (!dockerEnabled) {
218
- fs.rmSync(path.join(targetRoot, 'infra'), { recursive: true, force: true });
557
+ removeIfExists(path.join(targetRoot, 'infra'));
558
+ removeIfExists(path.join(targetRoot, 'apps', 'api', 'Dockerfile'));
559
+ removeIfExists(path.join(targetRoot, 'apps', 'web', 'Dockerfile'));
219
560
  } else {
220
- const envExamplePath = path.join(targetRoot, 'infra', 'docker', '.env.example');
221
- if (fs.existsSync(envExamplePath)) {
222
- const current = fs.readFileSync(envExamplePath, 'utf8');
223
- const next = current
224
- .replace(/I18N_ENABLED=.*/g, `I18N_ENABLED=${i18nEnabled}`)
225
- .replace(/I18N_DEFAULT_LANG=.*/g, 'I18N_DEFAULT_LANG=en')
226
- .replace(/I18N_FALLBACK_LANG=.*/g, 'I18N_FALLBACK_LANG=en');
227
- fs.writeFileSync(envExamplePath, next, 'utf8');
228
- }
561
+ applyProxyPreset(targetRoot, proxy);
562
+ patchDockerEnvForI18n(targetRoot, i18nEnabled);
563
+ }
564
+
565
+ if (!i18nEnabled) {
566
+ applyI18nDisabled(targetRoot);
229
567
  }
230
568
 
231
569
  const apiEnvExamplePath = path.join(targetRoot, 'apps', 'api', '.env.example');
232
- const apiEnv = [
570
+ const apiEnvLines = [
233
571
  'PORT=3000',
234
572
  'DATABASE_URL=postgresql://postgres:postgres@localhost:5432/app?schema=public',
235
- `I18N_ENABLED=${i18nEnabled}`,
236
- 'I18N_DEFAULT_LANG=en',
237
- 'I18N_FALLBACK_LANG=en',
238
- ].join('\n');
239
- fs.writeFileSync(apiEnvExamplePath, `${apiEnv}\n`, 'utf8');
573
+ ];
574
+
575
+ if (i18nEnabled) {
576
+ apiEnvLines.push('I18N_ENABLED=true');
577
+ apiEnvLines.push('I18N_DEFAULT_LANG=en');
578
+ apiEnvLines.push('I18N_FALLBACK_LANG=en');
579
+ }
580
+
581
+ fs.writeFileSync(apiEnvExamplePath, `${apiEnvLines.join('\n')}\n`, 'utf8');
582
+
583
+ patchReadme(targetRoot, { dockerEnabled, i18nEnabled });
584
+ patchAiDocs(targetRoot, { dockerEnabled, i18nEnabled });
240
585
 
241
586
  if (parseBoolean(options.install, false)) {
242
587
  const pnpmCmd = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm';
@@ -257,9 +602,10 @@ async function main() {
257
602
  console.log(`- db: ${db}`);
258
603
  console.log(`- i18n: ${i18nEnabled}`);
259
604
  console.log(`- docker: ${dockerEnabled}`);
605
+ console.log(`- proxy: ${proxy}`);
260
606
  }
261
607
 
262
608
  main().catch((error) => {
263
609
  console.error(error instanceof Error ? error.message : error);
264
610
  process.exit(1);
265
- });
611
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-forgeon",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "Forgeon project generator CLI",
5
5
  "license": "MIT",
6
6
  "author": "Forgeon",
@@ -0,0 +1,7 @@
1
+ **/node_modules
2
+ **/dist
3
+ .git
4
+ .gitignore
5
+ .DS_Store
6
+ pnpm-debug.log
7
+ *.log
@@ -44,13 +44,4 @@ API container starts with:
44
44
  2. `node apps/api/dist/main.js`
45
45
 
46
46
  This keeps container startup production-like while still simple.
47
-
48
- ## Generator Command
49
-
50
- Use:
51
- ```bash
52
- pnpm create:forgeon -- --name my-app --frontend react --db prisma --i18n true
53
- ```
54
-
55
- If flags are omitted, script asks questions interactively.
56
47
 
@@ -5,6 +5,7 @@ RUN corepack enable
5
5
 
6
6
  COPY package.json pnpm-workspace.yaml tsconfig.base.json ./
7
7
  COPY apps/api/package.json apps/api/package.json
8
+ COPY apps/api/prisma apps/api/prisma
8
9
  COPY packages/core/package.json packages/core/package.json
9
10
  COPY packages/i18n/package.json packages/i18n/package.json
10
11
 
@@ -10,7 +10,7 @@ A canonical fullstack monorepo scaffold intended to be reused as a project start
10
10
  - `apps/web` - frontend scaffold (default React + Vite + TS)
11
11
  - `packages/core` - shared backend core placeholder
12
12
  - `packages/i18n` - reusable nestjs-i18n integration package
13
- - `infra` - Docker Compose + Nginx
13
+ - `infra` - Docker Compose + reverse proxy preset (nginx/caddy)
14
14
  - `resources/i18n` - translation dictionaries
15
15
  - `docs` - documentation and AI workflow prompts
16
16
 
@@ -0,0 +1,11 @@
1
+ :80 {
2
+ encode gzip zstd
3
+
4
+ handle /api/* {
5
+ reverse_proxy api:3000
6
+ }
7
+
8
+ root * /srv
9
+ try_files {path} /index.html
10
+ file_server
11
+ }
@@ -0,0 +1,16 @@
1
+ FROM node:20-alpine AS web-builder
2
+
3
+ WORKDIR /app
4
+ RUN corepack enable
5
+
6
+ COPY package.json pnpm-workspace.yaml ./
7
+ COPY apps/web/package.json apps/web/package.json
8
+ RUN pnpm install --frozen-lockfile=false
9
+
10
+ COPY apps/web apps/web
11
+ WORKDIR /app/apps/web
12
+ RUN pnpm build
13
+
14
+ FROM caddy:2-alpine
15
+ COPY infra/caddy/Caddyfile /etc/caddy/Caddyfile
16
+ COPY --from=web-builder /app/apps/web/dist /srv
@@ -0,0 +1,45 @@
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
24
+ environment:
25
+ PORT: ${PORT}
26
+ DATABASE_URL: ${DATABASE_URL}
27
+ I18N_ENABLED: ${I18N_ENABLED}
28
+ I18N_DEFAULT_LANG: ${I18N_DEFAULT_LANG}
29
+ I18N_FALLBACK_LANG: ${I18N_FALLBACK_LANG}
30
+ depends_on:
31
+ db:
32
+ condition: service_healthy
33
+
34
+ caddy:
35
+ build:
36
+ context: ../..
37
+ dockerfile: infra/docker/caddy.Dockerfile
38
+ restart: unless-stopped
39
+ depends_on:
40
+ - api
41
+ ports:
42
+ - "8080:80"
43
+
44
+ volumes:
45
+ db_data:
@@ -0,0 +1,45 @@
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
24
+ environment:
25
+ PORT: ${PORT}
26
+ DATABASE_URL: ${DATABASE_URL}
27
+ I18N_ENABLED: ${I18N_ENABLED}
28
+ I18N_DEFAULT_LANG: ${I18N_DEFAULT_LANG}
29
+ 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: