create-forgeon 0.1.2 → 0.1.6

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 (143) hide show
  1. package/README.md +19 -17
  2. package/bin/create-forgeon.mjs +22 -22
  3. package/package.json +1 -1
  4. package/src/cli/add-help.mjs +12 -12
  5. package/src/cli/add-options.mjs +54 -54
  6. package/src/cli/add-options.test.mjs +24 -24
  7. package/src/cli/help.mjs +20 -20
  8. package/src/cli/options.mjs +121 -121
  9. package/src/cli/options.test.mjs +41 -41
  10. package/src/cli/prompt-select.mjs +94 -94
  11. package/src/cli/prompt-select.test.mjs +148 -148
  12. package/src/constants.mjs +13 -13
  13. package/src/core/docs.mjs +128 -128
  14. package/src/core/docs.test.mjs +91 -91
  15. package/src/core/install.mjs +14 -14
  16. package/src/core/scaffold.mjs +48 -45
  17. package/src/core/validate.mjs +12 -12
  18. package/src/core/validate.test.mjs +73 -73
  19. package/src/databases/index.mjs +26 -26
  20. package/src/frameworks/index.mjs +32 -32
  21. package/src/infrastructure/proxy.mjs +12 -12
  22. package/src/modules/docs.mjs +70 -70
  23. package/src/modules/executor.mjs +39 -21
  24. package/src/modules/executor.test.mjs +95 -45
  25. package/src/modules/i18n.mjs +283 -0
  26. package/src/modules/registry.mjs +43 -35
  27. package/src/presets/i18n.mjs +228 -180
  28. package/src/presets/index.mjs +2 -2
  29. package/src/presets/proxy.mjs +32 -32
  30. package/src/run-add-module.mjs +47 -47
  31. package/src/run-create-forgeon.mjs +72 -72
  32. package/src/utils/fs.mjs +26 -26
  33. package/src/utils/values.mjs +20 -20
  34. package/templates/base/.dockerignore +7 -7
  35. package/templates/base/.editorconfig +11 -11
  36. package/templates/base/README.md +46 -46
  37. package/templates/base/apps/api/Dockerfile +24 -24
  38. package/templates/base/apps/api/package.json +39 -39
  39. package/templates/base/apps/api/prisma/migrations/0001_init/migration.sql +11 -11
  40. package/templates/base/apps/api/prisma/schema.prisma +14 -14
  41. package/templates/base/apps/api/prisma/seed.ts +19 -19
  42. package/templates/base/apps/api/src/app.module.ts +32 -32
  43. package/templates/base/apps/api/src/common/dto/echo-query.dto.ts +5 -5
  44. package/templates/base/apps/api/src/common/filters/app-exception.filter.ts +129 -129
  45. package/templates/base/apps/api/src/config/app.config.ts +12 -12
  46. package/templates/base/apps/api/src/health/health.controller.ts +30 -30
  47. package/templates/base/apps/api/src/main.ts +25 -25
  48. package/templates/base/apps/api/src/prisma/prisma.module.ts +8 -8
  49. package/templates/base/apps/api/src/prisma/prisma.service.ts +26 -26
  50. package/templates/base/apps/api/tsconfig.build.json +8 -8
  51. package/templates/base/apps/api/tsconfig.json +8 -8
  52. package/templates/base/apps/web/Dockerfile +12 -12
  53. package/templates/base/apps/web/index.html +11 -11
  54. package/templates/base/apps/web/package.json +21 -21
  55. package/templates/base/apps/web/src/App.tsx +35 -35
  56. package/templates/base/apps/web/src/main.tsx +8 -8
  57. package/templates/base/apps/web/src/styles.css +32 -32
  58. package/templates/base/apps/web/tsconfig.json +17 -17
  59. package/templates/base/apps/web/vite.config.ts +14 -14
  60. package/templates/base/docs/AI/ARCHITECTURE.md +37 -37
  61. package/templates/base/docs/AI/MODULE_SPEC.md +56 -56
  62. package/templates/base/docs/AI/PROJECT.md +31 -31
  63. package/templates/base/docs/AI/TASKS.md +57 -57
  64. package/templates/base/docs/README.md +6 -6
  65. package/templates/base/infra/caddy/Caddyfile +15 -15
  66. package/templates/base/infra/docker/.env.example +9 -9
  67. package/templates/base/infra/docker/caddy.Dockerfile +15 -15
  68. package/templates/base/infra/docker/compose.caddy.yml +44 -44
  69. package/templates/base/infra/docker/compose.nginx.yml +44 -44
  70. package/templates/base/infra/docker/compose.none.yml +37 -37
  71. package/templates/base/infra/docker/compose.yml +44 -44
  72. package/templates/base/infra/docker/nginx.Dockerfile +15 -15
  73. package/templates/base/infra/nginx/nginx.conf +31 -31
  74. package/templates/base/package.json +23 -23
  75. package/templates/base/packages/core/README.md +2 -2
  76. package/templates/base/packages/core/package.json +13 -13
  77. package/templates/base/packages/core/tsconfig.json +7 -7
  78. package/templates/base/packages/i18n/package.json +18 -18
  79. package/templates/base/packages/i18n/src/forgeon-i18n.module.ts +45 -45
  80. package/templates/base/packages/i18n/tsconfig.json +8 -8
  81. package/templates/base/pnpm-workspace.yaml +2 -2
  82. package/templates/base/resources/i18n/en/common.json +4 -4
  83. package/templates/base/resources/i18n/en/validation.json +2 -2
  84. package/templates/base/resources/i18n/uk/common.json +4 -4
  85. package/templates/base/resources/i18n/uk/validation.json +2 -2
  86. package/templates/base/tsconfig.base.json +16 -16
  87. package/templates/docs-fragments/AI_ARCHITECTURE/00_title.md +1 -1
  88. package/templates/docs-fragments/AI_ARCHITECTURE/10_layout_base.md +6 -6
  89. package/templates/docs-fragments/AI_ARCHITECTURE/11_layout_infra.md +1 -1
  90. package/templates/docs-fragments/AI_ARCHITECTURE/12_layout_i18n_resources.md +1 -1
  91. package/templates/docs-fragments/AI_ARCHITECTURE/20_env_base.md +4 -4
  92. package/templates/docs-fragments/AI_ARCHITECTURE/21_env_i18n.md +3 -3
  93. package/templates/docs-fragments/AI_ARCHITECTURE/30_default_db.md +7 -7
  94. package/templates/docs-fragments/AI_ARCHITECTURE/31_docker_runtime.md +5 -5
  95. package/templates/docs-fragments/AI_ARCHITECTURE/32_scope_freeze.md +5 -5
  96. package/templates/docs-fragments/AI_ARCHITECTURE/40_docs_generation.md +9 -9
  97. package/templates/docs-fragments/AI_ARCHITECTURE/50_extension_points.md +8 -8
  98. package/templates/docs-fragments/AI_PROJECT/00_title.md +1 -1
  99. package/templates/docs-fragments/AI_PROJECT/10_what_is.md +3 -3
  100. package/templates/docs-fragments/AI_PROJECT/20_structure_base.md +5 -5
  101. package/templates/docs-fragments/AI_PROJECT/21_structure_i18n.md +2 -0
  102. package/templates/docs-fragments/AI_PROJECT/22_structure_docker.md +1 -1
  103. package/templates/docs-fragments/AI_PROJECT/23_structure_docs.md +1 -1
  104. package/templates/docs-fragments/AI_PROJECT/30_run_dev.md +8 -8
  105. package/templates/docs-fragments/AI_PROJECT/31_run_docker.md +5 -5
  106. package/templates/docs-fragments/AI_PROJECT/32_proxy_notes.md +5 -5
  107. package/templates/docs-fragments/AI_PROJECT/32_proxy_notes_none.md +5 -5
  108. package/templates/docs-fragments/AI_PROJECT/33_i18n_notes.md +2 -0
  109. package/templates/docs-fragments/AI_PROJECT/40_change_boundaries_base.md +3 -3
  110. package/templates/docs-fragments/AI_PROJECT/41_change_boundaries_docker.md +1 -1
  111. package/templates/docs-fragments/README/00_title.md +3 -3
  112. package/templates/docs-fragments/README/10_stack.md +8 -8
  113. package/templates/docs-fragments/README/20_quick_start_dev_intro.md +6 -6
  114. package/templates/docs-fragments/README/21_quick_start_dev_db_docker.md +4 -4
  115. package/templates/docs-fragments/README/21_quick_start_dev_db_local.md +1 -1
  116. package/templates/docs-fragments/README/22_quick_start_dev_outro.md +7 -7
  117. package/templates/docs-fragments/README/30_quick_start_docker.md +7 -7
  118. package/templates/docs-fragments/README/30_quick_start_docker_none.md +9 -9
  119. package/templates/docs-fragments/README/31_proxy_preset_caddy.md +9 -9
  120. package/templates/docs-fragments/README/31_proxy_preset_nginx.md +8 -8
  121. package/templates/docs-fragments/README/31_proxy_preset_none.md +6 -6
  122. package/templates/docs-fragments/README/32_prisma_container_start.md +5 -5
  123. package/templates/docs-fragments/README/40_i18n.md +14 -8
  124. package/templates/docs-fragments/README/90_next_steps.md +7 -7
  125. package/templates/module-fragments/i18n/00_title.md +5 -0
  126. package/templates/module-fragments/i18n/10_overview.md +9 -0
  127. package/templates/module-fragments/i18n/20_scope.md +7 -0
  128. package/templates/module-fragments/i18n/90_status_implemented.md +3 -0
  129. package/templates/module-fragments/jwt-auth/00_title.md +1 -1
  130. package/templates/module-fragments/jwt-auth/10_overview.md +6 -6
  131. package/templates/module-fragments/jwt-auth/20_scope.md +7 -7
  132. package/templates/module-fragments/jwt-auth/90_status_planned.md +3 -3
  133. package/templates/module-fragments/queue/00_title.md +1 -1
  134. package/templates/module-fragments/queue/10_overview.md +6 -6
  135. package/templates/module-fragments/queue/20_scope.md +7 -7
  136. package/templates/module-fragments/queue/90_status_planned.md +3 -3
  137. package/templates/module-presets/i18n/apps/web/src/App.tsx +61 -0
  138. package/templates/module-presets/i18n/packages/i18n-contracts/package.json +14 -0
  139. package/templates/module-presets/i18n/packages/i18n-contracts/src/index.ts +7 -0
  140. package/templates/module-presets/i18n/packages/i18n-contracts/tsconfig.json +8 -0
  141. package/templates/module-presets/i18n/packages/i18n-web/package.json +17 -0
  142. package/templates/module-presets/i18n/packages/i18n-web/src/index.ts +50 -0
  143. package/templates/module-presets/i18n/packages/i18n-web/tsconfig.json +8 -0
@@ -1,73 +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
- });
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
+ });
@@ -1,26 +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
- }
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
+ }
@@ -1,32 +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
- }
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
+ }
@@ -1,12 +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
- }
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
+ }
@@ -1,70 +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
- }
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
+ }
@@ -2,38 +2,56 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { ensureModuleExists } from './registry.mjs';
4
4
  import { writeModuleDocs } from './docs.mjs';
5
+ import { applyI18nModule } from './i18n.mjs';
6
+
7
+ function ensureForgeonLikeProject(targetRoot) {
8
+ const requiredPaths = [
9
+ path.join(targetRoot, 'package.json'),
10
+ path.join(targetRoot, 'pnpm-workspace.yaml'),
11
+ path.join(targetRoot, 'apps', 'api'),
12
+ ];
13
+
14
+ for (const requiredPath of requiredPaths) {
15
+ if (!fs.existsSync(requiredPath)) {
16
+ throw new Error(
17
+ `Target path does not look like a Forgeon project: missing ${path.relative(targetRoot, requiredPath)}`,
18
+ );
19
+ }
20
+ }
21
+ }
22
+
23
+ const MODULE_APPLIERS = {
24
+ i18n: applyI18nModule,
25
+ };
5
26
 
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
- }
27
+ export function applyModulePreset({ moduleId, targetRoot, packageRoot }) {
28
+ const applier = MODULE_APPLIERS[moduleId];
29
+ if (!applier) {
30
+ return false;
19
31
  }
32
+
33
+ applier({ targetRoot, packageRoot });
34
+ return true;
20
35
  }
21
36
 
22
- export function addModule({ moduleId, targetRoot, packageRoot }) {
37
+ export function addModule({ moduleId, targetRoot, packageRoot, writeDocs = true }) {
23
38
  ensureForgeonLikeProject(targetRoot);
24
39
 
25
40
  const preset = ensureModuleExists(moduleId);
26
- const docsPath = writeModuleDocs({
27
- packageRoot,
28
- targetRoot,
29
- preset,
30
- });
41
+ const applied = applyModulePreset({ moduleId, targetRoot, packageRoot });
42
+ const docsPath = writeDocs
43
+ ? writeModuleDocs({
44
+ packageRoot,
45
+ targetRoot,
46
+ preset,
47
+ })
48
+ : path.join(targetRoot, 'docs', 'AI', 'MODULES', `${preset.id}.md`);
31
49
 
32
50
  return {
33
51
  preset,
34
52
  docsPath,
35
- applied: preset.implemented,
36
- message: preset.implemented
53
+ applied: applied || preset.implemented,
54
+ message: applied
37
55
  ? `Module "${preset.id}" applied.`
38
56
  : `Module "${preset.id}" is planned; docs note created only.`,
39
57
  };
@@ -1,60 +1,110 @@
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';
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
5
  import path from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { addModule } from './executor.mjs';
8
+ import { scaffoldProject } from '../core/scaffold.mjs';
9
+
10
+ function mkTmp(prefix) {
11
+ return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
12
+ }
13
+
14
+ function createMinimalForgeonProject(targetRoot) {
15
+ fs.mkdirSync(path.join(targetRoot, 'apps', 'api'), { recursive: true });
16
+ fs.writeFileSync(path.join(targetRoot, 'package.json'), '{"name":"demo"}\n', 'utf8');
17
+ fs.writeFileSync(path.join(targetRoot, 'pnpm-workspace.yaml'), 'packages:\n - apps/*\n', 'utf8');
18
+ }
19
+
20
+ describe('addModule', () => {
21
+ const modulesDir = path.dirname(fileURLToPath(import.meta.url));
22
+ const packageRoot = path.resolve(modulesDir, '..', '..');
23
+
24
+ it('creates module docs note for planned module', () => {
25
+ const targetRoot = mkTmp('forgeon-module-');
26
+ try {
27
+ createMinimalForgeonProject(targetRoot);
28
+ const result = addModule({
29
+ moduleId: 'jwt-auth',
30
+ targetRoot,
31
+ packageRoot,
32
+ });
33
+
34
+ assert.equal(result.applied, false);
35
+ assert.match(result.message, /planned/);
36
+ assert.equal(fs.existsSync(result.docsPath), true);
37
+
38
+ const note = fs.readFileSync(result.docsPath, 'utf8');
39
+ assert.match(note, /JWT Auth/);
40
+ assert.match(note, /Status: planned/);
41
+ } finally {
42
+ fs.rmSync(targetRoot, { recursive: true, force: true });
43
+ }
44
+ });
45
+
46
+ it('throws for unknown module id', () => {
47
+ const targetRoot = mkTmp('forgeon-module-unknown-');
48
+ try {
49
+ createMinimalForgeonProject(targetRoot);
50
+ assert.throws(
51
+ () =>
52
+ addModule({
53
+ moduleId: 'unknown-module',
54
+ targetRoot,
55
+ packageRoot,
56
+ }),
57
+ /Unknown module/,
58
+ );
59
+ } finally {
60
+ fs.rmSync(targetRoot, { recursive: true, force: true });
61
+ }
62
+ });
8
63
 
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, '..', '..');
64
+ it('applies i18n module on top of scaffold without i18n', () => {
65
+ const targetRoot = mkTmp('forgeon-module-i18n-');
66
+ const projectRoot = path.join(targetRoot, 'demo-i18n');
67
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
22
68
 
23
- it('creates module docs note for planned module', () => {
24
- const targetRoot = mkTmp('forgeon-module-');
25
69
  try {
26
- createMinimalForgeonProject(targetRoot);
70
+ scaffoldProject({
71
+ templateRoot,
72
+ packageRoot,
73
+ targetRoot: projectRoot,
74
+ projectName: 'demo-i18n',
75
+ frontend: 'react',
76
+ db: 'prisma',
77
+ i18nEnabled: false,
78
+ proxy: 'caddy',
79
+ });
80
+
27
81
  const result = addModule({
28
- moduleId: 'jwt-auth',
29
- targetRoot,
82
+ moduleId: 'i18n',
83
+ targetRoot: projectRoot,
30
84
  packageRoot,
31
85
  });
32
86
 
33
- assert.equal(result.applied, false);
34
- assert.match(result.message, /planned/);
35
- assert.equal(fs.existsSync(result.docsPath), true);
87
+ assert.equal(result.applied, true);
88
+ assert.match(result.message, /applied/);
89
+ assert.equal(
90
+ fs.existsSync(path.join(projectRoot, 'packages', 'i18n-contracts', 'package.json')),
91
+ true,
92
+ );
93
+ assert.equal(
94
+ fs.existsSync(path.join(projectRoot, 'packages', 'i18n-web', 'package.json')),
95
+ true,
96
+ );
36
97
 
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
- });
98
+ const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
99
+ assert.match(apiPackage, /@forgeon\/i18n/);
100
+ assert.match(apiPackage, /@forgeon\/i18n-contracts/);
44
101
 
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
- );
102
+ const compose = fs.readFileSync(path.join(projectRoot, 'infra', 'docker', 'compose.yml'), 'utf8');
103
+ assert.match(compose, /I18N_ENABLED/);
104
+
105
+ const appTsx = fs.readFileSync(path.join(projectRoot, 'apps', 'web', 'src', 'App.tsx'), 'utf8');
106
+ assert.match(appTsx, /@forgeon\/i18n-web/);
107
+ assert.match(appTsx, /Language:/);
58
108
  } finally {
59
109
  fs.rmSync(targetRoot, { recursive: true, force: true });
60
110
  }