create-forgeon 0.3.19 → 0.3.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/package.json +1 -1
  2. package/src/cli/add-help.mjs +3 -2
  3. package/src/core/docs.test.mjs +1 -0
  4. package/src/core/scaffold.test.mjs +1 -0
  5. package/src/modules/accounts.mjs +9 -18
  6. package/src/modules/dependencies.mjs +153 -4
  7. package/src/modules/dependencies.test.mjs +58 -0
  8. package/src/modules/executor.test.mjs +544 -515
  9. package/src/modules/files-access.mjs +375 -375
  10. package/src/modules/files-image.mjs +512 -510
  11. package/src/modules/files-quotas.mjs +365 -365
  12. package/src/modules/files.mjs +5 -6
  13. package/src/modules/idempotency.test.mjs +3 -2
  14. package/src/modules/registry.mjs +20 -0
  15. package/src/modules/shared/files-runtime-wiring.mjs +13 -10
  16. package/src/run-add-module.mjs +39 -26
  17. package/src/run-add-module.test.mjs +228 -152
  18. package/src/run-scan-integrations.mjs +1 -0
  19. package/templates/base/package.json +1 -0
  20. package/templates/module-presets/accounts/packages/accounts-api/package.json +1 -0
  21. package/templates/module-presets/accounts/packages/accounts-api/src/auth-core.service.ts +15 -19
  22. package/templates/module-presets/accounts/{apps/api/src/accounts/prisma-accounts-persistence.store.ts → packages/accounts-api/src/auth.store.ts} +44 -166
  23. package/templates/module-presets/accounts/packages/accounts-api/src/forgeon-accounts.module.ts +7 -1
  24. package/templates/module-presets/accounts/packages/accounts-api/src/index.ts +3 -4
  25. package/templates/module-presets/accounts/packages/accounts-api/src/users.service.ts +10 -11
  26. package/templates/module-presets/accounts/packages/accounts-api/src/users.store.ts +113 -0
  27. package/templates/module-presets/accounts/packages/accounts-api/src/users.types.ts +48 -0
  28. package/templates/module-presets/files/packages/files/package.json +1 -0
  29. package/templates/module-presets/files/packages/files/src/files.ports.ts +0 -95
  30. package/templates/module-presets/files/packages/files/src/files.service.ts +43 -36
  31. package/templates/module-presets/files/{apps/api/src/files/prisma-files-persistence.store.ts → packages/files/src/files.store.ts} +77 -13
  32. package/templates/module-presets/files/packages/files/src/forgeon-files.module.ts +7 -116
  33. package/templates/module-presets/files/packages/files/src/index.ts +1 -0
  34. package/templates/module-presets/files-quotas/packages/files-quotas/package.json +20 -20
  35. package/templates/module-presets/files-quotas/packages/files-quotas/src/files-quotas.service.ts +118 -118
  36. package/templates/module-presets/files-quotas/packages/files-quotas/src/forgeon-files-quotas.module.ts +18 -18
  37. package/templates/module-presets/accounts/packages/accounts-api/src/accounts-persistence.port.ts +0 -67
  38. package/templates/module-presets/files/apps/api/src/files/forgeon-files-db-prisma.module.ts +0 -17
@@ -170,9 +170,9 @@ function patchAppModule(targetRoot) {
170
170
  content,
171
171
  "import { filesConfig, filesEnvSchema, ForgeonFilesModule } from '@forgeon/files';",
172
172
  );
173
- content = ensureImportLine(
174
- content,
175
- "import { ForgeonFilesDbPrismaModule } from './files/forgeon-files-db-prisma.module';",
173
+ content = content.replace(
174
+ /^import \{ ForgeonFilesDbPrismaModule \} from '\.\/files\/forgeon-files-db-prisma\.module';\r?\n/m,
175
+ '',
176
176
  );
177
177
 
178
178
  if (fs.existsSync(path.join(targetRoot, 'packages', 'files-local', 'package.json'))) {
@@ -188,7 +188,6 @@ function patchAppModule(targetRoot) {
188
188
 
189
189
  fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
190
190
  }
191
-
192
191
  function patchApiDockerfile(targetRoot) {
193
192
  const dockerfilePath = path.join(targetRoot, 'apps', 'api', 'Dockerfile');
194
193
  if (!fs.existsSync(dockerfilePath)) {
@@ -378,7 +377,7 @@ function patchReadme(targetRoot) {
378
377
 
379
378
  const section = `## Files Module
380
379
 
381
- The files module adds runtime file endpoints with DB-backed metadata and storage-driver-aware behavior.
380
+ The files module adds runtime file endpoints with Prisma-backed metadata and storage-driver-aware behavior.
382
381
 
383
382
  What it currently adds:
384
383
  - \`@forgeon/files\` package with:
@@ -425,7 +424,6 @@ Key env:
425
424
 
426
425
  export function applyFilesModule({ packageRoot, targetRoot }) {
427
426
  copyFromPreset(packageRoot, targetRoot, path.join('packages', 'files'));
428
- copyFromPreset(packageRoot, targetRoot, path.join('apps', 'api', 'src', 'files'));
429
427
  const probeTargets = resolveProbeTargets({ targetRoot, moduleId: 'files' });
430
428
 
431
429
 
@@ -456,3 +454,4 @@ export function applyFilesModule({ packageRoot, targetRoot }) {
456
454
  }
457
455
 
458
456
 
457
+
@@ -1,4 +1,4 @@
1
- import { describe, it } from 'node:test';
1
+ import { describe, it } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import fs from 'node:fs';
4
4
  import os from 'node:os';
@@ -91,7 +91,7 @@ describe('addModule idempotency', () => {
91
91
  fs.existsSync(
92
92
  path.join(projectRoot, 'apps', 'api', 'src', 'accounts', 'prisma-accounts-persistence.store.ts'),
93
93
  ),
94
- true,
94
+ false,
95
95
  );
96
96
  assert.match(
97
97
  readFile(path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts')),
@@ -178,3 +178,4 @@ describe('addModule idempotency', () => {
178
178
  }
179
179
  });
180
180
  });
181
+
@@ -255,6 +255,10 @@ const MODULE_PRESETS = {
255
255
  docFragments: ['00_title', '10_overview', '20_scope', '90_status_implemented'],
256
256
  },
257
257
  };
258
+
259
+ const RECOMMENDED_CAPABILITY_PROVIDERS = {
260
+ 'files-storage-adapter': 'files-local',
261
+ };
258
262
 
259
263
  export function listModulePresets() {
260
264
  return Object.values(MODULE_PRESETS);
@@ -272,6 +276,22 @@ export function getCapabilityProviders(capabilityId, { implementedOnly = true }
272
276
  return Array.isArray(preset.provides) && preset.provides.includes(capabilityId);
273
277
  });
274
278
  }
279
+
280
+ export function getRecommendedCapabilityProvider(capabilityId, providers = null) {
281
+ const availableProviders = Array.isArray(providers)
282
+ ? providers
283
+ : getCapabilityProviders(capabilityId, { implementedOnly: true });
284
+
285
+ const recommendedProviderId = RECOMMENDED_CAPABILITY_PROVIDERS[capabilityId];
286
+ if (recommendedProviderId) {
287
+ const matchedProvider = availableProviders.find((provider) => provider.id === recommendedProviderId);
288
+ if (matchedProvider) {
289
+ return matchedProvider;
290
+ }
291
+ }
292
+
293
+ return availableProviders[0] ?? null;
294
+ }
275
295
 
276
296
  export function ensureModuleExists(moduleId) {
277
297
  const preset = getModulePreset(moduleId);
@@ -1,4 +1,4 @@
1
- import fs from 'node:fs';
1
+ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { ensureLineAfter, ensureLineBefore } from './patch-utils.mjs';
4
4
 
@@ -38,22 +38,25 @@ export function resolveFilesStorageRuntimeModule(targetRoot) {
38
38
  }
39
39
 
40
40
  export function upsertFilesModuleRegistration(content, storageRuntimeModuleName = null) {
41
- const runtimeImports = ['ForgeonFilesDbPrismaModule'];
42
- if (storageRuntimeModuleName) {
43
- runtimeImports.push(storageRuntimeModuleName);
44
- }
45
-
46
- const moduleBlock = ` ForgeonFilesModule.register({
47
- imports: [${runtimeImports.join(', ')}],
48
- }),`;
41
+ const moduleBlock = storageRuntimeModuleName
42
+ ? ` ForgeonFilesModule.register({
43
+ imports: [${storageRuntimeModuleName}],
44
+ }),`
45
+ : ' ForgeonFilesModule.register(),';
49
46
 
50
47
  if (content.includes('ForgeonFilesModule.register({')) {
51
48
  return content.replace(
52
- / {4}ForgeonFilesModule\.register\(\{[\s\S]*? {4}\}\),/m,
49
+ / {4}ForgeonFilesModule\.register\([\s\S]*? {4}\}\),/m,
53
50
  moduleBlock,
54
51
  );
55
52
  }
56
53
 
54
+ if (content.includes(' ForgeonFilesModule.register(),')) {
55
+ return storageRuntimeModuleName
56
+ ? content.replace(' ForgeonFilesModule.register(),', moduleBlock)
57
+ : content;
58
+ }
59
+
57
60
  if (content.includes(' ForgeonFilesModule,')) {
58
61
  return content.replace(' ForgeonFilesModule,', moduleBlock);
59
62
  }
@@ -8,6 +8,7 @@ import { addModule } from './modules/executor.mjs';
8
8
  import {
9
9
  getPendingOptionalIntegrations,
10
10
  getPendingRecommendedCompanions,
11
+ resolveAllModulesInstallPlan,
11
12
  resolveModuleInstallPlan,
12
13
  } from './modules/dependencies.mjs';
13
14
  import { listModulePresets } from './modules/registry.mjs';
@@ -19,11 +20,10 @@ import {
19
20
  import { readJson, writeJson } from './utils/fs.mjs';
20
21
 
21
22
  function printModuleList() {
22
- const modules = listModulePresets();
23
- console.log('Available modules:');
24
- for (const moduleItem of modules) {
25
- const status = moduleItem.implemented ? 'implemented' : 'planned';
26
- console.log(`- ${moduleItem.id} (${status}) - ${moduleItem.description}`);
23
+ const modules = listModulePresets().filter((moduleItem) => moduleItem.implemented !== false);
24
+ console.log('Available modules:');
25
+ for (const moduleItem of modules) {
26
+ console.log(`- ${moduleItem.id} - ${moduleItem.description}`);
27
27
  }
28
28
  }
29
29
 
@@ -120,6 +120,7 @@ function ensureSyncTooling({ packageRoot, targetRoot }) {
120
120
  }
121
121
 
122
122
  packageJson.scripts['forgeon:sync-integrations'] = 'node scripts/forgeon-sync-integrations.mjs';
123
+ packageJson.scripts['add-all'] = 'npx create-forgeon@latest add all --project .';
123
124
 
124
125
  writeJson(packagePath, packageJson);
125
126
  }
@@ -230,12 +231,18 @@ export async function runAddModule(argv = process.argv.slice(2)) {
230
231
  const packageRoot = path.resolve(srcDir, '..');
231
232
  const targetRoot = path.resolve(process.cwd(), options.project);
232
233
  const dependencyManifestStateBefore = collectDependencyManifestState(targetRoot);
233
- const plan = await resolveModuleInstallPlan({
234
- moduleId: options.moduleId,
235
- targetRoot,
236
- withRequired: options.withRequired,
237
- providerSelections: options.providers,
238
- });
234
+ const isAllModulesInstall = options.moduleId === 'all';
235
+ const plan = isAllModulesInstall
236
+ ? await resolveAllModulesInstallPlan({
237
+ targetRoot,
238
+ providerSelections: options.providers,
239
+ })
240
+ : await resolveModuleInstallPlan({
241
+ moduleId: options.moduleId,
242
+ targetRoot,
243
+ withRequired: options.withRequired,
244
+ providerSelections: options.providers,
245
+ });
239
246
 
240
247
  if (plan.cancelled) {
241
248
  console.log('Installation cancelled.');
@@ -257,15 +264,19 @@ export async function runAddModule(argv = process.argv.slice(2)) {
257
264
  printModuleAdded(currentResult.preset.id, currentResult.docsPath);
258
265
  }
259
266
 
260
- const pendingRecommendedCompanions = getPendingRecommendedCompanions({
261
- moduleId: options.moduleId,
262
- targetRoot,
263
- });
264
- const selectedRecommendedCompanions = await chooseRecommendedCompanions({
265
- requestedModuleId: options.moduleId,
266
- companions: pendingRecommendedCompanions,
267
- withRecommended: options.withRecommended,
268
- });
267
+ const pendingRecommendedCompanions = isAllModulesInstall
268
+ ? []
269
+ : getPendingRecommendedCompanions({
270
+ moduleId: options.moduleId,
271
+ targetRoot,
272
+ });
273
+ const selectedRecommendedCompanions = isAllModulesInstall
274
+ ? []
275
+ : await chooseRecommendedCompanions({
276
+ requestedModuleId: options.moduleId,
277
+ companions: pendingRecommendedCompanions,
278
+ withRecommended: options.withRecommended,
279
+ });
269
280
 
270
281
  for (const recommendedModuleId of selectedRecommendedCompanions) {
271
282
  const recommendedPlan = await resolveModuleInstallPlan({
@@ -291,14 +302,16 @@ export async function runAddModule(argv = process.argv.slice(2)) {
291
302
  await runIntegrationFlow({
292
303
  targetRoot,
293
304
  packageRoot,
294
- relatedModuleId: selectedRecommendedCompanions.length > 0 ? null : options.moduleId,
305
+ relatedModuleId: isAllModulesInstall || selectedRecommendedCompanions.length > 0 ? null : options.moduleId,
295
306
  });
296
307
 
297
- const pendingOptionalIntegrations = getPendingOptionalIntegrations({
298
- moduleId: options.moduleId,
299
- targetRoot,
300
- });
301
- printOptionalIntegrationsWarning(pendingOptionalIntegrations);
308
+ if (!isAllModulesInstall) {
309
+ const pendingOptionalIntegrations = getPendingOptionalIntegrations({
310
+ moduleId: options.moduleId,
311
+ targetRoot,
312
+ });
313
+ printOptionalIntegrationsWarning(pendingOptionalIntegrations);
314
+ }
302
315
 
303
316
  const dependencyManifestStateAfter = collectDependencyManifestState(targetRoot);
304
317
  const changedDependencyManifestPaths = getChangedDependencyManifestPaths(
@@ -1,154 +1,230 @@
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 { fileURLToPath } from 'node:url';
7
- import { scaffoldProject } from './core/scaffold.mjs';
8
- import { runAddModule } from './run-add-module.mjs';
9
-
10
- function makeTempDir(prefix) {
11
- return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
12
- }
13
-
14
- function readFile(filePath) {
15
- return fs.readFileSync(filePath, 'utf8');
16
- }
17
-
18
- function writeJson(filePath, value) {
19
- fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
20
- }
21
-
22
- function scaffoldBaseProject({ packageRoot, targetRoot, projectName, proxy }) {
23
- const templateRoot = path.join(packageRoot, 'templates', 'base');
24
- scaffoldProject({
25
- templateRoot,
26
- packageRoot,
27
- targetRoot,
28
- projectName,
29
- frontend: 'react',
30
- db: 'prisma',
31
- dbPrismaEnabled: false,
32
- i18nEnabled: false,
33
- proxy,
34
- });
35
- }
36
-
37
- function stripSyncTooling(targetRoot) {
38
- const packagePath = path.join(targetRoot, 'package.json');
39
- const packageJson = JSON.parse(readFile(packagePath));
40
- if (packageJson.scripts) {
41
- delete packageJson.scripts['forgeon:sync-integrations'];
42
- }
43
- writeJson(packagePath, packageJson);
44
-
45
- fs.rmSync(path.join(targetRoot, 'scripts', 'forgeon-sync-integrations.mjs'), { force: true });
46
- }
47
-
48
- async function captureLogs(work) {
49
- const lines = [];
50
- const originalLog = console.log;
51
- console.log = (...args) => {
52
- lines.push(args.join(' '));
53
- };
54
-
55
- try {
56
- await work(lines);
57
- } finally {
58
- console.log = originalLog;
59
- }
60
-
61
- return lines.join('\n');
62
- }
63
-
64
- describe('runAddModule', () => {
65
- const thisDir = path.dirname(fileURLToPath(import.meta.url));
66
- const packageRoot = path.resolve(thisDir, '..');
67
-
68
- it('installs files stack non-interactively with provider selection and restores sync tooling', async () => {
69
- const tempRoot = makeTempDir('forgeon-run-add-files-');
70
- const targetRoot = path.join(tempRoot, 'demo-run-add-files');
71
-
72
- try {
73
- scaffoldBaseProject({
74
- packageRoot,
75
- targetRoot,
76
- projectName: 'demo-run-add-files',
77
- proxy: 'nginx',
78
- });
79
- stripSyncTooling(targetRoot);
80
-
81
- const output = await captureLogs(async () => {
82
- await runAddModule([
83
- 'files',
84
- '--project',
85
- targetRoot,
86
- '--with-required',
87
- '--provider',
88
- 'files-storage-adapter=files-local',
89
- ]);
90
- });
91
-
92
- assert.equal(fs.existsSync(path.join(targetRoot, 'packages', 'db-prisma', 'package.json')), true);
93
- assert.equal(fs.existsSync(path.join(targetRoot, 'packages', 'files-local', 'package.json')), true);
94
- assert.equal(fs.existsSync(path.join(targetRoot, 'packages', 'files', 'package.json')), true);
95
- assert.equal(fs.existsSync(path.join(targetRoot, 'scripts', 'forgeon-sync-integrations.mjs')), true);
96
-
97
- const packageJson = JSON.parse(readFile(path.join(targetRoot, 'package.json')));
98
- assert.equal(packageJson.scripts['forgeon:sync-integrations'], 'node scripts/forgeon-sync-integrations.mjs');
99
-
100
- const compose = readFile(path.join(targetRoot, 'infra', 'docker', 'compose.yml'));
101
- assert.match(compose, /^\s{2}nginx:\s*$/m);
102
- assert.doesNotMatch(compose, /^\s{2}caddy:\s*$/m);
103
-
104
- const apiEnv = readFile(path.join(targetRoot, 'apps', 'api', '.env.example'));
105
- assert.match(apiEnv, /DATABASE_URL=postgresql:\/\/postgres:postgres@localhost:5432\/app\?schema=public/);
106
- assert.match(apiEnv, /FILES_STORAGE_DRIVER=local/);
107
-
108
- const healthController = readFile(path.join(targetRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'));
109
- assert.match(healthController, /@Post\('files'\)/);
110
-
111
- assert.match(output, /Recommended companion modules are available:/);
112
- assert.match(output, /No integration groups found\./);
113
- assert.match(output, /Next: run pnpm install/);
114
- } finally {
115
- fs.rmSync(tempRoot, { recursive: true, force: true });
116
- }
117
- });
118
-
119
- it('installs scheduler with required queue on proxy=none scaffold', async () => {
120
- const tempRoot = makeTempDir('forgeon-run-add-scheduler-');
121
- const targetRoot = path.join(tempRoot, 'demo-run-add-scheduler');
122
-
123
- try {
124
- scaffoldBaseProject({
125
- packageRoot,
126
- targetRoot,
127
- projectName: 'demo-run-add-scheduler',
128
- proxy: 'none',
129
- });
130
-
131
- const output = await captureLogs(async () => {
132
- await runAddModule(['scheduler', '--project', targetRoot, '--with-required']);
133
- });
134
-
135
- assert.equal(fs.existsSync(path.join(targetRoot, 'packages', 'queue', 'package.json')), true);
136
- assert.equal(fs.existsSync(path.join(targetRoot, 'packages', 'scheduler', 'package.json')), true);
137
-
138
- const compose = readFile(path.join(targetRoot, 'infra', 'docker', 'compose.yml'));
139
- assert.match(compose, /^\s{2}redis:\s*$/m);
140
- assert.doesNotMatch(compose, /^\s{2}caddy:\s*$/m);
141
- assert.doesNotMatch(compose, /^\s{2}nginx:\s*$/m);
142
-
143
- const healthController = readFile(path.join(targetRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'));
144
- assert.match(healthController, /@Get\('queue'\)/);
145
- assert.match(healthController, /@Get\('scheduler'\)/);
146
-
147
- assert.match(output, /No integration groups found\./);
148
- assert.match(output, /Next: run pnpm install/);
149
- } finally {
150
- fs.rmSync(tempRoot, { recursive: true, force: true });
151
- }
152
- });
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 { fileURLToPath } from 'node:url';
7
+ import { scaffoldProject } from './core/scaffold.mjs';
8
+ import { runAddModule } from './run-add-module.mjs';
9
+
10
+ function makeTempDir(prefix) {
11
+ return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
12
+ }
13
+
14
+ function readFile(filePath) {
15
+ return fs.readFileSync(filePath, 'utf8');
16
+ }
17
+
18
+ function writeJson(filePath, value) {
19
+ fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
20
+ }
21
+
22
+ function scaffoldBaseProject({ packageRoot, targetRoot, projectName, proxy }) {
23
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
24
+ scaffoldProject({
25
+ templateRoot,
26
+ packageRoot,
27
+ targetRoot,
28
+ projectName,
29
+ frontend: 'react',
30
+ db: 'prisma',
31
+ dbPrismaEnabled: false,
32
+ i18nEnabled: false,
33
+ proxy,
34
+ });
35
+ }
36
+
37
+ function stripSyncTooling(targetRoot) {
38
+ const packagePath = path.join(targetRoot, 'package.json');
39
+ const packageJson = JSON.parse(readFile(packagePath));
40
+ if (packageJson.scripts) {
41
+ delete packageJson.scripts['forgeon:sync-integrations'];
42
+ }
43
+ writeJson(packagePath, packageJson);
44
+
45
+ fs.rmSync(path.join(targetRoot, 'scripts', 'forgeon-sync-integrations.mjs'), { force: true });
46
+ }
47
+
48
+ async function captureLogs(work) {
49
+ const lines = [];
50
+ const originalLog = console.log;
51
+ console.log = (...args) => {
52
+ lines.push(args.join(' '));
53
+ };
54
+
55
+ try {
56
+ await work(lines);
57
+ } finally {
58
+ console.log = originalLog;
59
+ }
60
+
61
+ return lines.join('\n');
62
+ }
63
+
64
+ describe('runAddModule', () => {
65
+ const thisDir = path.dirname(fileURLToPath(import.meta.url));
66
+ const packageRoot = path.resolve(thisDir, '..');
67
+
68
+ it('lists implemented modules without status suffixes', async () => {
69
+ const output = await captureLogs(async () => {
70
+ await runAddModule(['--list']);
71
+ });
72
+
73
+ assert.match(output, /Available modules:/);
74
+ assert.doesNotMatch(output, /\(implemented\)/);
75
+ assert.match(output, /- files - /);
76
+ });
77
+
78
+ it('installs files stack non-interactively with provider selection and restores sync tooling', async () => {
79
+ const tempRoot = makeTempDir('forgeon-run-add-files-');
80
+ const targetRoot = path.join(tempRoot, 'demo-run-add-files');
81
+
82
+ try {
83
+ scaffoldBaseProject({
84
+ packageRoot,
85
+ targetRoot,
86
+ projectName: 'demo-run-add-files',
87
+ proxy: 'nginx',
88
+ });
89
+ stripSyncTooling(targetRoot);
90
+
91
+ const output = await captureLogs(async () => {
92
+ await runAddModule([
93
+ 'files',
94
+ '--project',
95
+ targetRoot,
96
+ '--with-required',
97
+ '--provider',
98
+ 'files-storage-adapter=files-local',
99
+ ]);
100
+ });
101
+
102
+ assert.equal(fs.existsSync(path.join(targetRoot, 'packages', 'db-prisma', 'package.json')), true);
103
+ assert.equal(fs.existsSync(path.join(targetRoot, 'packages', 'files-local', 'package.json')), true);
104
+ assert.equal(fs.existsSync(path.join(targetRoot, 'packages', 'files', 'package.json')), true);
105
+ assert.equal(fs.existsSync(path.join(targetRoot, 'scripts', 'forgeon-sync-integrations.mjs')), true);
106
+
107
+ const packageJson = JSON.parse(readFile(path.join(targetRoot, 'package.json')));
108
+ assert.equal(packageJson.scripts['forgeon:sync-integrations'], 'node scripts/forgeon-sync-integrations.mjs');
109
+ assert.equal(packageJson.scripts['add-all'], 'npx create-forgeon@latest add all --project .');
110
+
111
+ const compose = readFile(path.join(targetRoot, 'infra', 'docker', 'compose.yml'));
112
+ assert.match(compose, /^\s{2}nginx:\s*$/m);
113
+ assert.doesNotMatch(compose, /^\s{2}caddy:\s*$/m);
114
+
115
+ const apiEnv = readFile(path.join(targetRoot, 'apps', 'api', '.env.example'));
116
+ assert.match(apiEnv, /DATABASE_URL=postgresql:\/\/postgres:postgres@localhost:5432\/app\?schema=public/);
117
+ assert.match(apiEnv, /FILES_STORAGE_DRIVER=local/);
118
+
119
+ const healthController = readFile(path.join(targetRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'));
120
+ assert.match(healthController, /@Post\('files'\)/);
121
+
122
+ assert.match(output, /Recommended companion modules are available:/);
123
+ assert.match(output, /No integration groups found\./);
124
+ assert.match(output, /Next: run pnpm install/);
125
+ } finally {
126
+ fs.rmSync(tempRoot, { recursive: true, force: true });
127
+ }
128
+ });
129
+
130
+ it('installs all implemented modules non-interactively with the recommended provider', async () => {
131
+ const tempRoot = makeTempDir('forgeon-run-add-all-');
132
+ const targetRoot = path.join(tempRoot, 'demo-run-add-all');
133
+
134
+ try {
135
+ scaffoldBaseProject({
136
+ packageRoot,
137
+ targetRoot,
138
+ projectName: 'demo-run-add-all',
139
+ proxy: 'caddy',
140
+ });
141
+ stripSyncTooling(targetRoot);
142
+
143
+ const output = await captureLogs(async () => {
144
+ await runAddModule(['all', '--project', targetRoot]);
145
+ });
146
+
147
+ const expectedInstalledModules = [
148
+ 'db-prisma',
149
+ 'files-local',
150
+ 'files',
151
+ 'files-access',
152
+ 'files-quotas',
153
+ 'files-image',
154
+ 'i18n',
155
+ 'logger',
156
+ 'swagger',
157
+ 'accounts',
158
+ 'rate-limit',
159
+ 'rbac',
160
+ 'queue',
161
+ 'scheduler',
162
+ ];
163
+
164
+ for (const moduleId of expectedInstalledModules) {
165
+ const packageDir =
166
+ moduleId === 'accounts'
167
+ ? path.join(targetRoot, 'packages', 'accounts-api')
168
+ : path.join(targetRoot, 'packages', moduleId);
169
+ assert.equal(fs.existsSync(path.join(packageDir, 'package.json')), true, `expected ${moduleId}`);
170
+ }
171
+
172
+ assert.equal(fs.existsSync(path.join(targetRoot, 'packages', 'files-s3', 'package.json')), false);
173
+
174
+ const packageJson = JSON.parse(readFile(path.join(targetRoot, 'package.json')));
175
+ assert.equal(packageJson.scripts['forgeon:sync-integrations'], 'node scripts/forgeon-sync-integrations.mjs');
176
+ assert.equal(packageJson.scripts['add-all'], 'npx create-forgeon@latest add all --project .');
177
+
178
+ const healthController = readFile(path.join(targetRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'));
179
+ assert.match(healthController, /@Post\('files'\)/);
180
+ assert.match(healthController, /@Get\('files-image'\)/);
181
+ assert.match(healthController, /@Get\('queue'\)/);
182
+ assert.match(healthController, /@Get\('scheduler'\)/);
183
+ assert.match(healthController, /@Get\('rate-limit'\)/);
184
+
185
+ assert.match(output, /Found 1 integration group/);
186
+ assert.match(output, /Integration skipped\./);
187
+ assert.match(output, /Run later with: pnpm forgeon:sync-integrations/);
188
+ assert.match(output, /Next: run pnpm install/);
189
+ assert.doesNotMatch(output, /Recommended companion modules are available:/);
190
+ } finally {
191
+ fs.rmSync(tempRoot, { recursive: true, force: true });
192
+ }
193
+ });
194
+
195
+ it('installs scheduler with required queue on proxy=none scaffold', async () => {
196
+ const tempRoot = makeTempDir('forgeon-run-add-scheduler-');
197
+ const targetRoot = path.join(tempRoot, 'demo-run-add-scheduler');
198
+
199
+ try {
200
+ scaffoldBaseProject({
201
+ packageRoot,
202
+ targetRoot,
203
+ projectName: 'demo-run-add-scheduler',
204
+ proxy: 'none',
205
+ });
206
+
207
+ const output = await captureLogs(async () => {
208
+ await runAddModule(['scheduler', '--project', targetRoot, '--with-required']);
209
+ });
210
+
211
+ assert.equal(fs.existsSync(path.join(targetRoot, 'packages', 'queue', 'package.json')), true);
212
+ assert.equal(fs.existsSync(path.join(targetRoot, 'packages', 'scheduler', 'package.json')), true);
213
+
214
+ const compose = readFile(path.join(targetRoot, 'infra', 'docker', 'compose.yml'));
215
+ assert.match(compose, /^\s{2}redis:\s*$/m);
216
+ assert.doesNotMatch(compose, /^\s{2}caddy:\s*$/m);
217
+ assert.doesNotMatch(compose, /^\s{2}nginx:\s*$/m);
218
+
219
+ const healthController = readFile(path.join(targetRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'));
220
+ assert.match(healthController, /@Get\('queue'\)/);
221
+ assert.match(healthController, /@Get\('scheduler'\)/);
222
+
223
+ assert.match(output, /No integration groups found\./);
224
+ assert.match(output, /Next: run pnpm install/);
225
+ } finally {
226
+ fs.rmSync(tempRoot, { recursive: true, force: true });
227
+ }
228
+ });
153
229
  });
154
230
 
@@ -69,6 +69,7 @@ function ensureSyncTooling({ packageRoot, targetRoot }) {
69
69
  packageJson.scripts = {};
70
70
  }
71
71
  packageJson.scripts['forgeon:sync-integrations'] = 'node scripts/forgeon-sync-integrations.mjs';
72
+ packageJson.scripts['add-all'] = 'npx create-forgeon@latest add all --project .';
72
73
  writeJson(packagePath, packageJson);
73
74
  }
74
75
 
@@ -6,6 +6,7 @@
6
6
  "scripts": {
7
7
  "dev": "pnpm --parallel --filter @forgeon/api --filter @forgeon/web dev",
8
8
  "build": "pnpm -r build",
9
+ "add-all": "npx create-forgeon@latest add all --project .",
9
10
  "forgeon:sync-integrations": "node scripts/forgeon-sync-integrations.mjs",
10
11
  "create:forgeon": "node scripts/create-forgeon.mjs",
11
12
  "docker:up": "docker compose --env-file infra/docker/.env.example -f infra/docker/compose.yml up --build",
@@ -9,6 +9,7 @@
9
9
  },
10
10
  "dependencies": {
11
11
  "@forgeon/accounts-contracts": "workspace:*",
12
+ "@forgeon/db-prisma": "workspace:*",
12
13
  "@nestjs/common": "^11.0.1",
13
14
  "@nestjs/config": "^4.0.2",
14
15
  "@nestjs/jwt": "^11.0.1",