create-forgeon 0.0.4 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +10 -5
  2. package/bin/create-forgeon.mjs +9 -604
  3. package/package.json +6 -2
  4. package/src/cli/add-help.mjs +12 -0
  5. package/src/cli/add-options.mjs +54 -0
  6. package/src/cli/add-options.test.mjs +24 -0
  7. package/src/cli/help.mjs +20 -0
  8. package/src/cli/options.mjs +121 -0
  9. package/src/cli/options.test.mjs +41 -0
  10. package/src/cli/prompt-select.mjs +100 -0
  11. package/src/cli/prompt-select.test.mjs +93 -0
  12. package/src/constants.mjs +13 -0
  13. package/src/core/docs.mjs +128 -0
  14. package/src/core/docs.test.mjs +91 -0
  15. package/src/core/install.mjs +14 -0
  16. package/src/core/scaffold.mjs +57 -0
  17. package/src/core/validate.mjs +12 -0
  18. package/src/core/validate.test.mjs +73 -0
  19. package/src/databases/index.mjs +26 -0
  20. package/src/frameworks/index.mjs +32 -0
  21. package/src/infrastructure/proxy.mjs +12 -0
  22. package/src/modules/docs.mjs +70 -0
  23. package/src/modules/executor.mjs +40 -0
  24. package/src/modules/executor.test.mjs +62 -0
  25. package/src/modules/registry.mjs +37 -0
  26. package/src/presets/i18n.mjs +203 -0
  27. package/src/presets/index.mjs +2 -0
  28. package/src/presets/proxy.mjs +32 -0
  29. package/src/run-add-module.mjs +47 -0
  30. package/src/run-create-forgeon.mjs +72 -0
  31. package/src/utils/fs.mjs +26 -0
  32. package/src/utils/values.mjs +20 -0
  33. package/templates/base/docs/AI/MODULE_SPEC.md +56 -0
  34. package/templates/base/docs/AI/TASKS.md +17 -7
  35. package/templates/base/docs/README.md +2 -1
  36. package/templates/base/infra/docker/compose.none.yml +37 -0
  37. package/templates/docs-fragments/AI_ARCHITECTURE/00_title.md +1 -0
  38. package/templates/docs-fragments/AI_ARCHITECTURE/10_layout_base.md +6 -0
  39. package/templates/docs-fragments/AI_ARCHITECTURE/11_layout_infra.md +1 -0
  40. package/templates/docs-fragments/AI_ARCHITECTURE/12_layout_i18n_resources.md +1 -0
  41. package/templates/docs-fragments/AI_ARCHITECTURE/20_env_base.md +4 -0
  42. package/templates/docs-fragments/AI_ARCHITECTURE/21_env_i18n.md +3 -0
  43. package/templates/docs-fragments/AI_ARCHITECTURE/30_default_db.md +7 -0
  44. package/templates/docs-fragments/AI_ARCHITECTURE/31_docker_runtime.md +5 -0
  45. package/templates/docs-fragments/AI_ARCHITECTURE/32_scope_freeze.md +5 -0
  46. package/templates/docs-fragments/AI_ARCHITECTURE/40_docs_generation.md +9 -0
  47. package/templates/docs-fragments/AI_ARCHITECTURE/50_extension_points.md +8 -0
  48. package/templates/docs-fragments/AI_PROJECT/00_title.md +1 -0
  49. package/templates/docs-fragments/AI_PROJECT/10_what_is.md +3 -0
  50. package/templates/docs-fragments/AI_PROJECT/20_structure_base.md +5 -0
  51. package/templates/docs-fragments/AI_PROJECT/21_structure_i18n.md +2 -0
  52. package/templates/docs-fragments/AI_PROJECT/22_structure_docker.md +1 -0
  53. package/templates/docs-fragments/AI_PROJECT/23_structure_docs.md +1 -0
  54. package/templates/docs-fragments/AI_PROJECT/30_run_dev.md +8 -0
  55. package/templates/docs-fragments/AI_PROJECT/31_run_docker.md +5 -0
  56. package/templates/docs-fragments/AI_PROJECT/32_proxy_notes.md +5 -0
  57. package/templates/docs-fragments/AI_PROJECT/32_proxy_notes_none.md +5 -0
  58. package/templates/docs-fragments/AI_PROJECT/33_i18n_notes.md +4 -0
  59. package/templates/docs-fragments/AI_PROJECT/40_change_boundaries_base.md +3 -0
  60. package/templates/docs-fragments/AI_PROJECT/41_change_boundaries_docker.md +1 -0
  61. package/templates/docs-fragments/README/00_title.md +3 -0
  62. package/templates/docs-fragments/README/10_stack.md +8 -0
  63. package/templates/docs-fragments/README/20_quick_start_dev_intro.md +6 -0
  64. package/templates/docs-fragments/README/21_quick_start_dev_db_docker.md +4 -0
  65. package/templates/docs-fragments/README/21_quick_start_dev_db_local.md +1 -0
  66. package/templates/docs-fragments/README/22_quick_start_dev_outro.md +7 -0
  67. package/templates/docs-fragments/README/30_quick_start_docker.md +7 -0
  68. package/templates/docs-fragments/README/30_quick_start_docker_none.md +9 -0
  69. package/templates/docs-fragments/README/31_proxy_preset_caddy.md +9 -0
  70. package/templates/docs-fragments/README/31_proxy_preset_nginx.md +8 -0
  71. package/templates/docs-fragments/README/31_proxy_preset_none.md +6 -0
  72. package/templates/docs-fragments/README/32_prisma_container_start.md +5 -0
  73. package/templates/docs-fragments/README/40_i18n.md +10 -0
  74. package/templates/docs-fragments/README/90_next_steps.md +7 -0
  75. package/templates/module-fragments/jwt-auth/00_title.md +1 -0
  76. package/templates/module-fragments/jwt-auth/10_overview.md +6 -0
  77. package/templates/module-fragments/jwt-auth/20_scope.md +7 -0
  78. package/templates/module-fragments/jwt-auth/90_status_planned.md +3 -0
  79. package/templates/module-fragments/queue/00_title.md +1 -0
  80. package/templates/module-fragments/queue/10_overview.md +6 -0
  81. package/templates/module-fragments/queue/20_scope.md +7 -0
  82. package/templates/module-fragments/queue/90_status_planned.md +3 -0
package/README.md CHANGED
@@ -5,14 +5,19 @@ 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 --proxy nginx
8
+ npx create-forgeon@latest my-app --i18n true --proxy caddy
9
9
  ```
10
10
 
11
11
  If flags are omitted, the CLI asks interactive questions.
12
+ Project name stays text input; fixed-choice prompts use arrow-key selection (`Up/Down + Enter`).
13
+
14
+ ```bash
15
+ npx create-forgeon@latest add --list
16
+ npx create-forgeon@latest add jwt-auth --project ./my-app
17
+ ```
12
18
 
13
19
  ## Notes
14
20
 
15
- - Implemented presets right now:
16
- - `--frontend react`
17
- - `--db prisma`
18
- - `--proxy` works only when `--docker true`
21
+ - Canonical stack is fixed: NestJS + React + Prisma/Postgres + Docker.
22
+ - Reverse proxy options: `caddy` (default), `nginx`, `none`.
23
+ - `add <module-id>` currently writes module docs notes under `docs/AI/MODULES/`
@@ -1,611 +1,16 @@
1
1
  #!/usr/bin/env node
2
- import fs from 'node:fs';
3
- import path from 'node:path';
4
- import readline from 'node:readline/promises';
5
- import { spawnSync } from 'node:child_process';
6
- import { fileURLToPath } from 'node:url';
7
- import { stdin as input, stdout as output } from 'node:process';
2
+ import { runCreateForgeon } from '../src/run-create-forgeon.mjs';
3
+ import { runAddModule } from '../src/run-add-module.mjs';
8
4
 
9
- const IMPLEMENTED_FRONTENDS = ['react'];
10
- const IMPLEMENTED_DBS = ['prisma'];
11
- const SUPPORTED_PROXIES = ['nginx', 'caddy'];
5
+ const args = process.argv.slice(2);
6
+ const command = args[0];
12
7
 
13
- function printHelp() {
14
- console.log(`create-forgeon
8
+ const task =
9
+ command === 'add'
10
+ ? runAddModule(args.slice(1))
11
+ : runCreateForgeon(args);
15
12
 
16
- Usage:
17
- npx create-forgeon@latest <project-name> [options]
18
-
19
- Options:
20
- --frontend <react|angular> Frontend preset (implemented: react)
21
- --db <prisma> DB preset (implemented: prisma)
22
- --i18n <true|false> Enable i18n (default: true)
23
- --docker <true|false> Include Docker/infra files (default: true)
24
- --proxy <nginx|caddy> Reverse proxy preset when docker=true (default: nginx)
25
- --install Run pnpm install after generation
26
- -y, --yes Skip prompts and use defaults
27
- -h, --help Show this help
28
- `);
29
- }
30
-
31
- function parseBoolean(value, fallback) {
32
- if (value === undefined) return fallback;
33
- if (typeof value === 'boolean') return value;
34
-
35
- const normalized = String(value).trim().toLowerCase();
36
- if (['true', '1', 'yes', 'y'].includes(normalized)) return true;
37
- if (['false', '0', 'no', 'n'].includes(normalized)) return false;
38
-
39
- throw new Error(`Invalid boolean value: ${value}`);
40
- }
41
-
42
- function toKebabCase(value) {
43
- return (
44
- value
45
- .trim()
46
- .toLowerCase()
47
- .replace(/[^a-z0-9]+/g, '-')
48
- .replace(/^-+|-+$/g, '') || 'forgeon-app'
49
- );
50
- }
51
-
52
- function copyRecursive(source, destination) {
53
- const stat = fs.statSync(source);
54
-
55
- if (stat.isDirectory()) {
56
- fs.mkdirSync(destination, { recursive: true });
57
- for (const entry of fs.readdirSync(source)) {
58
- copyRecursive(path.join(source, entry), path.join(destination, entry));
59
- }
60
- return;
61
- }
62
-
63
- fs.copyFileSync(source, destination);
64
- }
65
-
66
- function writeJson(filePath, data) {
67
- fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
68
- }
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
-
395
- async function main() {
396
- const args = process.argv.slice(2);
397
- const options = {
398
- name: undefined,
399
- frontend: undefined,
400
- db: undefined,
401
- i18n: undefined,
402
- docker: undefined,
403
- proxy: undefined,
404
- install: false,
405
- yes: false,
406
- help: false,
407
- };
408
-
409
- const positional = [];
410
-
411
- for (let i = 0; i < args.length; i += 1) {
412
- const arg = args[i];
413
-
414
- if (arg === '--') continue;
415
-
416
- if (arg === '-h' || arg === '--help') {
417
- options.help = true;
418
- continue;
419
- }
420
-
421
- if (arg === '-y' || arg === '--yes') {
422
- options.yes = true;
423
- continue;
424
- }
425
-
426
- if (arg === '--install') {
427
- options.install = true;
428
- continue;
429
- }
430
-
431
- if (arg.startsWith('--no-')) {
432
- const key = arg.slice(5);
433
- if (key === 'install') options.install = false;
434
- if (key === 'docker') options.docker = false;
435
- if (key === 'i18n') options.i18n = false;
436
- continue;
437
- }
438
-
439
- if (arg.startsWith('--')) {
440
- const [keyRaw, inlineValue] = arg.split('=');
441
- const key = keyRaw.slice(2);
442
-
443
- let value = inlineValue;
444
- if (value === undefined && args[i + 1] && !args[i + 1].startsWith('-')) {
445
- value = args[i + 1];
446
- i += 1;
447
- }
448
-
449
- if (Object.prototype.hasOwnProperty.call(options, key)) {
450
- options[key] = value;
451
- }
452
-
453
- continue;
454
- }
455
-
456
- positional.push(arg);
457
- }
458
-
459
- if (options.help) {
460
- printHelp();
461
- return;
462
- }
463
-
464
- if (!options.name && positional.length > 0) {
465
- options.name = positional[0];
466
- }
467
-
468
- const rl = readline.createInterface({ input, output });
469
-
470
- if (!options.name) {
471
- options.name = await rl.question('Project name: ');
472
- }
473
-
474
- if (!options.yes && !options.frontend) {
475
- options.frontend =
476
- (await rl.question('Frontend (react/angular) [react]: ')) || 'react';
477
- }
478
-
479
- if (!options.yes && !options.db) {
480
- options.db = (await rl.question('DB preset [prisma]: ')) || 'prisma';
481
- }
482
-
483
- if (!options.yes && options.i18n === undefined) {
484
- options.i18n = (await rl.question('Enable i18n (true/false) [true]: ')) || 'true';
485
- }
486
-
487
- if (!options.yes && options.docker === undefined) {
488
- options.docker =
489
- (await rl.question('Include Docker/infra (true/false) [true]: ')) || 'true';
490
- }
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
-
498
- await rl.close();
499
-
500
- if (!options.name || options.name.trim().length === 0) {
501
- console.error('Project name is required.');
502
- process.exit(1);
503
- }
504
-
505
- const frontend = (options.frontend ?? 'react').toString().toLowerCase();
506
- const db = (options.db ?? 'prisma').toString().toLowerCase();
507
- const i18nEnabled = parseBoolean(options.i18n, true);
508
- const dockerEnabled = parseBoolean(options.docker, true);
509
- const proxy = dockerEnabled
510
- ? (options.proxy ?? 'nginx').toString().toLowerCase()
511
- : 'none';
512
-
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}`);
518
- }
519
-
520
- if (!IMPLEMENTED_DBS.includes(db)) {
521
- throw new Error(`Unsupported db preset: ${db}. Currently implemented: prisma.`);
522
- }
523
-
524
- if (dockerEnabled && !SUPPORTED_PROXIES.includes(proxy)) {
525
- throw new Error(`Unsupported proxy preset: ${proxy}. Use nginx or caddy.`);
526
- }
527
-
528
- const projectName = options.name.trim();
529
- const targetRoot = path.resolve(process.cwd(), projectName);
530
-
531
- if (fs.existsSync(targetRoot)) {
532
- console.error(`Target directory already exists: ${targetRoot}`);
533
- process.exit(1);
534
- }
535
-
536
- const scriptDir = path.dirname(fileURLToPath(import.meta.url));
537
- const templateRoot = path.resolve(scriptDir, '..', 'templates', 'base');
538
-
539
- copyRecursive(templateRoot, targetRoot);
540
-
541
- const rootPackageJsonPath = path.join(targetRoot, 'package.json');
542
- const rootPackageJson = JSON.parse(fs.readFileSync(rootPackageJsonPath, 'utf8'));
543
- rootPackageJson.name = toKebabCase(projectName);
544
-
545
- if (rootPackageJson.scripts) {
546
- delete rootPackageJson.scripts['create:forgeon'];
547
-
548
- if (!dockerEnabled) {
549
- delete rootPackageJson.scripts['docker:up'];
550
- delete rootPackageJson.scripts['docker:down'];
551
- }
552
- }
553
-
554
- writeJson(rootPackageJsonPath, rootPackageJson);
555
-
556
- if (!dockerEnabled) {
557
- removeIfExists(path.join(targetRoot, 'infra'));
558
- removeIfExists(path.join(targetRoot, 'apps', 'api', 'Dockerfile'));
559
- removeIfExists(path.join(targetRoot, 'apps', 'web', 'Dockerfile'));
560
- } else {
561
- applyProxyPreset(targetRoot, proxy);
562
- patchDockerEnvForI18n(targetRoot, i18nEnabled);
563
- }
564
-
565
- if (!i18nEnabled) {
566
- applyI18nDisabled(targetRoot);
567
- }
568
-
569
- const apiEnvExamplePath = path.join(targetRoot, 'apps', 'api', '.env.example');
570
- const apiEnvLines = [
571
- 'PORT=3000',
572
- 'DATABASE_URL=postgresql://postgres:postgres@localhost:5432/app?schema=public',
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 });
585
-
586
- if (parseBoolean(options.install, false)) {
587
- const pnpmCmd = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm';
588
- const result = spawnSync(pnpmCmd, ['install'], {
589
- cwd: targetRoot,
590
- stdio: 'inherit',
591
- shell: false,
592
- });
593
-
594
- if (result.status !== 0) {
595
- process.exit(result.status ?? 1);
596
- }
597
- }
598
-
599
- console.log('Forgeon scaffold generated.');
600
- console.log(`- path: ${targetRoot}`);
601
- console.log(`- frontend: ${frontend}`);
602
- console.log(`- db: ${db}`);
603
- console.log(`- i18n: ${i18nEnabled}`);
604
- console.log(`- docker: ${dockerEnabled}`);
605
- console.log(`- proxy: ${proxy}`);
606
- }
607
-
608
- main().catch((error) => {
13
+ task.catch((error) => {
609
14
  console.error(error instanceof Error ? error.message : error);
610
15
  process.exit(1);
611
16
  });
package/package.json CHANGED
@@ -1,16 +1,20 @@
1
1
  {
2
2
  "name": "create-forgeon",
3
- "version": "0.0.4",
3
+ "version": "0.1.1",
4
4
  "description": "Forgeon project generator CLI",
5
5
  "license": "MIT",
6
6
  "author": "Forgeon",
7
7
  "type": "module",
8
+ "scripts": {
9
+ "test": "node --test src/cli/options.test.mjs src/cli/add-options.test.mjs src/cli/prompt-select.test.mjs src/core/docs.test.mjs src/core/validate.test.mjs src/modules/executor.test.mjs"
10
+ },
8
11
  "bin": {
9
12
  "create-forgeon": "bin/create-forgeon.mjs"
10
13
  },
11
14
  "files": [
12
15
  "bin",
16
+ "src",
13
17
  "templates",
14
18
  "README.md"
15
19
  ]
16
- }
20
+ }
@@ -0,0 +1,12 @@
1
+ export function printAddHelp() {
2
+ console.log(`create-forgeon add
3
+
4
+ Usage:
5
+ npx create-forgeon@latest add <module-id> [options]
6
+
7
+ Options:
8
+ --project <path> Target project path (default: current directory)
9
+ --list List available modules
10
+ -h, --help Show this help
11
+ `);
12
+ }