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.
@@ -0,0 +1,269 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { copyRecursive } from '../utils/fs.mjs';
4
+
5
+ const PRISMA_AUTH_STORE_TEMPLATE = path.join(
6
+ 'templates',
7
+ 'module-presets',
8
+ 'jwt-auth',
9
+ 'apps',
10
+ 'api',
11
+ 'src',
12
+ 'auth',
13
+ 'prisma-auth-refresh-token.store.ts',
14
+ );
15
+
16
+ const PRISMA_AUTH_MIGRATION_TEMPLATE = path.join(
17
+ 'templates',
18
+ 'module-presets',
19
+ 'jwt-auth',
20
+ 'apps',
21
+ 'api',
22
+ 'prisma',
23
+ 'migrations',
24
+ '0002_auth_refresh_token_hash',
25
+ );
26
+
27
+ function ensureLineAfter(content, anchorLine, lineToInsert) {
28
+ if (content.includes(lineToInsert)) {
29
+ return content;
30
+ }
31
+ const index = content.indexOf(anchorLine);
32
+ if (index < 0) {
33
+ return `${content.trimEnd()}\n${lineToInsert}\n`;
34
+ }
35
+ const insertAt = index + anchorLine.length;
36
+ return `${content.slice(0, insertAt)}\n${lineToInsert}${content.slice(insertAt)}`;
37
+ }
38
+
39
+ function isAuthPersistencePending(rootDir) {
40
+ const appModulePath = path.join(rootDir, 'apps', 'api', 'src', 'app.module.ts');
41
+ const schemaPath = path.join(rootDir, 'apps', 'api', 'prisma', 'schema.prisma');
42
+ const storePath = path.join(rootDir, 'apps', 'api', 'src', 'auth', 'prisma-auth-refresh-token.store.ts');
43
+ const migrationPath = path.join(
44
+ rootDir,
45
+ 'apps',
46
+ 'api',
47
+ 'prisma',
48
+ 'migrations',
49
+ '0002_auth_refresh_token_hash',
50
+ 'migration.sql',
51
+ );
52
+
53
+ if (!fs.existsSync(appModulePath) || !fs.existsSync(schemaPath)) {
54
+ return false;
55
+ }
56
+
57
+ const appModule = fs.readFileSync(appModulePath, 'utf8');
58
+ const schema = fs.readFileSync(schemaPath, 'utf8');
59
+
60
+ const hasModuleWiring =
61
+ appModule.includes('refreshTokenStoreProvider') &&
62
+ appModule.includes('PrismaAuthRefreshTokenStore') &&
63
+ appModule.includes('AUTH_REFRESH_TOKEN_STORE');
64
+ const hasSchema = schema.includes('refreshTokenHash');
65
+ const hasStoreFile = fs.existsSync(storePath);
66
+ const hasMigration = fs.existsSync(migrationPath);
67
+
68
+ return !(hasModuleWiring && hasSchema && hasStoreFile && hasMigration);
69
+ }
70
+
71
+ const INTEGRATION_GROUPS = [
72
+ {
73
+ id: 'auth-persistence',
74
+ title: 'Auth Persistence Integration',
75
+ modules: ['jwt-auth', 'db-prisma', 'core-config'],
76
+ description: [
77
+ 'Register Prisma refresh-token store in AuthModule',
78
+ 'Wire AUTH_REFRESH_TOKEN_STORE provider to Prisma store',
79
+ 'Extend Prisma User model with refreshTokenHash',
80
+ 'Add auth persistence migration and update README note',
81
+ ],
82
+ isAvailable: (detected) => detected.jwtAuth && detected.dbPrisma,
83
+ isPending: (rootDir) => isAuthPersistencePending(rootDir),
84
+ apply: syncJwtDbPrisma,
85
+ },
86
+ ];
87
+
88
+ function detectModules(rootDir) {
89
+ const appModulePath = path.join(rootDir, 'apps', 'api', 'src', 'app.module.ts');
90
+ const appModuleText = fs.existsSync(appModulePath) ? fs.readFileSync(appModulePath, 'utf8') : '';
91
+
92
+ return {
93
+ jwtAuth:
94
+ fs.existsSync(path.join(rootDir, 'packages', 'auth-api', 'package.json')) ||
95
+ appModuleText.includes("from '@forgeon/auth-api'"),
96
+ dbPrisma:
97
+ fs.existsSync(path.join(rootDir, 'packages', 'db-prisma', 'package.json')) ||
98
+ appModuleText.includes("from '@forgeon/db-prisma'"),
99
+ };
100
+ }
101
+
102
+ function syncJwtDbPrisma({ rootDir, packageRoot, changedFiles }) {
103
+ const appModulePath = path.join(rootDir, 'apps', 'api', 'src', 'app.module.ts');
104
+ const schemaPath = path.join(rootDir, 'apps', 'api', 'prisma', 'schema.prisma');
105
+ const readmePath = path.join(rootDir, 'README.md');
106
+ const storePath = path.join(rootDir, 'apps', 'api', 'src', 'auth', 'prisma-auth-refresh-token.store.ts');
107
+ const migrationPath = path.join(
108
+ rootDir,
109
+ 'apps',
110
+ 'api',
111
+ 'prisma',
112
+ 'migrations',
113
+ '0002_auth_refresh_token_hash',
114
+ 'migration.sql',
115
+ );
116
+
117
+ if (!fs.existsSync(appModulePath) || !fs.existsSync(schemaPath)) {
118
+ return { applied: false, reason: 'app module or prisma schema is missing' };
119
+ }
120
+
121
+ let touched = false;
122
+
123
+ if (!fs.existsSync(storePath)) {
124
+ const storeSource = path.join(packageRoot, PRISMA_AUTH_STORE_TEMPLATE);
125
+ if (!fs.existsSync(storeSource)) {
126
+ return { applied: false, reason: 'jwt-auth prisma store template is missing' };
127
+ }
128
+ fs.mkdirSync(path.dirname(storePath), { recursive: true });
129
+ copyRecursive(storeSource, storePath);
130
+ changedFiles.add(storePath);
131
+ touched = true;
132
+ }
133
+
134
+ let appModule = fs.readFileSync(appModulePath, 'utf8').replace(/\r\n/g, '\n');
135
+ const originalAppModule = appModule;
136
+
137
+ if (!appModule.includes("AUTH_REFRESH_TOKEN_STORE, authConfig")) {
138
+ appModule = appModule.replace(
139
+ /import\s*\{\s*authConfig,\s*authEnvSchema,\s*ForgeonAuthModule\s*\}\s*from '@forgeon\/auth-api';/m,
140
+ "import { AUTH_REFRESH_TOKEN_STORE, authConfig, authEnvSchema, ForgeonAuthModule } from '@forgeon/auth-api';",
141
+ );
142
+ }
143
+
144
+ const storeImportLine = "import { PrismaAuthRefreshTokenStore } from './auth/prisma-auth-refresh-token.store';";
145
+ if (!appModule.includes(storeImportLine)) {
146
+ appModule = ensureLineAfter(
147
+ appModule,
148
+ "import { HealthController } from './health/health.controller';",
149
+ storeImportLine,
150
+ );
151
+ }
152
+
153
+ if (!appModule.includes('refreshTokenStoreProvider')) {
154
+ appModule = appModule.replace(
155
+ /ForgeonAuthModule\.register\(\),/m,
156
+ `ForgeonAuthModule.register({
157
+ imports: [DbPrismaModule],
158
+ refreshTokenStoreProvider: {
159
+ provide: AUTH_REFRESH_TOKEN_STORE,
160
+ useClass: PrismaAuthRefreshTokenStore,
161
+ },
162
+ }),`,
163
+ );
164
+ }
165
+
166
+ if (appModule !== originalAppModule) {
167
+ fs.writeFileSync(appModulePath, `${appModule.trimEnd()}\n`, 'utf8');
168
+ changedFiles.add(appModulePath);
169
+ touched = true;
170
+ }
171
+
172
+ let schema = fs.readFileSync(schemaPath, 'utf8').replace(/\r\n/g, '\n');
173
+ const originalSchema = schema;
174
+ if (!schema.includes('refreshTokenHash')) {
175
+ schema = schema.replace(/email\s+String\s+@unique/g, 'email String @unique\n refreshTokenHash String?');
176
+ }
177
+ if (schema !== originalSchema) {
178
+ fs.writeFileSync(schemaPath, `${schema.trimEnd()}\n`, 'utf8');
179
+ changedFiles.add(schemaPath);
180
+ touched = true;
181
+ }
182
+
183
+ if (!fs.existsSync(migrationPath)) {
184
+ const migrationSource = path.join(packageRoot, PRISMA_AUTH_MIGRATION_TEMPLATE);
185
+ if (!fs.existsSync(migrationSource)) {
186
+ return { applied: false, reason: 'jwt-auth migration template is missing' };
187
+ }
188
+ const migrationDir = path.dirname(migrationPath);
189
+ fs.mkdirSync(path.dirname(migrationDir), { recursive: true });
190
+ copyRecursive(migrationSource, migrationDir);
191
+ changedFiles.add(migrationPath);
192
+ touched = true;
193
+ }
194
+
195
+ if (fs.existsSync(readmePath)) {
196
+ let readme = fs.readFileSync(readmePath, 'utf8').replace(/\r\n/g, '\n');
197
+ const originalReadme = readme;
198
+ readme = readme.replace(
199
+ '- refresh token persistence: disabled by default (stateless mode)',
200
+ '- refresh token persistence: enabled (`db-prisma` adapter)',
201
+ );
202
+ readme = readme.replace(
203
+ /- to enable persistence later:[\s\S]*?2\. run `pnpm forgeon:sync-integrations` to auto-wire pair integrations\./m,
204
+ '- migration: `apps/api/prisma/migrations/0002_auth_refresh_token_hash`',
205
+ );
206
+ if (readme !== originalReadme) {
207
+ fs.writeFileSync(readmePath, `${readme.trimEnd()}\n`, 'utf8');
208
+ changedFiles.add(readmePath);
209
+ touched = true;
210
+ }
211
+ }
212
+
213
+ if (!touched) {
214
+ return { applied: false, reason: 'already synced' };
215
+ }
216
+ return { applied: true };
217
+ }
218
+
219
+ export function syncIntegrations({ targetRoot, packageRoot, groupIds = null }) {
220
+ const rootDir = path.resolve(targetRoot);
221
+ const changedFiles = new Set();
222
+ const detected = detectModules(rootDir);
223
+ const summary = [];
224
+ const available = INTEGRATION_GROUPS.filter(
225
+ (group) => group.isAvailable(detected) && group.isPending(rootDir),
226
+ );
227
+ const selected = Array.isArray(groupIds)
228
+ ? available.filter((group) => groupIds.includes(group.id))
229
+ : available;
230
+
231
+ for (const group of selected) {
232
+ summary.push({
233
+ id: group.id,
234
+ title: group.title,
235
+ modules: group.modules,
236
+ result: group.apply({ rootDir, packageRoot, changedFiles }),
237
+ });
238
+ }
239
+
240
+ return {
241
+ summary,
242
+ availableGroups: available.map((group) => ({
243
+ id: group.id,
244
+ title: group.title,
245
+ modules: [...group.modules],
246
+ description: [...group.description],
247
+ })),
248
+ changedFiles: [...changedFiles].sort().map((filePath) => path.relative(rootDir, filePath)),
249
+ };
250
+ }
251
+
252
+ export function scanIntegrations({ targetRoot, relatedModuleId = null }) {
253
+ const rootDir = path.resolve(targetRoot);
254
+ const detected = detectModules(rootDir);
255
+ const available = INTEGRATION_GROUPS.filter(
256
+ (group) =>
257
+ group.isAvailable(detected) &&
258
+ group.isPending(rootDir) &&
259
+ (!relatedModuleId || group.modules.includes(relatedModuleId)),
260
+ );
261
+ return {
262
+ groups: available.map((group) => ({
263
+ id: group.id,
264
+ title: group.title,
265
+ modules: [...group.modules],
266
+ description: [...group.description],
267
+ })),
268
+ };
269
+ }
@@ -1,11 +1,11 @@
1
1
  import path from 'node:path';
2
2
  import fs from 'node:fs';
3
- import { spawnSync } from 'node:child_process';
4
3
  import { fileURLToPath } from 'node:url';
5
4
  import { printAddHelp } from './cli/add-help.mjs';
6
5
  import { parseAddCliArgs } from './cli/add-options.mjs';
7
6
  import { addModule } from './modules/executor.mjs';
8
7
  import { listModulePresets } from './modules/registry.mjs';
8
+ import { printModuleAdded, runIntegrationFlow } from './integrations/flow.mjs';
9
9
  import { writeJson } from './utils/fs.mjs';
10
10
 
11
11
  function printModuleList() {
@@ -41,47 +41,12 @@ function ensureSyncTooling({ packageRoot, targetRoot }) {
41
41
  if (!packageJson.scripts) {
42
42
  packageJson.scripts = {};
43
43
  }
44
- if (!packageJson.devDependencies) {
45
- packageJson.devDependencies = {};
46
- }
47
44
 
48
45
  packageJson.scripts['forgeon:sync-integrations'] = 'node scripts/forgeon-sync-integrations.mjs';
49
- if (!packageJson.devDependencies['ts-morph']) {
50
- packageJson.devDependencies['ts-morph'] = '^24.0.0';
51
- }
52
46
 
53
47
  writeJson(packagePath, packageJson);
54
48
  }
55
49
 
56
- function runIntegrationSync(targetRoot) {
57
- const scriptPath = path.join(targetRoot, 'scripts', 'forgeon-sync-integrations.mjs');
58
- if (!fs.existsSync(scriptPath)) {
59
- return;
60
- }
61
-
62
- const tsMorphPackagePath = path.join(targetRoot, 'node_modules', 'ts-morph', 'package.json');
63
- if (!fs.existsSync(tsMorphPackagePath)) {
64
- console.warn(
65
- '[create-forgeon add] sync-integrations skipped (dependencies are not installed yet). ' +
66
- 'Run `pnpm install` then `pnpm forgeon:sync-integrations` inside the project.',
67
- );
68
- return;
69
- }
70
-
71
- const result = spawnSync(process.execPath, [scriptPath], {
72
- cwd: targetRoot,
73
- stdio: 'inherit',
74
- env: process.env,
75
- });
76
-
77
- if (result.status !== 0) {
78
- console.warn(
79
- '[create-forgeon add] sync-integrations failed. ' +
80
- 'Run `pnpm install` then `pnpm forgeon:sync-integrations` inside the project.',
81
- );
82
- }
83
- }
84
-
85
50
  export async function runAddModule(argv = process.argv.slice(2)) {
86
51
  const options = parseAddCliArgs(argv);
87
52
 
@@ -109,9 +74,10 @@ export async function runAddModule(argv = process.argv.slice(2)) {
109
74
  packageRoot,
110
75
  });
111
76
  ensureSyncTooling({ packageRoot, targetRoot });
112
- runIntegrationSync(targetRoot);
113
-
114
- console.log(result.message);
115
- console.log(`- module: ${result.preset.id}`);
116
- console.log(`- docs: ${result.docsPath}`);
117
- }
77
+ printModuleAdded(result.preset.id, result.docsPath);
78
+ await runIntegrationFlow({
79
+ targetRoot,
80
+ packageRoot,
81
+ relatedModuleId: result.preset.id,
82
+ });
83
+ }
@@ -0,0 +1,93 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { runIntegrationFlow } from './integrations/flow.mjs';
5
+ import { writeJson } from './utils/fs.mjs';
6
+
7
+ function parseScanArgs(argv) {
8
+ const options = {
9
+ project: '.',
10
+ help: false,
11
+ };
12
+
13
+ for (let i = 0; i < argv.length; i += 1) {
14
+ const arg = argv[i];
15
+ if (arg === '--') continue;
16
+ if (arg === '-h' || arg === '--help') {
17
+ options.help = true;
18
+ continue;
19
+ }
20
+ if (arg.startsWith('--project=')) {
21
+ options.project = arg.split('=')[1] || '.';
22
+ continue;
23
+ }
24
+ if (arg === '--project') {
25
+ if (argv[i + 1] && !argv[i + 1].startsWith('-')) {
26
+ options.project = argv[i + 1];
27
+ i += 1;
28
+ }
29
+ }
30
+ }
31
+
32
+ return options;
33
+ }
34
+
35
+ function printScanHelp() {
36
+ console.log(`create-forgeon scan-integrations
37
+
38
+ Usage:
39
+ npx create-forgeon@latest scan-integrations [options]
40
+
41
+ Options:
42
+ --project <path> Target project path (default: current directory)
43
+ -h, --help Show this help
44
+ `);
45
+ }
46
+
47
+ function ensureSyncTooling({ packageRoot, targetRoot }) {
48
+ const sourceScript = path.join(
49
+ packageRoot,
50
+ 'templates',
51
+ 'base',
52
+ 'scripts',
53
+ 'forgeon-sync-integrations.mjs',
54
+ );
55
+ const targetScript = path.join(targetRoot, 'scripts', 'forgeon-sync-integrations.mjs');
56
+
57
+ if (fs.existsSync(sourceScript) && !fs.existsSync(targetScript)) {
58
+ fs.mkdirSync(path.dirname(targetScript), { recursive: true });
59
+ fs.copyFileSync(sourceScript, targetScript);
60
+ }
61
+
62
+ const packagePath = path.join(targetRoot, 'package.json');
63
+ if (!fs.existsSync(packagePath)) {
64
+ return;
65
+ }
66
+
67
+ const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
68
+ if (!packageJson.scripts) {
69
+ packageJson.scripts = {};
70
+ }
71
+ packageJson.scripts['forgeon:sync-integrations'] = 'node scripts/forgeon-sync-integrations.mjs';
72
+ writeJson(packagePath, packageJson);
73
+ }
74
+
75
+ export async function runScanIntegrations(argv = process.argv.slice(2)) {
76
+ const options = parseScanArgs(argv);
77
+ if (options.help) {
78
+ printScanHelp();
79
+ return;
80
+ }
81
+
82
+ const srcDir = path.dirname(fileURLToPath(import.meta.url));
83
+ const packageRoot = path.resolve(srcDir, '..');
84
+ const targetRoot = path.resolve(process.cwd(), options.project);
85
+
86
+ ensureSyncTooling({ packageRoot, targetRoot });
87
+ await runIntegrationFlow({
88
+ targetRoot,
89
+ packageRoot,
90
+ relatedModuleId: null,
91
+ scanMessage: 'Scanning for pending integrations...',
92
+ });
93
+ }
@@ -46,6 +46,23 @@ Reusable features should be added as fullstack add-modules:
46
46
 
47
47
  Reference: `docs/AI/MODULE_SPEC.md`.
48
48
 
49
+ ## Integration Sync Strategy
50
+
51
+ - Integration orchestration is a default project toolchain command:
52
+ - `pnpm forgeon:sync-integrations`
53
+ - Purpose:
54
+ - keep add-modules composable when installed in arbitrary order;
55
+ - apply module-to-module integration patches idempotently.
56
+ - Rule:
57
+ - each add-module patches only itself;
58
+ - cross-module changes are allowed only in integration sync rules.
59
+ - Current integration:
60
+ - `jwt-auth + db-prisma` (persistent refresh-token store wiring + schema/migration sync).
61
+ - Pair sync is explicit (opt-in), not automatic after `add`.
62
+ - Run `pnpm forgeon:sync-integrations` when you want to apply module-pair integrations.
63
+ - Swagger auth decorators are intentionally not auto-patched.
64
+ - Future option: this may return as an explicit optional command (not default automatic behavior).
65
+
49
66
  ## TypeScript Module Format Policy
50
67
 
51
68
  - `apps/api`, `packages/core`, and backend runtime packages use Node-oriented config:
@@ -66,3 +66,7 @@ Must contain:
66
66
  - If module behavior can be runtime-checked, it also includes API+Web probe hooks (see `docs/AI/MODULE_CHECKS.md`).
67
67
  - If i18n is enabled, module-specific namespaces must be created and wired for both API and web.
68
68
  - If module is added before i18n, namespace templates must still be prepared and applied when i18n is installed later.
69
+ - Module integration with other modules must be represented as idempotent sync rules and runnable via `pnpm forgeon:sync-integrations`.
70
+ - `create-forgeon add <module-id>` does not auto-apply pair integrations.
71
+ - Pair integrations are applied explicitly via `pnpm forgeon:sync-integrations`.
72
+ - Modules must not assume `db-prisma` is present unless they explicitly require it; DB integrations should be optional and synced when DB is added later.
@@ -11,9 +11,6 @@
11
11
  "docker:up": "docker compose --env-file infra/docker/.env.example -f infra/docker/compose.yml up --build",
12
12
  "docker:down": "docker compose -f infra/docker/compose.yml down -v"
13
13
  },
14
- "devDependencies": {
15
- "ts-morph": "^24.0.0"
16
- },
17
14
  "pnpm": {
18
15
  "onlyBuiltDependencies": [
19
16
  "@nestjs/core",