create-forgeon 0.0.3 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -5
- package/bin/create-forgeon.mjs +9 -604
- package/package.json +6 -2
- package/src/cli/add-help.mjs +12 -0
- package/src/cli/add-options.mjs +54 -0
- package/src/cli/add-options.test.mjs +24 -0
- package/src/cli/help.mjs +20 -0
- package/src/cli/options.mjs +121 -0
- package/src/cli/options.test.mjs +41 -0
- package/src/cli/prompt-select.mjs +89 -0
- package/src/cli/prompt-select.test.mjs +34 -0
- package/src/constants.mjs +13 -0
- package/src/core/docs.mjs +128 -0
- package/src/core/docs.test.mjs +91 -0
- package/src/core/install.mjs +14 -0
- package/src/core/scaffold.mjs +57 -0
- package/src/core/validate.mjs +12 -0
- package/src/core/validate.test.mjs +73 -0
- package/src/databases/index.mjs +26 -0
- package/src/frameworks/index.mjs +32 -0
- package/src/infrastructure/proxy.mjs +12 -0
- package/src/modules/docs.mjs +70 -0
- package/src/modules/executor.mjs +40 -0
- package/src/modules/executor.test.mjs +62 -0
- package/src/modules/registry.mjs +37 -0
- package/src/presets/i18n.mjs +203 -0
- package/src/presets/index.mjs +2 -0
- package/src/presets/proxy.mjs +32 -0
- package/src/run-add-module.mjs +47 -0
- package/src/run-create-forgeon.mjs +72 -0
- package/src/utils/fs.mjs +26 -0
- package/src/utils/values.mjs +20 -0
- package/templates/base/docs/AI/MODULE_SPEC.md +56 -0
- package/templates/base/docs/AI/TASKS.md +17 -7
- package/templates/base/docs/README.md +2 -1
- package/templates/base/infra/caddy/Caddyfile +11 -7
- package/templates/base/infra/docker/compose.none.yml +37 -0
- package/templates/docs-fragments/AI_ARCHITECTURE/00_title.md +1 -0
- package/templates/docs-fragments/AI_ARCHITECTURE/10_layout_base.md +6 -0
- package/templates/docs-fragments/AI_ARCHITECTURE/11_layout_infra.md +1 -0
- package/templates/docs-fragments/AI_ARCHITECTURE/12_layout_i18n_resources.md +1 -0
- package/templates/docs-fragments/AI_ARCHITECTURE/20_env_base.md +4 -0
- package/templates/docs-fragments/AI_ARCHITECTURE/21_env_i18n.md +3 -0
- package/templates/docs-fragments/AI_ARCHITECTURE/30_default_db.md +7 -0
- package/templates/docs-fragments/AI_ARCHITECTURE/31_docker_runtime.md +5 -0
- package/templates/docs-fragments/AI_ARCHITECTURE/32_scope_freeze.md +5 -0
- package/templates/docs-fragments/AI_ARCHITECTURE/40_docs_generation.md +9 -0
- package/templates/docs-fragments/AI_ARCHITECTURE/50_extension_points.md +8 -0
- package/templates/docs-fragments/AI_PROJECT/00_title.md +1 -0
- package/templates/docs-fragments/AI_PROJECT/10_what_is.md +3 -0
- package/templates/docs-fragments/AI_PROJECT/20_structure_base.md +5 -0
- package/templates/docs-fragments/AI_PROJECT/21_structure_i18n.md +2 -0
- package/templates/docs-fragments/AI_PROJECT/22_structure_docker.md +1 -0
- package/templates/docs-fragments/AI_PROJECT/23_structure_docs.md +1 -0
- package/templates/docs-fragments/AI_PROJECT/30_run_dev.md +8 -0
- package/templates/docs-fragments/AI_PROJECT/31_run_docker.md +5 -0
- package/templates/docs-fragments/AI_PROJECT/32_proxy_notes.md +5 -0
- package/templates/docs-fragments/AI_PROJECT/32_proxy_notes_none.md +5 -0
- package/templates/docs-fragments/AI_PROJECT/33_i18n_notes.md +4 -0
- package/templates/docs-fragments/AI_PROJECT/40_change_boundaries_base.md +3 -0
- package/templates/docs-fragments/AI_PROJECT/41_change_boundaries_docker.md +1 -0
- package/templates/docs-fragments/README/00_title.md +3 -0
- package/templates/docs-fragments/README/10_stack.md +8 -0
- package/templates/docs-fragments/README/20_quick_start_dev_intro.md +6 -0
- package/templates/docs-fragments/README/21_quick_start_dev_db_docker.md +4 -0
- package/templates/docs-fragments/README/21_quick_start_dev_db_local.md +1 -0
- package/templates/docs-fragments/README/22_quick_start_dev_outro.md +7 -0
- package/templates/docs-fragments/README/30_quick_start_docker.md +7 -0
- package/templates/docs-fragments/README/30_quick_start_docker_none.md +9 -0
- package/templates/docs-fragments/README/31_proxy_preset_caddy.md +9 -0
- package/templates/docs-fragments/README/31_proxy_preset_nginx.md +8 -0
- package/templates/docs-fragments/README/31_proxy_preset_none.md +6 -0
- package/templates/docs-fragments/README/32_prisma_container_start.md +5 -0
- package/templates/docs-fragments/README/40_i18n.md +10 -0
- package/templates/docs-fragments/README/90_next_steps.md +7 -0
- package/templates/module-fragments/jwt-auth/00_title.md +1 -0
- package/templates/module-fragments/jwt-auth/10_overview.md +6 -0
- package/templates/module-fragments/jwt-auth/20_scope.md +7 -0
- package/templates/module-fragments/jwt-auth/90_status_planned.md +3 -0
- package/templates/module-fragments/queue/00_title.md +1 -0
- package/templates/module-fragments/queue/10_overview.md +6 -0
- package/templates/module-fragments/queue/20_scope.md +7 -0
- 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 --
|
|
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
|
-
-
|
|
16
|
-
|
|
17
|
-
|
|
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/`
|
package/bin/create-forgeon.mjs
CHANGED
|
@@ -1,611 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import
|
|
3
|
-
import
|
|
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
|
|
10
|
-
const
|
|
11
|
-
const SUPPORTED_PROXIES = ['nginx', 'caddy'];
|
|
5
|
+
const args = process.argv.slice(2);
|
|
6
|
+
const command = args[0];
|
|
12
7
|
|
|
13
|
-
|
|
14
|
-
|
|
8
|
+
const task =
|
|
9
|
+
command === 'add'
|
|
10
|
+
? runAddModule(args.slice(1))
|
|
11
|
+
: runCreateForgeon(args);
|
|
15
12
|
|
|
16
|
-
|
|
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
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
}
|