create-forgeon 0.0.1 → 0.0.2
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 +9 -2
- package/bin/create-forgeon.mjs +387 -41
- package/package.json +1 -1
- package/templates/base/README.md +0 -9
- package/templates/base/docs/AI/PROJECT.md +1 -1
- package/templates/base/infra/caddy/Caddyfile +11 -0
- package/templates/base/infra/docker/caddy.Dockerfile +16 -0
- package/templates/base/infra/docker/compose.caddy.yml +45 -0
- package/templates/base/infra/docker/compose.nginx.yml +45 -0
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`
|
package/bin/create-forgeon.mjs
CHANGED
|
@@ -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
|
|
10
|
-
const
|
|
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 (
|
|
20
|
-
--db <prisma> DB preset (
|
|
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
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
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
|
|
172
|
-
const
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
181
|
-
|
|
519
|
+
|
|
520
|
+
if (!IMPLEMENTED_DBS.includes(db)) {
|
|
521
|
+
throw new Error(`Unsupported db preset: ${db}. Currently implemented: prisma.`);
|
|
182
522
|
}
|
|
183
523
|
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
|
570
|
+
const apiEnvLines = [
|
|
233
571
|
'PORT=3000',
|
|
234
572
|
'DATABASE_URL=postgresql://postgres:postgres@localhost:5432/app?schema=public',
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
package/templates/base/README.md
CHANGED
|
@@ -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
|
|
|
@@ -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 +
|
|
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,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:
|