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 +4 -0
- package/package.json +1 -1
- package/src/cli/add-help.mjs +5 -1
- package/src/cli/add-options.mjs +52 -16
- package/src/cli/add-options.test.mjs +22 -6
- package/src/integrations/flow.mjs +25 -0
- package/src/modules/dependencies.mjs +268 -0
- package/src/modules/dependencies.test.mjs +158 -0
- package/src/modules/registry.mjs +94 -9
- package/src/run-add-module.mjs +71 -7
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
package/src/cli/add-help.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/cli/add-options.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
26
|
-
options.
|
|
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
|
+
});
|
package/src/modules/registry.mjs
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/src/run-add-module.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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:
|
|
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(
|