create-forgeon 0.2.2 → 0.2.3

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.
@@ -1,14 +1,17 @@
1
1
  #!/usr/bin/env node
2
- import { runCreateForgeon } from '../src/run-create-forgeon.mjs';
3
- import { runAddModule } from '../src/run-add-module.mjs';
2
+ import { runCreateForgeon } from '../src/run-create-forgeon.mjs';
3
+ import { runAddModule } from '../src/run-add-module.mjs';
4
+ import { runScanIntegrations } from '../src/run-scan-integrations.mjs';
4
5
 
5
6
  const args = process.argv.slice(2);
6
7
  const command = args[0];
7
8
 
8
- const task =
9
- command === 'add'
10
- ? runAddModule(args.slice(1))
11
- : runCreateForgeon(args);
9
+ const task =
10
+ command === 'add'
11
+ ? runAddModule(args.slice(1))
12
+ : command === 'scan-integrations'
13
+ ? runScanIntegrations(args.slice(1))
14
+ : runCreateForgeon(args);
12
15
 
13
16
  task.then(() => {
14
17
  if (typeof process.stdin.pause === 'function') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-forgeon",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Forgeon project generator CLI",
5
5
  "license": "MIT",
6
6
  "author": "Forgeon",
@@ -4,9 +4,13 @@ export function printAddHelp() {
4
4
  Usage:
5
5
  npx create-forgeon@latest add <module-id> [options]
6
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
- }
7
+ Options:
8
+ --project <path> Target project path (default: current directory)
9
+ --list List available modules
10
+ -h, --help Show this help
11
+
12
+ Note:
13
+ Pair integrations are explicit.
14
+ Run "pnpm forgeon:sync-integrations" in the target project after add-module steps.
15
+ `);
16
+ }
package/src/cli/help.mjs CHANGED
@@ -1,10 +1,11 @@
1
1
  export function printHelp() {
2
2
  console.log(`create-forgeon
3
3
 
4
- Usage:
5
- npx create-forgeon@latest <project-name> [options]
6
- npx create-forgeon@latest add <module-id> [options]
7
- npx create-forgeon@latest add --list
4
+ Usage:
5
+ npx create-forgeon@latest <project-name> [options]
6
+ npx create-forgeon@latest add <module-id> [options]
7
+ npx create-forgeon@latest add --list
8
+ npx create-forgeon@latest scan-integrations [options]
8
9
 
9
10
  Create options:
10
11
  --db-prisma <true|false> Enable db-prisma module (default: true)
@@ -14,8 +15,11 @@ Create options:
14
15
  -y, --yes Skip prompts and use defaults
15
16
  -h, --help Show this help
16
17
 
17
- Add options:
18
- --project <path> Target project path (default: current directory)
19
- --list List available modules
20
- `);
21
- }
18
+ Add options:
19
+ --project <path> Target project path (default: current directory)
20
+ --list List available modules
21
+
22
+ Scan options:
23
+ --project <path> Target project path (default: current directory)
24
+ `);
25
+ }
@@ -0,0 +1,118 @@
1
+ import { promptSelect } from '../cli/prompt-select.mjs';
2
+ import { scanIntegrations, syncIntegrations } from '../modules/sync-integrations.mjs';
3
+
4
+ const ansi = {
5
+ reset: '\x1b[0m',
6
+ yellow: '\x1b[33m',
7
+ green: '\x1b[32m',
8
+ cyan: '\x1b[36m',
9
+ dim: '\x1b[2m',
10
+ };
11
+
12
+ function colorize(color, text) {
13
+ return `${ansi[color]}${text}${ansi.reset}`;
14
+ }
15
+
16
+ function printGroup(group) {
17
+ console.log(`\n${colorize('yellow', `▶ ${group.title}`)}`);
18
+ group.modules.forEach((moduleId, index) => {
19
+ const branch = index === group.modules.length - 1 ? '└─' : '├─';
20
+ console.log(` ${branch} ${colorize('cyan', moduleId)}`);
21
+ });
22
+ console.log(' This will:');
23
+ for (const line of group.description) {
24
+ console.log(` • ${line}`);
25
+ }
26
+ }
27
+
28
+ async function chooseGroupSelection(groups) {
29
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
30
+ return { kind: 'skip', ids: [] };
31
+ }
32
+
33
+ if (groups.length === 1) {
34
+ const picked = await promptSelect({
35
+ message: 'Apply now?',
36
+ defaultValue: 'skip',
37
+ choices: [
38
+ { label: 'No, skip for now', value: 'skip' },
39
+ { label: `Yes, apply "${groups[0].title}"`, value: groups[0].id },
40
+ ],
41
+ });
42
+ if (picked === 'skip') {
43
+ return { kind: 'skip', ids: [] };
44
+ }
45
+ return { kind: 'single', ids: [picked] };
46
+ }
47
+
48
+ const choices = groups.map((group) => ({
49
+ label: group.title,
50
+ value: group.id,
51
+ }));
52
+ choices.push({ label: 'Skip for now', value: '__skip' });
53
+ choices.push({ label: 'Sync all pending integrations', value: '__all' });
54
+
55
+ const picked = await promptSelect({
56
+ message: 'Select integration to apply now',
57
+ defaultValue: '__skip',
58
+ choices,
59
+ });
60
+
61
+ if (picked === '__skip') {
62
+ return { kind: 'skip', ids: [] };
63
+ }
64
+ if (picked === '__all') {
65
+ return { kind: 'all', ids: groups.map((group) => group.id) };
66
+ }
67
+ return { kind: 'single', ids: [picked] };
68
+ }
69
+
70
+ export async function runIntegrationFlow({
71
+ targetRoot,
72
+ packageRoot,
73
+ relatedModuleId = null,
74
+ scanMessage = 'Scanning for integrations...',
75
+ }) {
76
+ console.log(scanMessage);
77
+ const scan = scanIntegrations({ targetRoot, relatedModuleId });
78
+ if (scan.groups.length === 0) {
79
+ console.log('No integration groups found.');
80
+ return { scanned: true, applied: false, groups: [] };
81
+ }
82
+
83
+ const groupWord = scan.groups.length === 1 ? 'group' : 'groups';
84
+ console.log(colorize('yellow', `Found ${scan.groups.length} integration ${groupWord}:`));
85
+ for (const group of scan.groups) {
86
+ printGroup(group);
87
+ }
88
+
89
+ console.log(colorize('dim', 'Command: pnpm forgeon:sync-integrations'));
90
+ const selection = await chooseGroupSelection(scan.groups);
91
+ if (selection.kind === 'skip') {
92
+ console.log('Integration skipped.');
93
+ console.log('Run later with: pnpm forgeon:sync-integrations');
94
+ return { scanned: true, applied: false, groups: scan.groups };
95
+ }
96
+
97
+ const sync = syncIntegrations({ targetRoot, packageRoot, groupIds: selection.ids });
98
+ console.log('[forgeon:sync-integrations] done');
99
+ for (const item of sync.summary) {
100
+ if (item.result.applied) {
101
+ console.log(`- ${item.title}: applied`);
102
+ } else {
103
+ console.log(`- ${item.title}: skipped (${item.result.reason})`);
104
+ }
105
+ }
106
+ if (sync.changedFiles.length > 0) {
107
+ console.log('- changed files:');
108
+ for (const filePath of sync.changedFiles) {
109
+ console.log(` - ${filePath}`);
110
+ }
111
+ }
112
+ return { scanned: true, applied: true, groups: scan.groups, sync };
113
+ }
114
+
115
+ export function printModuleAdded(moduleId, docsPath) {
116
+ console.log(colorize('green', `✔ Module added: ${moduleId}`));
117
+ console.log(`- docs: ${docsPath}`);
118
+ }
@@ -1,6 +1,18 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { copyRecursive, writeJson } from '../utils/fs.mjs';
4
+ import {
5
+ ensureBuildSteps,
6
+ ensureDependency,
7
+ ensureDevDependency,
8
+ ensureLineAfter,
9
+ ensureLineBefore,
10
+ ensureLoadItem,
11
+ ensureNestCommonImport,
12
+ ensureScript,
13
+ ensureValidatorSchema,
14
+ upsertEnvLines,
15
+ } from './shared/patch-utils.mjs';
4
16
 
5
17
  function copyFromPreset(packageRoot, targetRoot, relativePath) {
6
18
  const source = path.join(packageRoot, 'templates', 'module-presets', 'db-prisma', relativePath);
@@ -11,167 +23,6 @@ function copyFromPreset(packageRoot, targetRoot, relativePath) {
11
23
  copyRecursive(source, destination);
12
24
  }
13
25
 
14
- function ensureDependency(packageJson, name, version) {
15
- if (!packageJson.dependencies) {
16
- packageJson.dependencies = {};
17
- }
18
- packageJson.dependencies[name] = version;
19
- }
20
-
21
- function ensureDevDependency(packageJson, name, version) {
22
- if (!packageJson.devDependencies) {
23
- packageJson.devDependencies = {};
24
- }
25
- packageJson.devDependencies[name] = version;
26
- }
27
-
28
- function ensureScript(packageJson, name, command) {
29
- if (!packageJson.scripts) {
30
- packageJson.scripts = {};
31
- }
32
- packageJson.scripts[name] = command;
33
- }
34
-
35
- function ensureBuildSteps(packageJson, scriptName, requiredCommands) {
36
- if (!packageJson.scripts) {
37
- packageJson.scripts = {};
38
- }
39
-
40
- const current = packageJson.scripts[scriptName];
41
- const steps =
42
- typeof current === 'string' && current.trim().length > 0
43
- ? current
44
- .split('&&')
45
- .map((item) => item.trim())
46
- .filter(Boolean)
47
- : [];
48
-
49
- for (const command of requiredCommands) {
50
- if (!steps.includes(command)) {
51
- steps.push(command);
52
- }
53
- }
54
-
55
- if (steps.length > 0) {
56
- packageJson.scripts[scriptName] = steps.join(' && ');
57
- }
58
- }
59
-
60
- function ensureLineAfter(content, anchorLine, lineToInsert) {
61
- if (content.includes(lineToInsert)) {
62
- return content;
63
- }
64
-
65
- const index = content.indexOf(anchorLine);
66
- if (index < 0) {
67
- return `${content.trimEnd()}\n${lineToInsert}\n`;
68
- }
69
-
70
- const insertAt = index + anchorLine.length;
71
- return `${content.slice(0, insertAt)}\n${lineToInsert}${content.slice(insertAt)}`;
72
- }
73
-
74
- function ensureLineBefore(content, anchorLine, lineToInsert) {
75
- if (content.includes(lineToInsert)) {
76
- return content;
77
- }
78
-
79
- const index = content.indexOf(anchorLine);
80
- if (index < 0) {
81
- return `${content.trimEnd()}\n${lineToInsert}\n`;
82
- }
83
-
84
- return `${content.slice(0, index)}${lineToInsert}\n${content.slice(index)}`;
85
- }
86
-
87
- function ensureNestCommonImport(content, importName) {
88
- const pattern = /import\s*\{([^}]*)\}\s*from '@nestjs\/common';/m;
89
- const match = content.match(pattern);
90
- if (!match) {
91
- return `import { ${importName} } from '@nestjs/common';\n${content}`;
92
- }
93
-
94
- const names = match[1]
95
- .split(',')
96
- .map((item) => item.trim())
97
- .filter(Boolean);
98
-
99
- if (!names.includes(importName)) {
100
- names.push(importName);
101
- }
102
-
103
- const replacement = `import { ${names.join(', ')} } from '@nestjs/common';`;
104
- return content.replace(pattern, replacement);
105
- }
106
-
107
- function upsertEnvLines(filePath, lines) {
108
- let content = '';
109
- if (fs.existsSync(filePath)) {
110
- content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
111
- }
112
-
113
- const keys = new Set(
114
- content
115
- .split('\n')
116
- .filter(Boolean)
117
- .map((line) => line.split('=')[0]),
118
- );
119
-
120
- const append = [];
121
- for (const line of lines) {
122
- const key = line.split('=')[0];
123
- if (!keys.has(key)) {
124
- append.push(line);
125
- }
126
- }
127
-
128
- const next =
129
- append.length > 0 ? `${content.trimEnd()}\n${append.join('\n')}\n` : `${content.trimEnd()}\n`;
130
- fs.writeFileSync(filePath, next.replace(/^\n/, ''), 'utf8');
131
- }
132
-
133
- function ensureLoadItem(content, itemName) {
134
- const pattern = /load:\s*\[([^\]]*)\]/m;
135
- const match = content.match(pattern);
136
- if (!match) {
137
- return content;
138
- }
139
-
140
- const rawList = match[1];
141
- const items = rawList
142
- .split(',')
143
- .map((item) => item.trim())
144
- .filter(Boolean);
145
-
146
- if (!items.includes(itemName)) {
147
- items.push(itemName);
148
- }
149
-
150
- const next = `load: [${items.join(', ')}]`;
151
- return content.replace(pattern, next);
152
- }
153
-
154
- function ensureValidatorSchema(content, schemaName) {
155
- const pattern = /validate:\s*createEnvValidator\(\[([^\]]*)\]\)/m;
156
- const match = content.match(pattern);
157
- if (!match) {
158
- return content;
159
- }
160
-
161
- const rawList = match[1];
162
- const items = rawList
163
- .split(',')
164
- .map((item) => item.trim())
165
- .filter(Boolean);
166
-
167
- if (!items.includes(schemaName)) {
168
- items.push(schemaName);
169
- }
170
-
171
- const next = `validate: createEnvValidator([${items.join(', ')}])`;
172
- return content.replace(pattern, next);
173
- }
174
-
175
26
  function patchApiPackage(targetRoot) {
176
27
  const packagePath = path.join(targetRoot, 'apps', 'api', 'package.json');
177
28
  if (!fs.existsSync(packagePath)) {
@@ -285,6 +136,16 @@ function patchHealthController(targetRoot) {
285
136
  private readonly prisma: PrismaService,
286
137
  ) {`;
287
138
  content = content.replace(original, next);
139
+ } else {
140
+ const classAnchor = 'export class HealthController {';
141
+ if (content.includes(classAnchor)) {
142
+ content = content.replace(
143
+ classAnchor,
144
+ `${classAnchor}
145
+ constructor(private readonly prisma: PrismaService) {}
146
+ `,
147
+ );
148
+ }
288
149
  }
289
150
  }
290
151
 
@@ -310,7 +171,12 @@ function patchHealthController(targetRoot) {
310
171
  if (translateIndex > -1) {
311
172
  content = `${content.slice(0, translateIndex).trimEnd()}\n\n${dbMethod}\n${content.slice(translateIndex)}`;
312
173
  } else {
313
- content = `${content.trimEnd()}\n${dbMethod}\n`;
174
+ const classEnd = content.lastIndexOf('\n}');
175
+ if (classEnd >= 0) {
176
+ content = `${content.slice(0, classEnd).trimEnd()}\n\n${dbMethod}\n${content.slice(classEnd)}`;
177
+ } else {
178
+ content = `${content.trimEnd()}\n${dbMethod}\n`;
179
+ }
314
180
  }
315
181
  }
316
182
 
@@ -5,6 +5,7 @@ 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 { syncIntegrations } from './sync-integrations.mjs';
8
9
  import { scaffoldProject } from '../core/scaffold.mjs';
9
10
 
10
11
  function mkTmp(prefix) {
@@ -400,7 +401,6 @@ describe('addModule', () => {
400
401
  assert.match(rootPackage, /"i18n:check"/);
401
402
  assert.match(rootPackage, /"i18n:types"/);
402
403
  assert.match(rootPackage, /"i18n:add"/);
403
- assert.match(rootPackage, /"ts-morph":/);
404
404
 
405
405
  const i18nAddScriptPath = path.join(projectRoot, 'scripts', 'i18n-add.mjs');
406
406
  assert.equal(fs.existsSync(i18nAddScriptPath), true);
@@ -893,7 +893,7 @@ describe('addModule', () => {
893
893
  }
894
894
  });
895
895
 
896
- it('applies jwt-auth with db-prisma adapter and wires persistent token store', () => {
896
+ it('applies jwt-auth with db-prisma as stateless first, then wires persistence via explicit sync', () => {
897
897
  const targetRoot = mkTmp('forgeon-module-jwt-db-');
898
898
  const projectRoot = path.join(targetRoot, 'demo-jwt-db');
899
899
  const templateRoot = path.join(packageRoot, 'templates', 'base');
@@ -918,6 +918,14 @@ describe('addModule', () => {
918
918
  });
919
919
 
920
920
  assert.equal(result.applied, true);
921
+ assertJwtAuthWiring(projectRoot, false);
922
+
923
+ const syncResult = syncIntegrations({ targetRoot: projectRoot, packageRoot });
924
+ const dbPair = syncResult.summary.find((item) => item.id === 'auth-persistence');
925
+ assert.ok(dbPair);
926
+ assert.equal(dbPair.result.applied, true);
927
+ assert.equal(syncResult.changedFiles.length > 0, true);
928
+
921
929
  assertJwtAuthWiring(projectRoot, true);
922
930
 
923
931
  const storeFile = path.join(
@@ -955,15 +963,11 @@ describe('addModule', () => {
955
963
  }
956
964
  });
957
965
 
958
- it('applies jwt-auth without db and prints warning with stateless fallback', () => {
966
+ it('applies jwt-auth without db and keeps stateless fallback until pair sync is available', () => {
959
967
  const targetRoot = mkTmp('forgeon-module-jwt-nodb-');
960
968
  const projectRoot = path.join(targetRoot, 'demo-jwt-nodb');
961
969
  const templateRoot = path.join(packageRoot, 'templates', 'base');
962
970
 
963
- const originalError = console.error;
964
- const warnings = [];
965
- console.error = (...args) => warnings.push(args.join(' '));
966
-
967
971
  try {
968
972
  scaffoldProject({
969
973
  templateRoot,
@@ -996,10 +1000,93 @@ describe('addModule', () => {
996
1000
  assert.match(readme, /refresh token persistence: disabled/);
997
1001
  assert.match(readme, /create-forgeon add db-prisma/);
998
1002
 
999
- assert.equal(warnings.length > 0, true);
1000
- assert.match(warnings.join('\n'), /jwt-auth installed without persistent refresh token store/);
1001
1003
  } finally {
1002
- console.error = originalError;
1004
+ fs.rmSync(targetRoot, { recursive: true, force: true });
1005
+ }
1006
+ });
1007
+
1008
+ it('applies logger then jwt-auth on db/i18n-disabled scaffold without breaking health controller syntax', () => {
1009
+ const targetRoot = mkTmp('forgeon-module-jwt-nodb-noi18n-');
1010
+ const projectRoot = path.join(targetRoot, 'demo-jwt-nodb-noi18n');
1011
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
1012
+
1013
+ try {
1014
+ scaffoldProject({
1015
+ templateRoot,
1016
+ packageRoot,
1017
+ targetRoot: projectRoot,
1018
+ projectName: 'demo-jwt-nodb-noi18n',
1019
+ frontend: 'react',
1020
+ db: 'prisma',
1021
+ dbPrismaEnabled: false,
1022
+ i18nEnabled: false,
1023
+ proxy: 'caddy',
1024
+ });
1025
+
1026
+ addModule({
1027
+ moduleId: 'logger',
1028
+ targetRoot: projectRoot,
1029
+ packageRoot,
1030
+ });
1031
+ addModule({
1032
+ moduleId: 'jwt-auth',
1033
+ targetRoot: projectRoot,
1034
+ packageRoot,
1035
+ });
1036
+
1037
+ const healthController = fs.readFileSync(
1038
+ path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'),
1039
+ 'utf8',
1040
+ );
1041
+ assert.match(healthController, /constructor\(private readonly authService: AuthService\)/);
1042
+ assert.match(healthController, /@Get\('auth'\)/);
1043
+ assert.match(healthController, /return this\.authService\.getProbeStatus\(\);/);
1044
+
1045
+ const classStart = healthController.indexOf('export class HealthController {');
1046
+ const classEnd = healthController.lastIndexOf('\n}');
1047
+ const authProbe = healthController.indexOf("@Get('auth')");
1048
+ assert.equal(classStart > -1, true);
1049
+ assert.equal(classEnd > classStart, true);
1050
+ assert.equal(authProbe > classStart && authProbe < classEnd, true);
1051
+ } finally {
1052
+ fs.rmSync(targetRoot, { recursive: true, force: true });
1053
+ }
1054
+ });
1055
+
1056
+ it('applies swagger then jwt-auth without forcing swagger dependency in auth-api', () => {
1057
+ const targetRoot = mkTmp('forgeon-module-jwt-swagger-');
1058
+ const projectRoot = path.join(targetRoot, 'demo-jwt-swagger');
1059
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
1060
+
1061
+ try {
1062
+ scaffoldProject({
1063
+ templateRoot,
1064
+ packageRoot,
1065
+ targetRoot: projectRoot,
1066
+ projectName: 'demo-jwt-swagger',
1067
+ frontend: 'react',
1068
+ db: 'prisma',
1069
+ dbPrismaEnabled: false,
1070
+ i18nEnabled: false,
1071
+ proxy: 'caddy',
1072
+ });
1073
+
1074
+ addModule({
1075
+ moduleId: 'swagger',
1076
+ targetRoot: projectRoot,
1077
+ packageRoot,
1078
+ });
1079
+ addModule({
1080
+ moduleId: 'jwt-auth',
1081
+ targetRoot: projectRoot,
1082
+ packageRoot,
1083
+ });
1084
+
1085
+ const authApiPackage = JSON.parse(
1086
+ fs.readFileSync(path.join(projectRoot, 'packages', 'auth-api', 'package.json'), 'utf8'),
1087
+ );
1088
+ assert.equal(Object.hasOwn(authApiPackage.dependencies ?? {}, '@nestjs/swagger'), false);
1089
+ } finally {
1003
1090
  fs.rmSync(targetRoot, { recursive: true, force: true });
1004
1091
  }
1005
1092
  });