create-forgeon 0.2.9 → 0.3.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 CHANGED
@@ -22,6 +22,7 @@ Project name stays text input; fixed-choice prompts use arrow-key selection (`Up
22
22
  npx create-forgeon@latest add --list
23
23
  npx create-forgeon@latest add i18n --project ./my-app
24
24
  npx create-forgeon@latest add jwt-auth --project ./my-app
25
+ npx create-forgeon@latest add files --with-required --provider db-adapter=db-prisma
25
26
  ```
26
27
 
27
28
  ```bash
@@ -38,3 +39,6 @@ pnpm forgeon:sync-integrations
38
39
  - `add jwt-auth` is implemented and auto-detects DB adapter support for refresh-token persistence.
39
40
  - Integration sync is bundled by default and runs after `add` commands (best-effort).
40
41
  - Module notes are written under `modules/<module-id>/README.md`.
42
+ - Hard prerequisites are explicit:
43
+ - TTY mode prompts for provider resolution and install plan confirmation
44
+ - non-TTY mode can use `--with-required` plus `--provider <capability>=<module>`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-forgeon",
3
- "version": "0.2.9",
3
+ "version": "0.3.0",
4
4
  "description": "Forgeon project generator CLI",
5
5
  "license": "MIT",
6
6
  "author": "Forgeon",
@@ -6,11 +6,15 @@ Usage:
6
6
 
7
7
  Options:
8
8
  --project <path> Target project path (default: current directory)
9
+ --with-required Allow recursive installation of hard prerequisites
10
+ --provider <capability>=<module>
11
+ Explicit provider mapping for non-interactive dependency resolution
9
12
  --list List available modules
10
13
  -h, --help Show this help
11
14
 
12
15
  Note:
13
- Pair integrations are explicit.
16
+ Hard prerequisites are resolved explicitly.
17
+ Pair integrations remain explicit follow-up actions.
14
18
  Run "pnpm forgeon:sync-integrations" in the target project after add-module steps.
15
19
  `);
16
20
  }
@@ -1,12 +1,14 @@
1
- export function parseAddCliArgs(argv) {
2
- const args = [...argv];
3
- const options = {
4
- moduleId: undefined,
5
- project: '.',
6
- list: false,
7
- help: false,
8
- };
9
- const positional = [];
1
+ export function parseAddCliArgs(argv) {
2
+ const args = [...argv];
3
+ const options = {
4
+ moduleId: undefined,
5
+ project: '.',
6
+ list: false,
7
+ help: false,
8
+ withRequired: false,
9
+ providers: {},
10
+ };
11
+ const positional = [];
10
12
 
11
13
  for (let i = 0; i < args.length; i += 1) {
12
14
  const arg = args[i];
@@ -17,13 +19,47 @@ export function parseAddCliArgs(argv) {
17
19
  continue;
18
20
  }
19
21
 
20
- if (arg === '--list') {
21
- options.list = true;
22
- continue;
23
- }
24
-
25
- if (arg.startsWith('--project=')) {
26
- options.project = arg.split('=')[1] || '.';
22
+ if (arg === '--list') {
23
+ options.list = true;
24
+ continue;
25
+ }
26
+
27
+ if (arg === '--with-required') {
28
+ options.withRequired = true;
29
+ continue;
30
+ }
31
+
32
+ if (arg.startsWith('--provider=')) {
33
+ const raw = arg.slice('--provider='.length);
34
+ const separatorIndex = raw.indexOf('=');
35
+ if (separatorIndex > 0) {
36
+ const capabilityId = raw.slice(0, separatorIndex).trim();
37
+ const moduleId = raw.slice(separatorIndex + 1).trim();
38
+ if (capabilityId && moduleId) {
39
+ options.providers[capabilityId] = moduleId;
40
+ }
41
+ }
42
+ continue;
43
+ }
44
+
45
+ if (arg === '--provider') {
46
+ const nextValue = args[i + 1];
47
+ if (nextValue && !nextValue.startsWith('-')) {
48
+ const separatorIndex = nextValue.indexOf('=');
49
+ if (separatorIndex > 0) {
50
+ const capabilityId = nextValue.slice(0, separatorIndex).trim();
51
+ const moduleId = nextValue.slice(separatorIndex + 1).trim();
52
+ if (capabilityId && moduleId) {
53
+ options.providers[capabilityId] = moduleId;
54
+ }
55
+ }
56
+ i += 1;
57
+ }
58
+ continue;
59
+ }
60
+
61
+ if (arg.startsWith('--project=')) {
62
+ options.project = arg.split('=')[1] || '.';
27
63
  continue;
28
64
  }
29
65
 
@@ -16,9 +16,25 @@ describe('parseAddCliArgs', () => {
16
16
  assert.equal(options.help, true);
17
17
  });
18
18
 
19
- it('uses second positional as project when project flag is absent', () => {
20
- const options = parseAddCliArgs(['queue', './my-app']);
21
- assert.equal(options.moduleId, 'queue');
22
- assert.equal(options.project, './my-app');
23
- });
24
- });
19
+ it('uses second positional as project when project flag is absent', () => {
20
+ const options = parseAddCliArgs(['queue', './my-app']);
21
+ assert.equal(options.moduleId, 'queue');
22
+ assert.equal(options.project, './my-app');
23
+ });
24
+
25
+ it('parses dependency resolution flags', () => {
26
+ const options = parseAddCliArgs([
27
+ 'files',
28
+ '--with-required',
29
+ '--provider',
30
+ 'db-adapter=db-prisma',
31
+ '--provider=queue-adapter=queue',
32
+ ]);
33
+ assert.equal(options.moduleId, 'files');
34
+ assert.equal(options.withRequired, true);
35
+ assert.deepEqual(options.providers, {
36
+ 'db-adapter': 'db-prisma',
37
+ 'queue-adapter': 'queue',
38
+ });
39
+ });
40
+ });
@@ -116,3 +116,28 @@ export function printModuleAdded(moduleId, docsPath) {
116
116
  console.log(colorize('green', `✔ Module added: ${moduleId}`));
117
117
  console.log(`- readme: ${docsPath}`);
118
118
  }
119
+
120
+ export function printOptionalIntegrationsWarning(integrations) {
121
+ if (!Array.isArray(integrations) || integrations.length === 0) {
122
+ return;
123
+ }
124
+
125
+ for (const integration of integrations) {
126
+ console.log(`\n${colorize('yellow', 'Warning: optional integration available')}`);
127
+ console.log(`- ${integration.title}`);
128
+ console.log('Modules:');
129
+ for (const moduleId of integration.modules ?? []) {
130
+ console.log(`- ${colorize('cyan', moduleId)}`);
131
+ }
132
+ console.log('This enables:');
133
+ for (const line of integration.description ?? []) {
134
+ console.log(`- ${line}`);
135
+ }
136
+ if (Array.isArray(integration.followUpCommands) && integration.followUpCommands.length > 0) {
137
+ console.log('Apply later:');
138
+ for (const command of integration.followUpCommands) {
139
+ console.log(`- ${command}`);
140
+ }
141
+ }
142
+ }
143
+ }
@@ -0,0 +1,268 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { promptSelect } from '../cli/prompt-select.mjs';
4
+ import { getCapabilityProviders, listModulePresets } from './registry.mjs';
5
+
6
+ function getPresetMap(presets) {
7
+ return new Map(presets.map((preset) => [preset.id, preset]));
8
+ }
9
+
10
+ function getDetectionPaths(preset) {
11
+ if (Array.isArray(preset.detectionPaths) && preset.detectionPaths.length > 0) {
12
+ return preset.detectionPaths;
13
+ }
14
+ return [path.join('packages', preset.id, 'package.json')];
15
+ }
16
+
17
+ export function detectInstalledModules(targetRoot, presets = listModulePresets()) {
18
+ const installed = new Set();
19
+ const absoluteRoot = path.resolve(targetRoot);
20
+
21
+ for (const preset of presets) {
22
+ const detectionPaths = getDetectionPaths(preset);
23
+ if (
24
+ detectionPaths.some((relativePath) => fs.existsSync(path.join(absoluteRoot, relativePath)))
25
+ ) {
26
+ installed.add(preset.id);
27
+ }
28
+ }
29
+
30
+ return installed;
31
+ }
32
+
33
+ export function collectProvidedCapabilities(moduleIds, presets = listModulePresets()) {
34
+ const presetMap = getPresetMap(presets);
35
+ const capabilities = new Set();
36
+
37
+ for (const moduleId of moduleIds) {
38
+ const preset = presetMap.get(moduleId);
39
+ if (!preset || !Array.isArray(preset.provides)) {
40
+ continue;
41
+ }
42
+ for (const capabilityId of preset.provides) {
43
+ capabilities.add(capabilityId);
44
+ }
45
+ }
46
+
47
+ return capabilities;
48
+ }
49
+
50
+ function describeMissingRequirement(requirement) {
51
+ if (requirement.type === 'capability') {
52
+ return `required capability "${requirement.id}" is missing`;
53
+ }
54
+ return `required module "${requirement.id}" is missing`;
55
+ }
56
+
57
+ function getNonInteractiveHint(requirement, providers, selectedProviderId) {
58
+ if (requirement.type === 'capability') {
59
+ if (providers.length === 0) {
60
+ return 'No implemented providers are currently available for this capability.';
61
+ }
62
+ const providerLines = providers.map((provider) => `- npx create-forgeon@latest add ${provider.id}`);
63
+ const withRequiredExample = selectedProviderId
64
+ ? [
65
+ '',
66
+ 'Or re-run with:',
67
+ `- npx create-forgeon@latest add <module-id> --with-required --provider ${requirement.id}=${selectedProviderId}`,
68
+ ]
69
+ : [];
70
+ return [
71
+ 'Install one of the supported providers first:',
72
+ ...providerLines,
73
+ ...withRequiredExample,
74
+ ].join('\n');
75
+ }
76
+
77
+ return `Install it first:\n- npx create-forgeon@latest add ${requirement.id}`;
78
+ }
79
+
80
+ function createResolutionError(moduleId, requirement, providers = [], selectedProviderId = null) {
81
+ const hint = getNonInteractiveHint(requirement, providers, selectedProviderId);
82
+ return new Error(`Cannot install "${moduleId}": ${describeMissingRequirement(requirement)}.\n\n${hint}`);
83
+ }
84
+
85
+ async function selectProviderForCapability({
86
+ moduleId,
87
+ capabilityId,
88
+ providers,
89
+ promptSelectImpl,
90
+ }) {
91
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
92
+ throw new Error(
93
+ `Interactive provider selection requires a TTY for capability "${capabilityId}" while installing "${moduleId}".`,
94
+ );
95
+ }
96
+
97
+ const choices = providers.map((provider, index) => ({
98
+ label: index === 0 ? `${provider.id} (Recommended)` : provider.id,
99
+ value: provider.id,
100
+ }));
101
+ choices.push({ label: 'Cancel', value: '__cancel' });
102
+
103
+ const picked = await promptSelectImpl({
104
+ message: `Module "${moduleId}" requires capability: ${capabilityId}`,
105
+ defaultValue: '__cancel',
106
+ choices,
107
+ });
108
+
109
+ if (picked === '__cancel') {
110
+ return null;
111
+ }
112
+
113
+ return picked;
114
+ }
115
+
116
+ export async function resolveModuleInstallPlan({
117
+ moduleId,
118
+ targetRoot,
119
+ presets = listModulePresets(),
120
+ withRequired = false,
121
+ providerSelections = {},
122
+ promptSelectImpl = promptSelect,
123
+ isInteractive = process.stdin.isTTY && process.stdout.isTTY,
124
+ }) {
125
+ const presetMap = getPresetMap(presets);
126
+ if (!presetMap.has(moduleId)) {
127
+ throw new Error(`Unknown module "${moduleId}".`);
128
+ }
129
+
130
+ const installed = detectInstalledModules(targetRoot, presets);
131
+ const planned = [];
132
+ const plannedSet = new Set();
133
+ const providedCapabilities = collectProvidedCapabilities(installed, presets);
134
+ const selectedProviders = new Map();
135
+
136
+ async function ensureModule(moduleIdToEnsure, isRoot = false) {
137
+ const preset = presetMap.get(moduleIdToEnsure);
138
+ if (!preset) {
139
+ throw new Error(`Unknown module "${moduleIdToEnsure}".`);
140
+ }
141
+
142
+ const requirements = Array.isArray(preset.requires) ? preset.requires : [];
143
+ for (const requirement of requirements) {
144
+ if (requirement.type === 'module') {
145
+ if (installed.has(requirement.id) || plannedSet.has(requirement.id)) {
146
+ continue;
147
+ }
148
+ if (!isInteractive && !withRequired) {
149
+ throw createResolutionError(moduleId, requirement);
150
+ }
151
+ await ensureModule(requirement.id);
152
+ continue;
153
+ }
154
+
155
+ if (requirement.type !== 'capability') {
156
+ continue;
157
+ }
158
+
159
+ if (providedCapabilities.has(requirement.id)) {
160
+ continue;
161
+ }
162
+
163
+ const providers = getCapabilityProviders(requirement.id, { implementedOnly: true });
164
+ if (providers.length === 0) {
165
+ throw createResolutionError(moduleId, requirement, providers);
166
+ }
167
+
168
+ let providerId = null;
169
+ if (selectedProviders.has(requirement.id)) {
170
+ providerId = selectedProviders.get(requirement.id);
171
+ } else if (isInteractive) {
172
+ providerId = await selectProviderForCapability({
173
+ moduleId,
174
+ capabilityId: requirement.id,
175
+ providers,
176
+ promptSelectImpl,
177
+ });
178
+ if (!providerId) {
179
+ return { cancelled: true };
180
+ }
181
+ } else {
182
+ if (!withRequired) {
183
+ throw createResolutionError(moduleId, requirement, providers);
184
+ }
185
+
186
+ if (providers.length === 1) {
187
+ providerId = providers[0].id;
188
+ } else {
189
+ const explicitProvider = providerSelections[requirement.id];
190
+ if (!explicitProvider) {
191
+ throw createResolutionError(moduleId, requirement, providers);
192
+ }
193
+ const matchedProvider = providers.find((provider) => provider.id === explicitProvider);
194
+ if (!matchedProvider) {
195
+ throw createResolutionError(moduleId, requirement, providers, explicitProvider);
196
+ }
197
+ providerId = matchedProvider.id;
198
+ }
199
+ }
200
+
201
+ selectedProviders.set(requirement.id, providerId);
202
+ const providerResult = await ensureModule(providerId);
203
+ if (providerResult?.cancelled) {
204
+ return providerResult;
205
+ }
206
+ }
207
+
208
+ if (!isRoot && !installed.has(moduleIdToEnsure) && !plannedSet.has(moduleIdToEnsure)) {
209
+ planned.push(moduleIdToEnsure);
210
+ plannedSet.add(moduleIdToEnsure);
211
+ const provided = Array.isArray(preset.provides) ? preset.provides : [];
212
+ for (const capabilityId of provided) {
213
+ providedCapabilities.add(capabilityId);
214
+ }
215
+ }
216
+
217
+ if (isRoot && !plannedSet.has(moduleIdToEnsure)) {
218
+ planned.push(moduleIdToEnsure);
219
+ plannedSet.add(moduleIdToEnsure);
220
+ }
221
+ return { cancelled: false };
222
+ }
223
+
224
+ const result = await ensureModule(moduleId, true);
225
+ return {
226
+ cancelled: result?.cancelled === true,
227
+ moduleSequence: planned,
228
+ selectedProviders: Object.fromEntries(selectedProviders),
229
+ };
230
+ }
231
+
232
+ function requirementIsMissing(requirement, installedModules, providedCapabilities) {
233
+ if (requirement.type === 'capability') {
234
+ return !providedCapabilities.has(requirement.id);
235
+ }
236
+ return !installedModules.has(requirement.id);
237
+ }
238
+
239
+ export function getPendingOptionalIntegrations({
240
+ moduleId,
241
+ targetRoot,
242
+ presets = listModulePresets(),
243
+ }) {
244
+ const presetMap = getPresetMap(presets);
245
+ const preset = presetMap.get(moduleId);
246
+ if (!preset || !Array.isArray(preset.optionalIntegrations)) {
247
+ return [];
248
+ }
249
+
250
+ const installedModules = detectInstalledModules(targetRoot, presets);
251
+ const providedCapabilities = collectProvidedCapabilities(installedModules, presets);
252
+
253
+ return preset.optionalIntegrations
254
+ .map((integration) => {
255
+ const requirements = Array.isArray(integration.requires) ? integration.requires : [];
256
+ const missing = requirements.filter((requirement) =>
257
+ requirementIsMissing(requirement, installedModules, providedCapabilities),
258
+ );
259
+ if (missing.length === 0) {
260
+ return null;
261
+ }
262
+ return {
263
+ ...integration,
264
+ missing,
265
+ };
266
+ })
267
+ .filter(Boolean);
268
+ }
@@ -0,0 +1,158 @@
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 {
7
+ collectProvidedCapabilities,
8
+ detectInstalledModules,
9
+ getPendingOptionalIntegrations,
10
+ resolveModuleInstallPlan,
11
+ } from './dependencies.mjs';
12
+
13
+ function mkTmp(prefix) {
14
+ return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
15
+ }
16
+
17
+ const TEST_PRESETS = [
18
+ {
19
+ id: 'db-prisma',
20
+ label: 'DB Prisma',
21
+ implemented: true,
22
+ detectionPaths: ['packages/db-prisma/package.json'],
23
+ provides: ['db-adapter'],
24
+ requires: [],
25
+ optionalIntegrations: [],
26
+ },
27
+ {
28
+ id: 'files',
29
+ label: 'Files',
30
+ implemented: false,
31
+ detectionPaths: ['packages/files/package.json'],
32
+ provides: ['files-runtime'],
33
+ requires: [{ type: 'capability', id: 'db-adapter' }],
34
+ optionalIntegrations: [],
35
+ },
36
+ {
37
+ id: 'jwt-auth',
38
+ label: 'JWT Auth',
39
+ implemented: true,
40
+ detectionPaths: ['packages/auth-api/package.json'],
41
+ provides: ['auth-runtime'],
42
+ requires: [],
43
+ optionalIntegrations: [
44
+ {
45
+ id: 'auth-persistence',
46
+ title: 'Auth Persistence Integration',
47
+ modules: ['jwt-auth', 'db-prisma'],
48
+ requires: [{ type: 'module', id: 'db-prisma' }],
49
+ description: ['Persist refresh-token state'],
50
+ followUpCommands: [
51
+ 'npx create-forgeon@latest add db-prisma',
52
+ 'pnpm forgeon:sync-integrations',
53
+ ],
54
+ },
55
+ ],
56
+ },
57
+ ];
58
+
59
+ describe('module dependency helpers', () => {
60
+ it('detects installed modules from detection paths', () => {
61
+ const targetRoot = mkTmp('forgeon-deps-detect-');
62
+ try {
63
+ fs.mkdirSync(path.join(targetRoot, 'packages', 'db-prisma'), { recursive: true });
64
+ fs.writeFileSync(path.join(targetRoot, 'packages', 'db-prisma', 'package.json'), '{}\n', 'utf8');
65
+
66
+ const installed = detectInstalledModules(targetRoot, TEST_PRESETS);
67
+ assert.equal(installed.has('db-prisma'), true);
68
+ assert.equal(installed.has('files'), false);
69
+ } finally {
70
+ fs.rmSync(targetRoot, { recursive: true, force: true });
71
+ }
72
+ });
73
+
74
+ it('collects provided capabilities from installed module ids', () => {
75
+ const capabilities = collectProvidedCapabilities(new Set(['db-prisma']), TEST_PRESETS);
76
+ assert.deepEqual([...capabilities], ['db-adapter']);
77
+ });
78
+
79
+ it('fails in non-interactive mode without --with-required when a capability is missing', async () => {
80
+ const targetRoot = mkTmp('forgeon-deps-fail-');
81
+
82
+ try {
83
+ await assert.rejects(
84
+ () =>
85
+ resolveModuleInstallPlan({
86
+ moduleId: 'files',
87
+ targetRoot,
88
+ presets: TEST_PRESETS,
89
+ withRequired: false,
90
+ isInteractive: false,
91
+ }),
92
+ /required capability "db-adapter" is missing/,
93
+ );
94
+ } finally {
95
+ fs.rmSync(targetRoot, { recursive: true, force: true });
96
+ }
97
+ });
98
+
99
+ it('builds a concrete install plan in non-interactive mode with --with-required', async () => {
100
+ const targetRoot = mkTmp('forgeon-deps-plan-');
101
+
102
+ try {
103
+ const result = await resolveModuleInstallPlan({
104
+ moduleId: 'files',
105
+ targetRoot,
106
+ presets: TEST_PRESETS,
107
+ withRequired: true,
108
+ isInteractive: false,
109
+ });
110
+
111
+ assert.equal(result.cancelled, false);
112
+ assert.deepEqual(result.moduleSequence, ['db-prisma', 'files']);
113
+ assert.deepEqual(result.selectedProviders, { 'db-adapter': 'db-prisma' });
114
+ } finally {
115
+ fs.rmSync(targetRoot, { recursive: true, force: true });
116
+ }
117
+ });
118
+
119
+ it('reports missing optional integrations for the installed module', () => {
120
+ const targetRoot = mkTmp('forgeon-deps-optional-');
121
+ try {
122
+ fs.mkdirSync(path.join(targetRoot, 'packages', 'auth-api'), { recursive: true });
123
+ fs.writeFileSync(path.join(targetRoot, 'packages', 'auth-api', 'package.json'), '{}\n', 'utf8');
124
+
125
+ const pending = getPendingOptionalIntegrations({
126
+ moduleId: 'jwt-auth',
127
+ targetRoot,
128
+ presets: TEST_PRESETS,
129
+ });
130
+
131
+ assert.equal(pending.length, 1);
132
+ assert.equal(pending[0].id, 'auth-persistence');
133
+ assert.equal(pending[0].missing[0].id, 'db-prisma');
134
+ } finally {
135
+ fs.rmSync(targetRoot, { recursive: true, force: true });
136
+ }
137
+ });
138
+
139
+ it('keeps the requested module in the plan even when it is already installed', async () => {
140
+ const targetRoot = mkTmp('forgeon-deps-reapply-');
141
+
142
+ try {
143
+ fs.mkdirSync(path.join(targetRoot, 'packages', 'db-prisma'), { recursive: true });
144
+ fs.writeFileSync(path.join(targetRoot, 'packages', 'db-prisma', 'package.json'), '{}\n', 'utf8');
145
+
146
+ const result = await resolveModuleInstallPlan({
147
+ moduleId: 'db-prisma',
148
+ targetRoot,
149
+ presets: TEST_PRESETS,
150
+ isInteractive: false,
151
+ });
152
+
153
+ assert.deepEqual(result.moduleSequence, ['db-prisma']);
154
+ } finally {
155
+ fs.rmSync(targetRoot, { recursive: true, force: true });
156
+ }
157
+ });
158
+ });
@@ -5,6 +5,10 @@ const MODULE_PRESETS = {
5
5
  category: 'database-layer',
6
6
  implemented: true,
7
7
  description: 'Prisma/Postgres module wiring with env config, scripts, and DB probe endpoint.',
8
+ detectionPaths: ['packages/db-prisma/package.json'],
9
+ provides: ['db-adapter'],
10
+ requires: [],
11
+ optionalIntegrations: [],
8
12
  docFragments: ['00_title', '10_overview', '20_scope', '90_status_implemented'],
9
13
  },
10
14
  i18n: {
@@ -13,6 +17,10 @@ const MODULE_PRESETS = {
13
17
  category: 'localization',
14
18
  implemented: true,
15
19
  description: 'Backend/frontend i18n wiring with locale contracts and translation resources.',
20
+ detectionPaths: ['packages/i18n/package.json'],
21
+ provides: ['i18n-runtime'],
22
+ requires: [],
23
+ optionalIntegrations: [],
16
24
  docFragments: ['00_title', '10_overview', '20_scope', '90_status_implemented'],
17
25
  },
18
26
  logger: {
@@ -21,6 +29,10 @@ const MODULE_PRESETS = {
21
29
  category: 'observability',
22
30
  implemented: true,
23
31
  description: 'Structured API logger with request id middleware and HTTP logging interceptor.',
32
+ detectionPaths: ['packages/logger/package.json'],
33
+ provides: ['logger-runtime'],
34
+ requires: [],
35
+ optionalIntegrations: [],
24
36
  docFragments: ['00_title', '10_overview', '20_scope', '90_status_implemented'],
25
37
  },
26
38
  swagger: {
@@ -29,6 +41,10 @@ const MODULE_PRESETS = {
29
41
  category: 'api-documentation',
30
42
  implemented: true,
31
43
  description: 'OpenAPI docs setup with env-based toggle and route path.',
44
+ detectionPaths: ['packages/swagger/package.json'],
45
+ provides: ['openapi-runtime'],
46
+ requires: [],
47
+ optionalIntegrations: [],
32
48
  docFragments: ['00_title', '10_overview', '20_scope', '90_status_implemented'],
33
49
  },
34
50
  'jwt-auth': {
@@ -37,6 +53,39 @@ const MODULE_PRESETS = {
37
53
  category: 'auth-security',
38
54
  implemented: true,
39
55
  description: 'JWT auth preset with contracts/api module split, guard+strategy, and DB-aware refresh token storage wiring.',
56
+ detectionPaths: ['packages/auth-api/package.json'],
57
+ provides: ['auth-runtime'],
58
+ requires: [],
59
+ optionalIntegrations: [
60
+ {
61
+ id: 'auth-persistence',
62
+ title: 'Auth Persistence Integration',
63
+ modules: ['jwt-auth', 'db-prisma'],
64
+ requires: [{ type: 'module', id: 'db-prisma' }],
65
+ description: [
66
+ 'Persist refresh-token state through the current DB integration',
67
+ 'Enable stronger refresh-token invalidation flows after logout and rotation',
68
+ ],
69
+ followUpCommands: [
70
+ 'npx create-forgeon@latest add db-prisma',
71
+ 'pnpm forgeon:sync-integrations',
72
+ ],
73
+ },
74
+ {
75
+ id: 'auth-rbac-claims',
76
+ title: 'Auth Claims Integration',
77
+ modules: ['jwt-auth', 'rbac'],
78
+ requires: [{ type: 'module', id: 'rbac' }],
79
+ description: [
80
+ 'Expose demo RBAC permissions inside JWT payloads',
81
+ 'Return permissions through refresh and /me responses',
82
+ ],
83
+ followUpCommands: [
84
+ 'npx create-forgeon@latest add rbac',
85
+ 'pnpm forgeon:sync-integrations',
86
+ ],
87
+ },
88
+ ],
40
89
  docFragments: ['00_title', '10_overview', '20_scope', '90_status_implemented'],
41
90
  },
42
91
  'rate-limit': {
@@ -45,6 +94,10 @@ const MODULE_PRESETS = {
45
94
  category: 'auth-security',
46
95
  implemented: true,
47
96
  description: 'Request throttling preset with env-based limits, proxy-aware trust, and a runtime probe endpoint.',
97
+ detectionPaths: ['packages/rate-limit/package.json'],
98
+ provides: ['rate-limit-runtime'],
99
+ requires: [],
100
+ optionalIntegrations: [],
48
101
  docFragments: [
49
102
  '00_title',
50
103
  '10_overview',
@@ -63,6 +116,25 @@ const MODULE_PRESETS = {
63
116
  category: 'auth-security',
64
117
  implemented: true,
65
118
  description: 'Role and permission decorators with a Nest guard and a protected probe endpoint.',
119
+ detectionPaths: ['packages/rbac/package.json'],
120
+ provides: ['rbac-runtime'],
121
+ requires: [],
122
+ optionalIntegrations: [
123
+ {
124
+ id: 'auth-rbac-claims',
125
+ title: 'Auth Claims Integration',
126
+ modules: ['jwt-auth', 'rbac'],
127
+ requires: [{ type: 'module', id: 'jwt-auth' }],
128
+ description: [
129
+ 'Expose demo RBAC permissions inside JWT payloads',
130
+ 'Return permissions through refresh and /me responses',
131
+ ],
132
+ followUpCommands: [
133
+ 'npx create-forgeon@latest add jwt-auth',
134
+ 'pnpm forgeon:sync-integrations',
135
+ ],
136
+ },
137
+ ],
66
138
  docFragments: [
67
139
  '00_title',
68
140
  '10_overview',
@@ -78,20 +150,33 @@ const MODULE_PRESETS = {
78
150
  queue: {
79
151
  id: 'queue',
80
152
  label: 'Queue Worker',
81
- category: 'background-jobs',
82
- implemented: false,
83
- description: 'Queue processing preset (BullMQ-style app wiring).',
84
- docFragments: ['00_title', '10_overview', '20_scope', '90_status_planned'],
85
- },
86
- };
153
+ category: 'background-jobs',
154
+ implemented: false,
155
+ description: 'Queue processing preset (BullMQ-style app wiring).',
156
+ detectionPaths: ['packages/queue/package.json'],
157
+ provides: ['queue-runtime'],
158
+ requires: [],
159
+ optionalIntegrations: [],
160
+ docFragments: ['00_title', '10_overview', '20_scope', '90_status_planned'],
161
+ },
162
+ };
87
163
 
88
164
  export function listModulePresets() {
89
165
  return Object.values(MODULE_PRESETS);
90
166
  }
91
167
 
92
- export function getModulePreset(moduleId) {
93
- return MODULE_PRESETS[moduleId] ?? null;
94
- }
168
+ export function getModulePreset(moduleId) {
169
+ return MODULE_PRESETS[moduleId] ?? null;
170
+ }
171
+
172
+ export function getCapabilityProviders(capabilityId, { implementedOnly = true } = {}) {
173
+ return listModulePresets().filter((preset) => {
174
+ if (implementedOnly && !preset.implemented) {
175
+ return false;
176
+ }
177
+ return Array.isArray(preset.provides) && preset.provides.includes(capabilityId);
178
+ });
179
+ }
95
180
 
96
181
  export function ensureModuleExists(moduleId) {
97
182
  const preset = getModulePreset(moduleId);
@@ -3,9 +3,15 @@ import fs from 'node:fs';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { printAddHelp } from './cli/add-help.mjs';
5
5
  import { parseAddCliArgs } from './cli/add-options.mjs';
6
+ import { promptSelect } from './cli/prompt-select.mjs';
6
7
  import { addModule } from './modules/executor.mjs';
8
+ import { getPendingOptionalIntegrations, resolveModuleInstallPlan } from './modules/dependencies.mjs';
7
9
  import { listModulePresets } from './modules/registry.mjs';
8
- import { printModuleAdded, runIntegrationFlow } from './integrations/flow.mjs';
10
+ import {
11
+ printModuleAdded,
12
+ printOptionalIntegrationsWarning,
13
+ runIntegrationFlow,
14
+ } from './integrations/flow.mjs';
9
15
  import { writeJson } from './utils/fs.mjs';
10
16
 
11
17
  function printModuleList() {
@@ -114,8 +120,40 @@ function ensureSyncTooling({ packageRoot, targetRoot }) {
114
120
  writeJson(packagePath, packageJson);
115
121
  }
116
122
 
123
+ function printInstallPlan(moduleSequence) {
124
+ if (!Array.isArray(moduleSequence) || moduleSequence.length === 0) {
125
+ return;
126
+ }
127
+
128
+ console.log('Install plan:');
129
+ moduleSequence.forEach((moduleId, index) => {
130
+ console.log(`${index + 1}. ${moduleId}`);
131
+ });
132
+ }
133
+
134
+ async function confirmInstallPlan(moduleSequence, requestedModuleId) {
135
+ if (moduleSequence.length <= 1) {
136
+ return true;
137
+ }
138
+
139
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
140
+ return true;
141
+ }
142
+
143
+ printInstallPlan(moduleSequence);
144
+ const picked = await promptSelect({
145
+ message: `Apply now for "${requestedModuleId}"?`,
146
+ defaultValue: 'cancel',
147
+ choices: [
148
+ { label: 'Yes, apply install plan', value: 'apply' },
149
+ { label: 'Cancel', value: 'cancel' },
150
+ ],
151
+ });
152
+ return picked === 'apply';
153
+ }
154
+
117
155
  export async function runAddModule(argv = process.argv.slice(2)) {
118
- const options = parseAddCliArgs(argv);
156
+ const options = parseAddCliArgs(argv);
119
157
 
120
158
  if (options.help) {
121
159
  printAddHelp();
@@ -135,19 +173,45 @@ export async function runAddModule(argv = process.argv.slice(2)) {
135
173
  const packageRoot = path.resolve(srcDir, '..');
136
174
  const targetRoot = path.resolve(process.cwd(), options.project);
137
175
  const dependencyManifestStateBefore = collectDependencyManifestState(targetRoot);
138
-
139
- const result = addModule({
176
+ const plan = await resolveModuleInstallPlan({
140
177
  moduleId: options.moduleId,
141
178
  targetRoot,
142
- packageRoot,
179
+ withRequired: options.withRequired,
180
+ providerSelections: options.providers,
143
181
  });
182
+
183
+ if (plan.cancelled) {
184
+ console.log('Installation cancelled.');
185
+ return;
186
+ }
187
+
188
+ const confirmed = await confirmInstallPlan(plan.moduleSequence, options.moduleId);
189
+ if (!confirmed) {
190
+ console.log('Installation cancelled.');
191
+ return;
192
+ }
193
+
194
+ for (const moduleId of plan.moduleSequence) {
195
+ const currentResult = addModule({
196
+ moduleId,
197
+ targetRoot,
198
+ packageRoot,
199
+ });
200
+ printModuleAdded(currentResult.preset.id, currentResult.docsPath);
201
+ }
202
+
144
203
  ensureSyncTooling({ packageRoot, targetRoot });
145
- printModuleAdded(result.preset.id, result.docsPath);
146
204
  await runIntegrationFlow({
147
205
  targetRoot,
148
206
  packageRoot,
149
- relatedModuleId: result.preset.id,
207
+ relatedModuleId: options.moduleId,
208
+ });
209
+
210
+ const pendingOptionalIntegrations = getPendingOptionalIntegrations({
211
+ moduleId: options.moduleId,
212
+ targetRoot,
150
213
  });
214
+ printOptionalIntegrationsWarning(pendingOptionalIntegrations);
151
215
 
152
216
  const dependencyManifestStateAfter = collectDependencyManifestState(targetRoot);
153
217
  const changedDependencyManifestPaths = getChangedDependencyManifestPaths(