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
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { ensureDatabaseSupported } from '../databases/index.mjs';
|
|
2
|
+
import { ensureFrontendSupported } from '../frameworks/index.mjs';
|
|
3
|
+
import { ensureProxySupported } from '../infrastructure/proxy.mjs';
|
|
4
|
+
|
|
5
|
+
export function validatePresetSupport({ frontend, db, dockerEnabled, proxy }) {
|
|
6
|
+
ensureFrontendSupported(frontend);
|
|
7
|
+
ensureDatabaseSupported(db);
|
|
8
|
+
|
|
9
|
+
if (dockerEnabled) {
|
|
10
|
+
ensureProxySupported(proxy);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { validatePresetSupport } from './validate.mjs';
|
|
4
|
+
|
|
5
|
+
describe('validatePresetSupport', () => {
|
|
6
|
+
it('accepts current supported presets', () => {
|
|
7
|
+
assert.doesNotThrow(() =>
|
|
8
|
+
validatePresetSupport({
|
|
9
|
+
frontend: 'react',
|
|
10
|
+
db: 'prisma',
|
|
11
|
+
dockerEnabled: true,
|
|
12
|
+
proxy: 'caddy',
|
|
13
|
+
}),
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
assert.doesNotThrow(() =>
|
|
17
|
+
validatePresetSupport({
|
|
18
|
+
frontend: 'react',
|
|
19
|
+
db: 'prisma',
|
|
20
|
+
dockerEnabled: true,
|
|
21
|
+
proxy: 'none',
|
|
22
|
+
}),
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('throws for angular not-yet-implemented preset', () => {
|
|
27
|
+
assert.throws(
|
|
28
|
+
() =>
|
|
29
|
+
validatePresetSupport({
|
|
30
|
+
frontend: 'angular',
|
|
31
|
+
db: 'prisma',
|
|
32
|
+
dockerEnabled: false,
|
|
33
|
+
proxy: 'none',
|
|
34
|
+
}),
|
|
35
|
+
/Frontend preset "angular" is not implemented yet/,
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('throws for unsupported db preset', () => {
|
|
40
|
+
assert.throws(
|
|
41
|
+
() =>
|
|
42
|
+
validatePresetSupport({
|
|
43
|
+
frontend: 'react',
|
|
44
|
+
db: 'mongo',
|
|
45
|
+
dockerEnabled: false,
|
|
46
|
+
proxy: 'none',
|
|
47
|
+
}),
|
|
48
|
+
/Unsupported db preset: mongo/,
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('throws for unsupported proxy only when docker is enabled', () => {
|
|
53
|
+
assert.throws(
|
|
54
|
+
() =>
|
|
55
|
+
validatePresetSupport({
|
|
56
|
+
frontend: 'react',
|
|
57
|
+
db: 'prisma',
|
|
58
|
+
dockerEnabled: true,
|
|
59
|
+
proxy: 'traefik',
|
|
60
|
+
}),
|
|
61
|
+
/Unsupported proxy preset: traefik/,
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
assert.doesNotThrow(() =>
|
|
65
|
+
validatePresetSupport({
|
|
66
|
+
frontend: 'react',
|
|
67
|
+
db: 'prisma',
|
|
68
|
+
dockerEnabled: false,
|
|
69
|
+
proxy: 'traefik',
|
|
70
|
+
}),
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const DATABASE_PRESETS = {
|
|
2
|
+
prisma: {
|
|
3
|
+
id: 'prisma',
|
|
4
|
+
label: 'Prisma + PostgreSQL',
|
|
5
|
+
implemented: true,
|
|
6
|
+
},
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function getDatabasePreset(db) {
|
|
10
|
+
return DATABASE_PRESETS[db] ?? null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function getDatabaseLabel(db) {
|
|
14
|
+
return getDatabasePreset(db)?.label ?? db;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function ensureDatabaseSupported(db) {
|
|
18
|
+
const preset = getDatabasePreset(db);
|
|
19
|
+
if (!preset) {
|
|
20
|
+
throw new Error(`Unsupported db preset: ${db}. Currently implemented: prisma.`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!preset.implemented) {
|
|
24
|
+
throw new Error(`DB preset "${db}" is not implemented yet.`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const FRONTEND_PRESETS = {
|
|
2
|
+
react: {
|
|
3
|
+
id: 'react',
|
|
4
|
+
label: 'React + Vite + TypeScript',
|
|
5
|
+
implemented: true,
|
|
6
|
+
},
|
|
7
|
+
angular: {
|
|
8
|
+
id: 'angular',
|
|
9
|
+
label: 'Angular',
|
|
10
|
+
implemented: false,
|
|
11
|
+
message: 'Frontend preset "angular" is not implemented yet. Use --frontend react.',
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function getFrontendPreset(frontend) {
|
|
16
|
+
return FRONTEND_PRESETS[frontend] ?? null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function getFrontendLabel(frontend) {
|
|
20
|
+
return getFrontendPreset(frontend)?.label ?? frontend;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function ensureFrontendSupported(frontend) {
|
|
24
|
+
const preset = getFrontendPreset(frontend);
|
|
25
|
+
if (!preset) {
|
|
26
|
+
throw new Error(`Unsupported frontend preset: ${frontend}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (!preset.implemented) {
|
|
30
|
+
throw new Error(preset.message ?? `Frontend preset "${frontend}" is not implemented yet.`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const SUPPORTED_PROXIES = ['caddy', 'nginx', 'none'];
|
|
2
|
+
|
|
3
|
+
export function ensureProxySupported(proxy) {
|
|
4
|
+
if (!SUPPORTED_PROXIES.includes(proxy)) {
|
|
5
|
+
throw new Error(`Unsupported proxy preset: ${proxy}. Use caddy, nginx, or none.`);
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function getProxyConfigPath(proxy) {
|
|
10
|
+
if (proxy === 'none') return 'n/a';
|
|
11
|
+
return proxy === 'caddy' ? 'infra/caddy/Caddyfile' : 'infra/nginx/nginx.conf';
|
|
12
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
function renderTemplate(content, variables) {
|
|
5
|
+
return content.replace(/\{\{([A-Z0-9_]+)\}\}/g, (_, key) => String(variables[key] ?? ''));
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function readModuleFragment(packageRoot, moduleId, fragmentName, variables) {
|
|
9
|
+
const fragmentPath = path.join(
|
|
10
|
+
packageRoot,
|
|
11
|
+
'templates',
|
|
12
|
+
'module-fragments',
|
|
13
|
+
moduleId,
|
|
14
|
+
`${fragmentName}.md`,
|
|
15
|
+
);
|
|
16
|
+
if (!fs.existsSync(fragmentPath)) {
|
|
17
|
+
throw new Error(`Missing module docs fragment: ${fragmentPath}`);
|
|
18
|
+
}
|
|
19
|
+
const raw = fs.readFileSync(fragmentPath, 'utf8').replace(/\r\n/g, '\n').trim();
|
|
20
|
+
return renderTemplate(raw, variables).trim();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function ensureModuleIndex(targetRoot) {
|
|
24
|
+
const indexPath = path.join(targetRoot, 'docs', 'AI', 'MODULES', 'README.md');
|
|
25
|
+
if (!fs.existsSync(indexPath)) {
|
|
26
|
+
fs.mkdirSync(path.dirname(indexPath), { recursive: true });
|
|
27
|
+
fs.writeFileSync(
|
|
28
|
+
indexPath,
|
|
29
|
+
'# MODULES\n\nGenerated notes for module presets added via `create-forgeon add`.\n',
|
|
30
|
+
'utf8',
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
return indexPath;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function updateModuleIndex(indexPath, preset) {
|
|
37
|
+
const relativePath = `${preset.id}.md`;
|
|
38
|
+
const nextLine = `- \`${preset.id}\` - ${preset.label} (${preset.implemented ? 'implemented' : 'planned'})`;
|
|
39
|
+
const current = fs.readFileSync(indexPath, 'utf8').replace(/\r\n/g, '\n');
|
|
40
|
+
|
|
41
|
+
if (current.includes(`\`${preset.id}\``)) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const content = `${current.trimEnd()}\n${nextLine}\n`;
|
|
46
|
+
fs.writeFileSync(indexPath, content, 'utf8');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function writeModuleDocs({ packageRoot, targetRoot, preset }) {
|
|
50
|
+
const variables = {
|
|
51
|
+
MODULE_ID: preset.id,
|
|
52
|
+
MODULE_LABEL: preset.label,
|
|
53
|
+
MODULE_CATEGORY: preset.category,
|
|
54
|
+
MODULE_STATUS: preset.implemented ? 'implemented' : 'planned',
|
|
55
|
+
MODULE_DESCRIPTION: preset.description,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const sections = preset.docFragments
|
|
59
|
+
.map((fragmentName) => readModuleFragment(packageRoot, preset.id, fragmentName, variables))
|
|
60
|
+
.filter(Boolean);
|
|
61
|
+
|
|
62
|
+
const outputPath = path.join(targetRoot, 'docs', 'AI', 'MODULES', `${preset.id}.md`);
|
|
63
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
64
|
+
fs.writeFileSync(outputPath, `${sections.join('\n\n').trimEnd()}\n`, 'utf8');
|
|
65
|
+
|
|
66
|
+
const indexPath = ensureModuleIndex(targetRoot);
|
|
67
|
+
updateModuleIndex(indexPath, preset);
|
|
68
|
+
|
|
69
|
+
return outputPath;
|
|
70
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { ensureModuleExists } from './registry.mjs';
|
|
4
|
+
import { writeModuleDocs } from './docs.mjs';
|
|
5
|
+
|
|
6
|
+
function ensureForgeonLikeProject(targetRoot) {
|
|
7
|
+
const requiredPaths = [
|
|
8
|
+
path.join(targetRoot, 'package.json'),
|
|
9
|
+
path.join(targetRoot, 'pnpm-workspace.yaml'),
|
|
10
|
+
path.join(targetRoot, 'apps', 'api'),
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
for (const requiredPath of requiredPaths) {
|
|
14
|
+
if (!fs.existsSync(requiredPath)) {
|
|
15
|
+
throw new Error(
|
|
16
|
+
`Target path does not look like a Forgeon project: missing ${path.relative(targetRoot, requiredPath)}`,
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function addModule({ moduleId, targetRoot, packageRoot }) {
|
|
23
|
+
ensureForgeonLikeProject(targetRoot);
|
|
24
|
+
|
|
25
|
+
const preset = ensureModuleExists(moduleId);
|
|
26
|
+
const docsPath = writeModuleDocs({
|
|
27
|
+
packageRoot,
|
|
28
|
+
targetRoot,
|
|
29
|
+
preset,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
preset,
|
|
34
|
+
docsPath,
|
|
35
|
+
applied: preset.implemented,
|
|
36
|
+
message: preset.implemented
|
|
37
|
+
? `Module "${preset.id}" applied.`
|
|
38
|
+
: `Module "${preset.id}" is planned; docs note created only.`,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { addModule } from './executor.mjs';
|
|
8
|
+
|
|
9
|
+
function mkTmp(prefix) {
|
|
10
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function createMinimalForgeonProject(targetRoot) {
|
|
14
|
+
fs.mkdirSync(path.join(targetRoot, 'apps', 'api'), { recursive: true });
|
|
15
|
+
fs.writeFileSync(path.join(targetRoot, 'package.json'), '{"name":"demo"}\n', 'utf8');
|
|
16
|
+
fs.writeFileSync(path.join(targetRoot, 'pnpm-workspace.yaml'), 'packages:\n - apps/*\n', 'utf8');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('addModule', () => {
|
|
20
|
+
const modulesDir = path.dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const packageRoot = path.resolve(modulesDir, '..', '..');
|
|
22
|
+
|
|
23
|
+
it('creates module docs note for planned module', () => {
|
|
24
|
+
const targetRoot = mkTmp('forgeon-module-');
|
|
25
|
+
try {
|
|
26
|
+
createMinimalForgeonProject(targetRoot);
|
|
27
|
+
const result = addModule({
|
|
28
|
+
moduleId: 'jwt-auth',
|
|
29
|
+
targetRoot,
|
|
30
|
+
packageRoot,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
assert.equal(result.applied, false);
|
|
34
|
+
assert.match(result.message, /planned/);
|
|
35
|
+
assert.equal(fs.existsSync(result.docsPath), true);
|
|
36
|
+
|
|
37
|
+
const note = fs.readFileSync(result.docsPath, 'utf8');
|
|
38
|
+
assert.match(note, /JWT Auth/);
|
|
39
|
+
assert.match(note, /Status: planned/);
|
|
40
|
+
} finally {
|
|
41
|
+
fs.rmSync(targetRoot, { recursive: true, force: true });
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('throws for unknown module id', () => {
|
|
46
|
+
const targetRoot = mkTmp('forgeon-module-unknown-');
|
|
47
|
+
try {
|
|
48
|
+
createMinimalForgeonProject(targetRoot);
|
|
49
|
+
assert.throws(
|
|
50
|
+
() =>
|
|
51
|
+
addModule({
|
|
52
|
+
moduleId: 'unknown-module',
|
|
53
|
+
targetRoot,
|
|
54
|
+
packageRoot,
|
|
55
|
+
}),
|
|
56
|
+
/Unknown module/,
|
|
57
|
+
);
|
|
58
|
+
} finally {
|
|
59
|
+
fs.rmSync(targetRoot, { recursive: true, force: true });
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const MODULE_PRESETS = {
|
|
2
|
+
'jwt-auth': {
|
|
3
|
+
id: 'jwt-auth',
|
|
4
|
+
label: 'JWT Auth',
|
|
5
|
+
category: 'auth-security',
|
|
6
|
+
implemented: false,
|
|
7
|
+
description: 'JWT auth preset with guards and passport strategy wiring.',
|
|
8
|
+
docFragments: ['00_title', '10_overview', '20_scope', '90_status_planned'],
|
|
9
|
+
},
|
|
10
|
+
queue: {
|
|
11
|
+
id: 'queue',
|
|
12
|
+
label: 'Queue Worker',
|
|
13
|
+
category: 'background-jobs',
|
|
14
|
+
implemented: false,
|
|
15
|
+
description: 'Queue processing preset (BullMQ-style app wiring).',
|
|
16
|
+
docFragments: ['00_title', '10_overview', '20_scope', '90_status_planned'],
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function listModulePresets() {
|
|
21
|
+
return Object.values(MODULE_PRESETS);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getModulePreset(moduleId) {
|
|
25
|
+
return MODULE_PRESETS[moduleId] ?? null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function ensureModuleExists(moduleId) {
|
|
29
|
+
const preset = getModulePreset(moduleId);
|
|
30
|
+
if (!preset) {
|
|
31
|
+
const available = listModulePresets()
|
|
32
|
+
.map((item) => item.id)
|
|
33
|
+
.join(', ');
|
|
34
|
+
throw new Error(`Unknown module "${moduleId}". Available modules: ${available}`);
|
|
35
|
+
}
|
|
36
|
+
return preset;
|
|
37
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { removeIfExists, writeJson } from '../utils/fs.mjs';
|
|
4
|
+
|
|
5
|
+
export function applyI18nDisabled(targetRoot) {
|
|
6
|
+
removeIfExists(path.join(targetRoot, 'packages', 'i18n'));
|
|
7
|
+
removeIfExists(path.join(targetRoot, 'resources', 'i18n'));
|
|
8
|
+
|
|
9
|
+
const apiPackagePath = path.join(targetRoot, 'apps', 'api', 'package.json');
|
|
10
|
+
if (fs.existsSync(apiPackagePath)) {
|
|
11
|
+
const apiPackage = JSON.parse(fs.readFileSync(apiPackagePath, 'utf8'));
|
|
12
|
+
|
|
13
|
+
if (apiPackage.scripts) {
|
|
14
|
+
delete apiPackage.scripts.predev;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (apiPackage.dependencies) {
|
|
18
|
+
delete apiPackage.dependencies['@forgeon/i18n'];
|
|
19
|
+
delete apiPackage.dependencies['nestjs-i18n'];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
writeJson(apiPackagePath, apiPackage);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const apiDockerfile = path.join(targetRoot, 'apps', 'api', 'Dockerfile');
|
|
26
|
+
if (fs.existsSync(apiDockerfile)) {
|
|
27
|
+
let content = fs.readFileSync(apiDockerfile, 'utf8');
|
|
28
|
+
content = content
|
|
29
|
+
.replace(/^COPY packages\/i18n\/package\.json packages\/i18n\/package\.json\r?\n/gm, '')
|
|
30
|
+
.replace(/^COPY packages\/i18n packages\/i18n\r?\n/gm, '')
|
|
31
|
+
.replace(/^RUN pnpm --filter @forgeon\/i18n build\r?\n/gm, '');
|
|
32
|
+
fs.writeFileSync(apiDockerfile, content, 'utf8');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const appModulePath = path.join(targetRoot, 'apps', 'api', 'src', 'app.module.ts');
|
|
36
|
+
fs.writeFileSync(
|
|
37
|
+
appModulePath,
|
|
38
|
+
`import { Module } from '@nestjs/common';
|
|
39
|
+
import { ConfigModule } from '@nestjs/config';
|
|
40
|
+
import appConfig from './config/app.config';
|
|
41
|
+
import { HealthController } from './health/health.controller';
|
|
42
|
+
import { PrismaModule } from './prisma/prisma.module';
|
|
43
|
+
import { AppExceptionFilter } from './common/filters/app-exception.filter';
|
|
44
|
+
|
|
45
|
+
@Module({
|
|
46
|
+
imports: [
|
|
47
|
+
ConfigModule.forRoot({
|
|
48
|
+
isGlobal: true,
|
|
49
|
+
load: [appConfig],
|
|
50
|
+
envFilePath: '.env',
|
|
51
|
+
}),
|
|
52
|
+
PrismaModule,
|
|
53
|
+
],
|
|
54
|
+
controllers: [HealthController],
|
|
55
|
+
providers: [AppExceptionFilter],
|
|
56
|
+
})
|
|
57
|
+
export class AppModule {}
|
|
58
|
+
`,
|
|
59
|
+
'utf8',
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const healthControllerPath = path.join(
|
|
63
|
+
targetRoot,
|
|
64
|
+
'apps',
|
|
65
|
+
'api',
|
|
66
|
+
'src',
|
|
67
|
+
'health',
|
|
68
|
+
'health.controller.ts',
|
|
69
|
+
);
|
|
70
|
+
fs.writeFileSync(
|
|
71
|
+
healthControllerPath,
|
|
72
|
+
`import { Controller, Get, Query } from '@nestjs/common';
|
|
73
|
+
import { EchoQueryDto } from '../common/dto/echo-query.dto';
|
|
74
|
+
|
|
75
|
+
@Controller('health')
|
|
76
|
+
export class HealthController {
|
|
77
|
+
@Get()
|
|
78
|
+
getHealth(@Query('lang') _lang?: string) {
|
|
79
|
+
return {
|
|
80
|
+
status: 'ok',
|
|
81
|
+
message: 'OK',
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
@Get('echo')
|
|
86
|
+
getEcho(@Query() query: EchoQueryDto) {
|
|
87
|
+
return { value: query.value };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
`,
|
|
91
|
+
'utf8',
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const filterPath = path.join(
|
|
95
|
+
targetRoot,
|
|
96
|
+
'apps',
|
|
97
|
+
'api',
|
|
98
|
+
'src',
|
|
99
|
+
'common',
|
|
100
|
+
'filters',
|
|
101
|
+
'app-exception.filter.ts',
|
|
102
|
+
);
|
|
103
|
+
fs.writeFileSync(
|
|
104
|
+
filterPath,
|
|
105
|
+
`import {
|
|
106
|
+
ArgumentsHost,
|
|
107
|
+
Catch,
|
|
108
|
+
ExceptionFilter,
|
|
109
|
+
HttpException,
|
|
110
|
+
HttpStatus,
|
|
111
|
+
Injectable,
|
|
112
|
+
} from '@nestjs/common';
|
|
113
|
+
import { Response } from 'express';
|
|
114
|
+
|
|
115
|
+
@Injectable()
|
|
116
|
+
@Catch()
|
|
117
|
+
export class AppExceptionFilter implements ExceptionFilter {
|
|
118
|
+
catch(exception: unknown, host: ArgumentsHost): void {
|
|
119
|
+
const context = host.switchToHttp();
|
|
120
|
+
const response = context.getResponse<Response>();
|
|
121
|
+
|
|
122
|
+
const status =
|
|
123
|
+
exception instanceof HttpException
|
|
124
|
+
? exception.getStatus()
|
|
125
|
+
: HttpStatus.INTERNAL_SERVER_ERROR;
|
|
126
|
+
|
|
127
|
+
const payload =
|
|
128
|
+
exception instanceof HttpException
|
|
129
|
+
? exception.getResponse()
|
|
130
|
+
: { message: 'Internal server error' };
|
|
131
|
+
|
|
132
|
+
const message =
|
|
133
|
+
typeof payload === 'object' && payload !== null && 'message' in payload
|
|
134
|
+
? Array.isArray((payload as { message?: unknown }).message)
|
|
135
|
+
? String((payload as { message: unknown[] }).message[0] ?? 'Internal server error')
|
|
136
|
+
: String((payload as { message?: unknown }).message ?? 'Internal server error')
|
|
137
|
+
: typeof payload === 'string'
|
|
138
|
+
? payload
|
|
139
|
+
: 'Internal server error';
|
|
140
|
+
|
|
141
|
+
response.status(status).json({
|
|
142
|
+
error: {
|
|
143
|
+
code: this.resolveCode(status),
|
|
144
|
+
message,
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private resolveCode(status: number): string {
|
|
150
|
+
switch (status) {
|
|
151
|
+
case HttpStatus.BAD_REQUEST:
|
|
152
|
+
return 'validation_error';
|
|
153
|
+
case HttpStatus.UNAUTHORIZED:
|
|
154
|
+
return 'unauthorized';
|
|
155
|
+
case HttpStatus.FORBIDDEN:
|
|
156
|
+
return 'forbidden';
|
|
157
|
+
case HttpStatus.NOT_FOUND:
|
|
158
|
+
return 'not_found';
|
|
159
|
+
case HttpStatus.CONFLICT:
|
|
160
|
+
return 'conflict';
|
|
161
|
+
default:
|
|
162
|
+
return 'internal_error';
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
`,
|
|
167
|
+
'utf8',
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
const appConfigPath = path.join(targetRoot, 'apps', 'api', 'src', 'config', 'app.config.ts');
|
|
171
|
+
fs.writeFileSync(
|
|
172
|
+
appConfigPath,
|
|
173
|
+
`import { registerAs } from '@nestjs/config';
|
|
174
|
+
|
|
175
|
+
export default registerAs('app', () => ({
|
|
176
|
+
port: Number(process.env.PORT ?? 3000),
|
|
177
|
+
}));
|
|
178
|
+
`,
|
|
179
|
+
'utf8',
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function patchDockerEnvForI18n(targetRoot, i18nEnabled) {
|
|
184
|
+
const dockerEnvPath = path.join(targetRoot, 'infra', 'docker', '.env.example');
|
|
185
|
+
if (fs.existsSync(dockerEnvPath) && !i18nEnabled) {
|
|
186
|
+
const content = fs
|
|
187
|
+
.readFileSync(dockerEnvPath, 'utf8')
|
|
188
|
+
.replace(/^I18N_ENABLED=.*\r?\n/gm, '')
|
|
189
|
+
.replace(/^I18N_DEFAULT_LANG=.*\r?\n/gm, '')
|
|
190
|
+
.replace(/^I18N_FALLBACK_LANG=.*\r?\n/gm, '');
|
|
191
|
+
fs.writeFileSync(dockerEnvPath, content.trimEnd() + '\n', 'utf8');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const composePath = path.join(targetRoot, 'infra', 'docker', 'compose.yml');
|
|
195
|
+
if (fs.existsSync(composePath) && !i18nEnabled) {
|
|
196
|
+
const content = fs
|
|
197
|
+
.readFileSync(composePath, 'utf8')
|
|
198
|
+
.replace(/^\s+I18N_ENABLED:.*\r?\n/gm, '')
|
|
199
|
+
.replace(/^\s+I18N_DEFAULT_LANG:.*\r?\n/gm, '')
|
|
200
|
+
.replace(/^\s+I18N_FALLBACK_LANG:.*\r?\n/gm, '');
|
|
201
|
+
fs.writeFileSync(composePath, content, 'utf8');
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { removeIfExists } from '../utils/fs.mjs';
|
|
4
|
+
|
|
5
|
+
export function applyProxyPreset(targetRoot, proxy) {
|
|
6
|
+
const dockerDir = path.join(targetRoot, 'infra', 'docker');
|
|
7
|
+
const composeTarget = path.join(dockerDir, 'compose.yml');
|
|
8
|
+
const composeSource = path.join(dockerDir, `compose.${proxy}.yml`);
|
|
9
|
+
|
|
10
|
+
if (!fs.existsSync(composeSource)) {
|
|
11
|
+
throw new Error(`Missing proxy compose preset: ${composeSource}`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
fs.copyFileSync(composeSource, composeTarget);
|
|
15
|
+
|
|
16
|
+
removeIfExists(path.join(dockerDir, 'compose.nginx.yml'));
|
|
17
|
+
removeIfExists(path.join(dockerDir, 'compose.caddy.yml'));
|
|
18
|
+
removeIfExists(path.join(dockerDir, 'compose.none.yml'));
|
|
19
|
+
|
|
20
|
+
if (proxy === 'nginx') {
|
|
21
|
+
removeIfExists(path.join(dockerDir, 'caddy.Dockerfile'));
|
|
22
|
+
removeIfExists(path.join(targetRoot, 'infra', 'caddy'));
|
|
23
|
+
} else if (proxy === 'caddy') {
|
|
24
|
+
removeIfExists(path.join(dockerDir, 'nginx.Dockerfile'));
|
|
25
|
+
removeIfExists(path.join(targetRoot, 'infra', 'nginx'));
|
|
26
|
+
} else if (proxy === 'none') {
|
|
27
|
+
removeIfExists(path.join(dockerDir, 'nginx.Dockerfile'));
|
|
28
|
+
removeIfExists(path.join(dockerDir, 'caddy.Dockerfile'));
|
|
29
|
+
removeIfExists(path.join(targetRoot, 'infra', 'nginx'));
|
|
30
|
+
removeIfExists(path.join(targetRoot, 'infra', 'caddy'));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { printAddHelp } from './cli/add-help.mjs';
|
|
4
|
+
import { parseAddCliArgs } from './cli/add-options.mjs';
|
|
5
|
+
import { addModule } from './modules/executor.mjs';
|
|
6
|
+
import { listModulePresets } from './modules/registry.mjs';
|
|
7
|
+
|
|
8
|
+
function printModuleList() {
|
|
9
|
+
const modules = listModulePresets();
|
|
10
|
+
console.log('Available modules:');
|
|
11
|
+
for (const moduleItem of modules) {
|
|
12
|
+
const status = moduleItem.implemented ? 'implemented' : 'planned';
|
|
13
|
+
console.log(`- ${moduleItem.id} (${status}) - ${moduleItem.description}`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function runAddModule(argv = process.argv.slice(2)) {
|
|
18
|
+
const options = parseAddCliArgs(argv);
|
|
19
|
+
|
|
20
|
+
if (options.help) {
|
|
21
|
+
printAddHelp();
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (options.list) {
|
|
26
|
+
printModuleList();
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!options.moduleId) {
|
|
31
|
+
throw new Error('Module id is required. Use `create-forgeon add --list` to see available modules.');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const srcDir = path.dirname(fileURLToPath(import.meta.url));
|
|
35
|
+
const packageRoot = path.resolve(srcDir, '..');
|
|
36
|
+
const targetRoot = path.resolve(process.cwd(), options.project);
|
|
37
|
+
|
|
38
|
+
const result = addModule({
|
|
39
|
+
moduleId: options.moduleId,
|
|
40
|
+
targetRoot,
|
|
41
|
+
packageRoot,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
console.log(result.message);
|
|
45
|
+
console.log(`- module: ${result.preset.id}`);
|
|
46
|
+
console.log(`- docs: ${result.docsPath}`);
|
|
47
|
+
}
|