create-forgeon 0.3.22 → 0.3.24

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 (64) hide show
  1. package/README.md +14 -12
  2. package/package.json +1 -1
  3. package/src/modules/accounts.mjs +3 -1
  4. package/src/modules/communications.mjs +232 -0
  5. package/src/modules/dependencies.test.mjs +16 -2
  6. package/src/modules/executor.mjs +4 -0
  7. package/src/modules/executor.test.mjs +60 -31
  8. package/src/modules/files-access.mjs +25 -1
  9. package/src/modules/registry.mjs +18 -2
  10. package/src/modules/shared/probes.mjs +3 -1
  11. package/src/run-add-module.test.mjs +4 -0
  12. package/templates/base/apps/web/src/App.tsx +75 -11
  13. package/templates/base/apps/web/src/probes.ts +11 -1
  14. package/templates/base/apps/web/src/styles.css +25 -0
  15. package/templates/module-fragments/accounts/20_scope.md +3 -2
  16. package/templates/module-fragments/accounts/90_status_implemented.md +2 -2
  17. package/templates/module-fragments/accounts/90_status_planned.md +1 -1
  18. package/templates/module-fragments/communications/00_title.md +1 -0
  19. package/templates/module-fragments/communications/10_overview.md +6 -0
  20. package/templates/module-fragments/communications/20_scope.md +20 -0
  21. package/templates/module-fragments/communications/90_status_implemented.md +8 -0
  22. package/templates/module-presets/accounts/packages/accounts-api/package.json +1 -0
  23. package/templates/module-presets/accounts/packages/accounts-api/src/auth-core.service.ts +60 -20
  24. package/templates/module-presets/accounts/packages/accounts-api/src/forgeon-accounts.module.ts +2 -6
  25. package/templates/module-presets/accounts/packages/accounts-api/src/index.ts +1 -1
  26. package/templates/module-presets/communications/packages/communications/package.json +23 -0
  27. package/templates/module-presets/communications/packages/communications/src/communications-config.loader.ts +58 -0
  28. package/templates/module-presets/communications/packages/communications/src/communications-config.module.ts +11 -0
  29. package/templates/module-presets/communications/packages/communications/src/communications-config.service.ts +60 -0
  30. package/templates/module-presets/communications/packages/communications/src/communications-env.schema.ts +24 -0
  31. package/templates/module-presets/communications/packages/communications/src/communications.constants.ts +3 -0
  32. package/templates/module-presets/communications/packages/communications/src/communications.probe.controller.ts +18 -0
  33. package/templates/module-presets/communications/packages/communications/src/communications.service.ts +104 -0
  34. package/templates/module-presets/communications/packages/communications/src/communications.types.ts +55 -0
  35. package/templates/module-presets/communications/packages/communications/src/dto/send-communications-probe.dto.ts +6 -0
  36. package/templates/module-presets/communications/packages/communications/src/email/email-channel.service.ts +90 -0
  37. package/templates/module-presets/communications/packages/communications/src/email/email-provider.port.ts +16 -0
  38. package/templates/module-presets/communications/packages/communications/src/email/providers/gmail-smtp-email.provider.ts +64 -0
  39. package/templates/module-presets/communications/packages/communications/src/forgeon-communications.module.ts +64 -0
  40. package/templates/module-presets/communications/packages/communications/src/index.ts +21 -0
  41. package/templates/module-presets/communications/packages/communications/src/push/providers/stub-push.provider.ts +16 -0
  42. package/templates/module-presets/communications/packages/communications/src/push/push-channel.service.ts +56 -0
  43. package/templates/module-presets/communications/packages/communications/src/push/push-provider.port.ts +14 -0
  44. package/templates/module-presets/communications/packages/communications/src/sms/providers/stub-sms.provider.ts +16 -0
  45. package/templates/module-presets/communications/packages/communications/src/sms/sms-channel.service.ts +56 -0
  46. package/templates/module-presets/communications/packages/communications/src/sms/sms-provider.port.ts +14 -0
  47. package/templates/module-presets/communications/packages/communications/src/template-loader.service.ts +98 -0
  48. package/templates/module-presets/communications/packages/communications/src/template-renderer.service.ts +30 -0
  49. package/templates/module-presets/communications/packages/communications/tsconfig.json +9 -0
  50. package/templates/module-presets/communications/resources/communications/email/communications_probe.html +9 -0
  51. package/templates/module-presets/communications/resources/communications/email/communications_probe.subject.txt +1 -0
  52. package/templates/module-presets/communications/resources/communications/email/email_verification_code.html +8 -0
  53. package/templates/module-presets/communications/resources/communications/email/email_verification_code.subject.txt +1 -0
  54. package/templates/module-presets/communications/resources/communications/email/password_reset.html +8 -0
  55. package/templates/module-presets/communications/resources/communications/email/password_reset.subject.txt +1 -0
  56. package/templates/module-presets/communications/resources/communications/email/welcome_email.html +8 -0
  57. package/templates/module-presets/communications/resources/communications/email/welcome_email.subject.txt +1 -0
  58. package/templates/module-presets/communications/resources/communications/push/login_alert.json +4 -0
  59. package/templates/module-presets/communications/resources/communications/sms/phone_verification.txt +1 -0
  60. package/templates/module-presets/files-quotas/packages/files-quotas/package.json +21 -20
  61. package/templates/module-presets/files-quotas/packages/files-quotas/src/files-quotas.service.ts +118 -118
  62. package/templates/module-presets/files-quotas/packages/files-quotas/src/forgeon-files-quotas.module.ts +21 -19
  63. package/templates/module-presets/i18n/apps/web/src/App.tsx +72 -8
  64. package/templates/module-presets/accounts/packages/accounts-api/src/accounts-email.port.ts +0 -13
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # create-forgeon
1
+ # create-forgeon
2
2
 
3
3
  CLI package for generating Forgeon fullstack monorepo projects.
4
4
 
@@ -9,19 +9,20 @@ CLI package for generating Forgeon fullstack monorepo projects.
9
9
  > ![warning](https://img.shields.io/badge/STATUS-PRE--RELEASE%20DO%20NOT%20USE-red)
10
10
 
11
11
  ## Usage
12
-
12
+
13
13
  ```bash
14
14
  npx create-forgeon@latest my-app --i18n true --db-prisma true --proxy caddy
15
15
  npx create-forgeon@latest my-app --db-prisma false --proxy caddy
16
16
  ```
17
-
18
- If flags are omitted, the CLI asks interactive questions.
19
- Project name stays text input; fixed-choice prompts use arrow-key selection (`Up/Down + Enter`).
20
-
17
+
18
+ If flags are omitted, the CLI asks interactive questions.
19
+ Project name stays text input; fixed-choice prompts use arrow-key selection (`Up/Down + Enter`).
20
+
21
21
  ```bash
22
22
  npx create-forgeon@latest add --list
23
23
  npx create-forgeon@latest add i18n --project ./my-app
24
- npx create-forgeon@latest add jwt-auth --project ./my-app
24
+ npx create-forgeon@latest add communications --project ./my-app
25
+ npx create-forgeon@latest add accounts --project ./my-app --with-required
25
26
  npx create-forgeon@latest add files --with-required --provider db-adapter=db-prisma
26
27
  ```
27
28
 
@@ -29,16 +30,17 @@ npx create-forgeon@latest add files --with-required --provider db-adapter=db-pri
29
30
  cd my-app
30
31
  pnpm forgeon:sync-integrations
31
32
  ```
32
-
33
- ## Notes
34
-
33
+
34
+ ## Notes
35
+
35
36
  - Canonical runtime stack is fixed: NestJS + React + Docker.
36
37
  - DB is module-driven: `db-prisma` is default-on and can be disabled at scaffold time.
37
38
  - Reverse proxy options: `caddy` (default), `nginx`, `none`.
38
39
  - `add i18n` is implemented and applies backend/frontend i18n wiring.
39
- - `add jwt-auth` is implemented and auto-detects DB adapter support for refresh-token persistence.
40
+ - `add communications` is implemented and adds the shared email/sms/push orchestration surface.
41
+ - `add accounts` is implemented and installs the DB-backed accounts/authentication runtime.
40
42
  - Integration sync is bundled by default and runs after `add` commands (best-effort).
41
43
  - Module notes are written under `modules/<module-id>/README.md`.
42
44
  - Hard prerequisites are explicit:
43
45
  - TTY mode prompts for provider resolution and install plan confirmation
44
- - non-TTY mode can use `--with-required` plus `--provider <capability>=<module>`
46
+ - 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.3.22",
3
+ "version": "0.3.24",
4
4
  "description": "Forgeon project generator CLI",
5
5
  "license": "MIT",
6
6
  "author": "Forgeon",
@@ -336,7 +336,7 @@ function patchReadme(targetRoot) {
336
336
  '',
337
337
  'Current boundaries:',
338
338
  '- `UsersModule.register({ user, profile, settings })` controls runtime defaults for JSON-backed extension fields',
339
- '- email verification and password reset use an internal email stub through `AccountsEmailPort`',
339
+ '- email verification and password-reset request flows send best-effort communication intents through `CommunicationsService`',
340
340
  '- base accounts schema does not include RBAC storage',
341
341
  ACCOUNTS_RBAC_MARKERS.start,
342
342
  ACCOUNTS_DEFAULT_RBAC_BLOCK,
@@ -420,3 +420,5 @@ export function applyAccountsModule({ packageRoot, targetRoot }) {
420
420
 
421
421
 
422
422
 
423
+
424
+
@@ -0,0 +1,232 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { copyRecursive, writeJson } from '../utils/fs.mjs';
4
+ import {
5
+ ensureBuildSteps,
6
+ ensureDependency,
7
+ ensureLineAfter,
8
+ ensureLineBefore,
9
+ upsertEnvLines,
10
+ } from './shared/patch-utils.mjs';
11
+ import { patchAppModuleRegistration } from './shared/nest-runtime-wiring.mjs';
12
+ import { ensureWebProbeDefinition, resolveProbeTargets } from './shared/probes.mjs';
13
+
14
+ function copyFromPreset(packageRoot, targetRoot, relativePath) {
15
+ const source = path.join(packageRoot, 'templates', 'module-presets', 'communications', relativePath);
16
+ if (!fs.existsSync(source)) {
17
+ throw new Error(`Missing communications preset template: ${source}`);
18
+ }
19
+ const destination = path.join(targetRoot, relativePath);
20
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
21
+ copyRecursive(source, destination);
22
+ }
23
+
24
+ function patchApiPackage(targetRoot) {
25
+ const packagePath = path.join(targetRoot, 'apps', 'api', 'package.json');
26
+ if (!fs.existsSync(packagePath)) {
27
+ return;
28
+ }
29
+
30
+ const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
31
+ ensureDependency(packageJson, '@forgeon/communications', 'workspace:*');
32
+ ensureBuildSteps(packageJson, 'predev', ['pnpm --filter @forgeon/communications build']);
33
+ writeJson(packagePath, packageJson);
34
+ }
35
+
36
+ function patchAppModule(targetRoot) {
37
+ patchAppModuleRegistration(targetRoot, {
38
+ importLine: "import { communicationsConfig, communicationsEnvSchema, ForgeonCommunicationsModule } from '@forgeon/communications';",
39
+ loadItem: 'communicationsConfig',
40
+ envSchema: 'communicationsEnvSchema',
41
+ moduleLine: ' ForgeonCommunicationsModule.register(),',
42
+ afterAnchors: [' DbPrismaModule,', ' ForgeonLoggerModule,'],
43
+ fallbackAnchor: ' CoreErrorsModule,',
44
+ });
45
+ }
46
+
47
+ function registerWebProbe(targetRoot) {
48
+ const probeTargets = resolveProbeTargets({ targetRoot, moduleId: 'communications' });
49
+ ensureWebProbeDefinition({
50
+ targetRoot,
51
+ probeTargets,
52
+ definition: {
53
+ id: 'communications',
54
+ title: 'Communications',
55
+ buttonLabel: 'Send communications probe email',
56
+ resultTitle: 'Communications probe response',
57
+ path: '/health/communications',
58
+ request: {
59
+ method: 'POST',
60
+ body: {
61
+ email: '$INPUT.email$',
62
+ },
63
+ },
64
+ inputs: [
65
+ {
66
+ id: 'email',
67
+ label: 'Test email',
68
+ type: 'email',
69
+ placeholder: 'you@example.com',
70
+ defaultValue: 'probe@example.com',
71
+ },
72
+ ],
73
+ },
74
+ });
75
+ }
76
+
77
+ function patchApiDockerfile(targetRoot) {
78
+ const dockerfilePath = path.join(targetRoot, 'apps', 'api', 'Dockerfile');
79
+ if (!fs.existsSync(dockerfilePath)) {
80
+ return;
81
+ }
82
+
83
+ let content = fs.readFileSync(dockerfilePath, 'utf8').replace(/\r\n/g, '\n');
84
+ const packageAnchors = [
85
+ 'COPY packages/accounts-api/package.json packages/accounts-api/package.json',
86
+ 'COPY packages/logger/package.json packages/logger/package.json',
87
+ 'COPY packages/i18n/package.json packages/i18n/package.json',
88
+ 'COPY packages/db-prisma/package.json packages/db-prisma/package.json',
89
+ 'COPY packages/core/package.json packages/core/package.json',
90
+ ];
91
+ const packageAnchor = packageAnchors.find((line) => content.includes(line)) ?? packageAnchors.at(-1);
92
+ content = ensureLineAfter(content, packageAnchor, 'COPY packages/communications/package.json packages/communications/package.json');
93
+
94
+ const sourceAnchors = [
95
+ 'COPY packages/accounts-api packages/accounts-api',
96
+ 'COPY packages/logger packages/logger',
97
+ 'COPY packages/i18n packages/i18n',
98
+ 'COPY packages/db-prisma packages/db-prisma',
99
+ 'COPY packages/core packages/core',
100
+ ];
101
+ const sourceAnchor = sourceAnchors.find((line) => content.includes(line)) ?? sourceAnchors.at(-1);
102
+ content = ensureLineAfter(content, sourceAnchor, 'COPY packages/communications packages/communications');
103
+
104
+ content = content.replace(/^RUN pnpm --filter @forgeon\/communications build\r?\n?/gm, '');
105
+ const buildAnchor = content.includes('RUN pnpm --filter @forgeon/api prisma:generate')
106
+ ? 'RUN pnpm --filter @forgeon/api prisma:generate'
107
+ : 'RUN pnpm --filter @forgeon/api build';
108
+ content = ensureLineBefore(content, buildAnchor, 'RUN pnpm --filter @forgeon/communications build');
109
+
110
+ fs.writeFileSync(dockerfilePath, `${content.trimEnd()}\n`, 'utf8');
111
+ }
112
+
113
+ function patchCompose(targetRoot) {
114
+ const composePath = path.join(targetRoot, 'infra', 'docker', 'compose.yml');
115
+ if (!fs.existsSync(composePath)) {
116
+ return;
117
+ }
118
+
119
+ let content = fs.readFileSync(composePath, 'utf8').replace(/\r\n/g, '\n');
120
+ if (!content.includes('COMMUNICATIONS_EMAIL_PROVIDER: ${COMMUNICATIONS_EMAIL_PROVIDER}')) {
121
+ content = content.replace(
122
+ /^(\s+API_PREFIX:.*)$/m,
123
+ `$1
124
+ COMMUNICATIONS_TEMPLATES_ROOT: \${COMMUNICATIONS_TEMPLATES_ROOT}
125
+ COMMUNICATIONS_EMAIL_PROVIDER: \${COMMUNICATIONS_EMAIL_PROVIDER}
126
+ COMMUNICATIONS_EMAIL_FROM: \${COMMUNICATIONS_EMAIL_FROM}
127
+ COMMUNICATIONS_EMAIL_REPLY_TO: \${COMMUNICATIONS_EMAIL_REPLY_TO}
128
+ COMMUNICATIONS_EMAIL_SUBJECT_PREFIX: \${COMMUNICATIONS_EMAIL_SUBJECT_PREFIX}
129
+ COMMUNICATIONS_EMAIL_SMTP_HOST: \${COMMUNICATIONS_EMAIL_SMTP_HOST}
130
+ COMMUNICATIONS_EMAIL_SMTP_PORT: \${COMMUNICATIONS_EMAIL_SMTP_PORT}
131
+ COMMUNICATIONS_EMAIL_SMTP_SECURE: \${COMMUNICATIONS_EMAIL_SMTP_SECURE}
132
+ COMMUNICATIONS_EMAIL_SMTP_USER: \${COMMUNICATIONS_EMAIL_SMTP_USER}
133
+ COMMUNICATIONS_EMAIL_SMTP_PASS: \${COMMUNICATIONS_EMAIL_SMTP_PASS}
134
+ COMMUNICATIONS_SMS_PROVIDER: \${COMMUNICATIONS_SMS_PROVIDER}
135
+ COMMUNICATIONS_PUSH_PROVIDER: \${COMMUNICATIONS_PUSH_PROVIDER}`,
136
+ );
137
+ }
138
+
139
+ fs.writeFileSync(composePath, `${content.trimEnd()}\n`, 'utf8');
140
+ }
141
+
142
+ function patchReadme(targetRoot) {
143
+ const readmePath = path.join(targetRoot, 'README.md');
144
+ if (!fs.existsSync(readmePath)) {
145
+ return;
146
+ }
147
+
148
+ const section = [
149
+ '## Communications Module',
150
+ '',
151
+ 'The communications add-module provides a single orchestration surface for email, sms, and push delivery.',
152
+ '',
153
+ 'What it adds:',
154
+ '- `@forgeon/communications` backend runtime with file-based template loading and simple placeholder rendering',
155
+ '- real email delivery through the Gmail SMTP transport configuration',
156
+ '- SMS and PUSH stub channels for future expansion',
157
+ '- probe routes: `GET /api/health/communications` and `POST /api/health/communications`',
158
+ '- generated resources under `resources/communications/email`, `resources/communications/sms`, and `resources/communications/push`',
159
+ '',
160
+ 'Current boundaries:',
161
+ '- domain modules should inject only `CommunicationsService`',
162
+ '- provider selection is module-owned configuration, never a runtime input field',
163
+ '- queues, scheduling, delivery history, and retries are intentionally out of scope for v1',
164
+ '',
165
+ 'Example env keys:',
166
+ '- `COMMUNICATIONS_EMAIL_PROVIDER=gmail-smtp`',
167
+ '- `COMMUNICATIONS_EMAIL_SMTP_HOST=smtp.gmail.com`',
168
+ '- `COMMUNICATIONS_EMAIL_SMTP_USER=`',
169
+ '- `COMMUNICATIONS_EMAIL_SMTP_PASS=`',
170
+ ].join('\n');
171
+
172
+ let content = fs.readFileSync(readmePath, 'utf8').replace(/\r\n/g, '\n');
173
+ const sectionHeading = '## Communications Module';
174
+ if (content.includes(sectionHeading)) {
175
+ const start = content.indexOf(sectionHeading);
176
+ const tail = content.slice(start + sectionHeading.length);
177
+ const nextHeadingMatch = tail.match(/\n##\s+/);
178
+ const end =
179
+ nextHeadingMatch && nextHeadingMatch.index !== undefined
180
+ ? start + sectionHeading.length + nextHeadingMatch.index + 1
181
+ : content.length;
182
+ content = `${content.slice(0, start)}${section}\n\n${content.slice(end).replace(/^\n+/, '')}`;
183
+ } else if (content.includes('## Prisma In Docker Start')) {
184
+ content = content.replace('## Prisma In Docker Start', `${section}\n\n## Prisma In Docker Start`);
185
+ } else {
186
+ content = `${content.trimEnd()}\n\n${section}\n`;
187
+ }
188
+
189
+ fs.writeFileSync(readmePath, `${content.trimEnd()}\n`, 'utf8');
190
+ }
191
+
192
+ export function applyCommunicationsModule({ packageRoot, targetRoot }) {
193
+ copyFromPreset(packageRoot, targetRoot, path.join('packages', 'communications'));
194
+ copyFromPreset(packageRoot, targetRoot, path.join('resources', 'communications'));
195
+
196
+ patchApiPackage(targetRoot);
197
+ patchAppModule(targetRoot);
198
+ registerWebProbe(targetRoot);
199
+ patchApiDockerfile(targetRoot);
200
+ patchCompose(targetRoot);
201
+ patchReadme(targetRoot);
202
+
203
+ upsertEnvLines(path.join(targetRoot, 'apps', 'api', '.env.example'), [
204
+ 'COMMUNICATIONS_TEMPLATES_ROOT=resources/communications',
205
+ 'COMMUNICATIONS_EMAIL_PROVIDER=gmail-smtp',
206
+ 'COMMUNICATIONS_EMAIL_FROM=Forgeon <no-reply@example.com>',
207
+ 'COMMUNICATIONS_EMAIL_REPLY_TO=',
208
+ 'COMMUNICATIONS_EMAIL_SUBJECT_PREFIX=[Forgeon]',
209
+ 'COMMUNICATIONS_EMAIL_SMTP_HOST=smtp.gmail.com',
210
+ 'COMMUNICATIONS_EMAIL_SMTP_PORT=587',
211
+ 'COMMUNICATIONS_EMAIL_SMTP_SECURE=false',
212
+ 'COMMUNICATIONS_EMAIL_SMTP_USER=',
213
+ 'COMMUNICATIONS_EMAIL_SMTP_PASS=',
214
+ 'COMMUNICATIONS_SMS_PROVIDER=stub',
215
+ 'COMMUNICATIONS_PUSH_PROVIDER=stub',
216
+ ]);
217
+
218
+ upsertEnvLines(path.join(targetRoot, 'infra', 'docker', '.env.example'), [
219
+ 'COMMUNICATIONS_TEMPLATES_ROOT=resources/communications',
220
+ 'COMMUNICATIONS_EMAIL_PROVIDER=gmail-smtp',
221
+ 'COMMUNICATIONS_EMAIL_FROM=Forgeon <no-reply@example.com>',
222
+ 'COMMUNICATIONS_EMAIL_REPLY_TO=',
223
+ 'COMMUNICATIONS_EMAIL_SUBJECT_PREFIX=[Forgeon]',
224
+ 'COMMUNICATIONS_EMAIL_SMTP_HOST=smtp.gmail.com',
225
+ 'COMMUNICATIONS_EMAIL_SMTP_PORT=587',
226
+ 'COMMUNICATIONS_EMAIL_SMTP_SECURE=false',
227
+ 'COMMUNICATIONS_EMAIL_SMTP_USER=',
228
+ 'COMMUNICATIONS_EMAIL_SMTP_PASS=',
229
+ 'COMMUNICATIONS_SMS_PROVIDER=stub',
230
+ 'COMMUNICATIONS_PUSH_PROVIDER=stub',
231
+ ]);
232
+ }
@@ -26,13 +26,22 @@ const TEST_PRESETS = [
26
26
  requires: [],
27
27
  optionalIntegrations: [],
28
28
  },
29
+ {
30
+ id: 'communications',
31
+ label: 'Communications',
32
+ implemented: true,
33
+ detectionPaths: ['packages/communications/package.json'],
34
+ provides: ['communications-runtime'],
35
+ requires: [],
36
+ optionalIntegrations: [],
37
+ },
29
38
  {
30
39
  id: 'accounts',
31
40
  label: 'Accounts',
32
41
  implemented: true,
33
42
  detectionPaths: ['packages/accounts-api/package.json'],
34
43
  provides: ['accounts-runtime'],
35
- requires: [{ type: 'capability', id: 'db-adapter' }],
44
+ requires: [{ type: 'capability', id: 'db-adapter' }, { type: 'capability', id: 'communications-runtime' }],
36
45
  optionalIntegrations: [
37
46
  {
38
47
  id: 'accounts-rbac',
@@ -204,9 +213,10 @@ describe('module dependency helpers', () => {
204
213
  });
205
214
 
206
215
  assert.equal(result.cancelled, false);
207
- assert.deepEqual(result.moduleSequence, ['db-prisma', 'accounts']);
216
+ assert.deepEqual(result.moduleSequence, ['db-prisma', 'communications', 'accounts']);
208
217
  assert.deepEqual(result.selectedProviders, {
209
218
  'db-adapter': 'db-prisma',
219
+ 'communications-runtime': 'communications',
210
220
  });
211
221
  } finally {
212
222
  fs.rmSync(targetRoot, { recursive: true, force: true });
@@ -375,6 +385,7 @@ describe('module dependency helpers', () => {
375
385
  assert.equal(result.cancelled, false);
376
386
  assert.deepEqual(result.moduleSequence, [
377
387
  'db-prisma',
388
+ 'communications',
378
389
  'accounts',
379
390
  'rbac',
380
391
  'files-local',
@@ -388,6 +399,7 @@ describe('module dependency helpers', () => {
388
399
  assert.deepEqual(result.selectedProviders, {
389
400
  'files-storage-adapter': 'files-local',
390
401
  'db-adapter': 'db-prisma',
402
+ 'communications-runtime': 'communications',
391
403
  'files-runtime': 'files',
392
404
  'queue-runtime': 'queue',
393
405
  });
@@ -499,3 +511,5 @@ describe('module dependency helpers', () => {
499
511
  }
500
512
  });
501
513
  });
514
+
515
+
@@ -11,6 +11,7 @@ import { applyFilesLocalModule } from './files-local.mjs';
11
11
  import { applyFilesS3Module } from './files-s3.mjs';
12
12
  import { applyI18nModule } from './i18n.mjs';
13
13
  import { applyAccountsModule } from './accounts.mjs';
14
+ import { applyCommunicationsModule } from './communications.mjs';
14
15
  import { applyLoggerModule } from './logger.mjs';
15
16
  import { applyRateLimitModule } from './rate-limit.mjs';
16
17
  import { applyRbacModule } from './rbac.mjs';
@@ -44,6 +45,7 @@ const MODULE_APPLIERS = {
44
45
  'files-s3': applyFilesS3Module,
45
46
  i18n: applyI18nModule,
46
47
  accounts: applyAccountsModule,
48
+ communications: applyCommunicationsModule,
47
49
  logger: applyLoggerModule,
48
50
  queue: applyQueueModule,
49
51
  'rate-limit': applyRateLimitModule,
@@ -86,3 +88,5 @@ export function addModule({ moduleId, targetRoot, packageRoot, writeDocs = true
86
88
  }
87
89
 
88
90
 
91
+
92
+
@@ -506,6 +506,16 @@ function assertFilesAccessWiring(projectRoot) {
506
506
  assert.match(filesController, /@Req\(\) req: any/);
507
507
  assert.match(filesController, /openDownload\(publicId,\s*variant\)/);
508
508
 
509
+ const filesModule = fs.readFileSync(
510
+ path.join(projectRoot, 'packages', 'files', 'src', 'forgeon-files.module.ts'),
511
+ 'utf8',
512
+ );
513
+ assert.match(filesModule, /import \{ ForgeonFilesAccessModule \} from '@forgeon\/files-access';/);
514
+ assert.match(
515
+ filesModule,
516
+ /imports: \[FilesConfigModule, ForgeonFilesAccessModule, (?:ForgeonFilesImageModule, )?DbPrismaModule, \.\.\.\(options\.imports \?\? \[\]\)\],/,
517
+ );
518
+
509
519
  const healthController = fs.readFileSync(
510
520
  path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'),
511
521
  'utf8',
@@ -532,14 +542,14 @@ function assertFilesQuotasWiring(projectRoot) {
532
542
  assert.match(appModule, /filesQuotasEnvSchema/);
533
543
  assert.match(appModule, /ForgeonFilesQuotasModule/);
534
544
 
535
- const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
536
- assert.match(apiPackage, /@forgeon\/files-quotas/);
537
- assert.match(apiPackage, /pnpm --filter @forgeon\/files-quotas build/);
538
- assert.equal(
539
- apiPackage.indexOf('pnpm --filter @forgeon/files-quotas build') >
540
- apiPackage.indexOf('pnpm --filter @forgeon/files build'),
541
- true,
542
- );
545
+ const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
546
+ assert.match(apiPackage, /@forgeon\/files-quotas/);
547
+ assert.match(apiPackage, /pnpm --filter @forgeon\/files-quotas build/);
548
+ assert.equal(
549
+ apiPackage.indexOf('pnpm --filter @forgeon/files-quotas build') >
550
+ apiPackage.indexOf('pnpm --filter @forgeon/files build'),
551
+ true,
552
+ );
543
553
 
544
554
  const filesPackage = fs.readFileSync(path.join(projectRoot, 'packages', 'files', 'package.json'), 'utf8');
545
555
  assert.doesNotMatch(filesPackage, /@forgeon\/files-quotas/);
@@ -550,13 +560,13 @@ function assertFilesQuotasWiring(projectRoot) {
550
560
  apiDockerfile,
551
561
  /COPY packages\/files-quotas\/package\.json packages\/files-quotas\/package\.json/,
552
562
  );
553
- assert.match(apiDockerfile, /COPY packages\/files-quotas packages\/files-quotas/);
554
- assert.match(apiDockerfile, /RUN pnpm --filter @forgeon\/files-quotas build/);
555
- assert.equal(
556
- apiDockerfile.indexOf('RUN pnpm --filter @forgeon/files-quotas build') >
557
- apiDockerfile.indexOf('RUN pnpm --filter @forgeon/files build'),
558
- true,
559
- );
563
+ assert.match(apiDockerfile, /COPY packages\/files-quotas packages\/files-quotas/);
564
+ assert.match(apiDockerfile, /RUN pnpm --filter @forgeon\/files-quotas build/);
565
+ assert.equal(
566
+ apiDockerfile.indexOf('RUN pnpm --filter @forgeon/files-quotas build') >
567
+ apiDockerfile.indexOf('RUN pnpm --filter @forgeon/files build'),
568
+ true,
569
+ );
560
570
 
561
571
  const filesController = fs.readFileSync(
562
572
  path.join(projectRoot, 'packages', 'files', 'src', 'files.controller.ts'),
@@ -591,6 +601,22 @@ function assertFilesQuotasWiring(projectRoot) {
591
601
  const readme = fs.readFileSync(path.join(projectRoot, 'README.md'), 'utf8');
592
602
  assert.match(readme, /## Files Quotas Module/);
593
603
  assert.match(readme, /owner-based limits/i);
604
+
605
+ const filesQuotasService = fs.readFileSync(
606
+ path.join(projectRoot, 'packages', 'files-quotas', 'src', 'files-quotas.service.ts'),
607
+ 'utf8',
608
+ );
609
+ assert.match(filesQuotasService, /FilesStore/);
610
+ assert.match(filesQuotasService, /filesStore\.countOwnerUsage/);
611
+ assert.doesNotMatch(filesQuotasService, /FilesService/);
612
+
613
+ const filesQuotasModule = fs.readFileSync(
614
+ path.join(projectRoot, 'packages', 'files-quotas', 'src', 'forgeon-files-quotas.module.ts'),
615
+ 'utf8',
616
+ );
617
+ assert.match(filesQuotasModule, /DbPrismaModule/);
618
+ assert.match(filesQuotasModule, /FilesStore/);
619
+ assert.doesNotMatch(filesQuotasModule, /ForgeonFilesModule/);
594
620
  }
595
621
 
596
622
  function assertFilesImageWiring(projectRoot) {
@@ -611,20 +637,20 @@ function assertFilesImageWiring(projectRoot) {
611
637
  const filesPackage = fs.readFileSync(path.join(projectRoot, 'packages', 'files', 'package.json'), 'utf8');
612
638
  assert.match(filesPackage, /@forgeon\/files-image/);
613
639
 
614
- const filesModule = fs.readFileSync(
615
- path.join(projectRoot, 'packages', 'files', 'src', 'forgeon-files.module.ts'),
616
- 'utf8',
617
- );
618
- assert.match(filesModule, /ForgeonFilesImageModule/);
619
- assert.match(
620
- filesModule,
621
- /imports: \[FilesConfigModule, (?:ForgeonFilesAccessModule, )?ForgeonFilesImageModule, DbPrismaModule, \.\.\.\(options\.imports \?\? \[\]\)\],/,
622
- );
623
-
624
- const filesService = fs.readFileSync(
625
- path.join(projectRoot, 'packages', 'files', 'src', 'files.service.ts'),
626
- 'utf8',
627
- );
640
+ const filesModule = fs.readFileSync(
641
+ path.join(projectRoot, 'packages', 'files', 'src', 'forgeon-files.module.ts'),
642
+ 'utf8',
643
+ );
644
+ assert.match(filesModule, /ForgeonFilesImageModule/);
645
+ assert.match(
646
+ filesModule,
647
+ /imports: \[FilesConfigModule, (?:ForgeonFilesAccessModule, )?ForgeonFilesImageModule, DbPrismaModule, \.\.\.\(options\.imports \?\? \[\]\)\],/,
648
+ );
649
+
650
+ const filesService = fs.readFileSync(
651
+ path.join(projectRoot, 'packages', 'files', 'src', 'files.service.ts'),
652
+ 'utf8',
653
+ );
628
654
  assert.match(filesService, /FilesImageService/);
629
655
  assert.match(filesService, /filesImageService\.sanitizeForStorage/);
630
656
  assert.match(filesService, /sanitizeForStorage\({/);
@@ -770,7 +796,7 @@ function assertAccountsWiring(projectRoot) {
770
796
  const readme = fs.readFileSync(path.join(projectRoot, 'README.md'), 'utf8');
771
797
  assert.match(readme, /## Accounts Module/);
772
798
  assert.match(readme, /owner-scoped user routes/);
773
- assert.match(readme, /AccountsEmailPort/);
799
+ assert.match(readme, /CommunicationsService/);
774
800
 
775
801
  const authServiceSource = fs.readFileSync(
776
802
  path.join(projectRoot, 'packages', 'accounts-api', 'src', 'auth.service.ts'),
@@ -789,7 +815,8 @@ function assertAccountsWiring(projectRoot) {
789
815
  path.join(projectRoot, 'packages', 'accounts-api', 'package.json'),
790
816
  'utf8',
791
817
  );
792
- assert.match(accountsApiPackage, /@forgeon\/db-prisma/);
818
+ assert.match(accountsApiPackage, /@forgeon\/db-prisma/);
819
+ assert.match(accountsApiPackage, /@forgeon\/communications/);
793
820
 
794
821
  const authStoreSource = fs.readFileSync(
795
822
  path.join(projectRoot, 'packages', 'accounts-api', 'src', 'auth.store.ts'),
@@ -2681,5 +2708,7 @@ describe('addModule', () => {
2681
2708
 
2682
2709
 
2683
2710
 
2711
+
2712
+
2684
2713
 
2685
2714
 
@@ -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 { copyRecursive, writeJson } from '../utils/fs.mjs';
4
4
  import {
@@ -51,6 +51,29 @@ function patchFilesPackage(targetRoot) {
51
51
  writeJson(packagePath, packageJson);
52
52
  }
53
53
 
54
+ function patchFilesModule(targetRoot) {
55
+ const filePath = path.join(targetRoot, 'packages', 'files', 'src', 'forgeon-files.module.ts');
56
+ if (!fs.existsSync(filePath)) {
57
+ return;
58
+ }
59
+
60
+ let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
61
+ content = ensureImportLine(content, "import { ForgeonFilesAccessModule } from '@forgeon/files-access';");
62
+
63
+ if (!content.includes('imports: [FilesConfigModule, ForgeonFilesAccessModule,')) {
64
+ content = content.replace(
65
+ 'imports: [FilesConfigModule, ForgeonFilesImageModule, DbPrismaModule, ...(options.imports ?? [])],',
66
+ 'imports: [FilesConfigModule, ForgeonFilesAccessModule, ForgeonFilesImageModule, DbPrismaModule, ...(options.imports ?? [])],',
67
+ );
68
+ content = content.replace(
69
+ 'imports: [FilesConfigModule, DbPrismaModule, ...(options.imports ?? [])],',
70
+ 'imports: [FilesConfigModule, ForgeonFilesAccessModule, DbPrismaModule, ...(options.imports ?? [])],',
71
+ );
72
+ }
73
+
74
+ fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
75
+ }
76
+
54
77
  function patchAppModule(targetRoot) {
55
78
  const filePath = path.join(targetRoot, 'apps', 'api', 'src', 'app.module.ts');
56
79
  if (!fs.existsSync(filePath)) {
@@ -366,6 +389,7 @@ export function applyFilesAccessModule({ packageRoot, targetRoot }) {
366
389
 
367
390
  patchApiPackage(targetRoot);
368
391
  patchFilesPackage(targetRoot);
392
+ patchFilesModule(targetRoot);
369
393
  patchAppModule(targetRoot);
370
394
  patchFilesController(targetRoot);
371
395
  patchHealthController(targetRoot, probeTargets);
@@ -139,16 +139,29 @@ const MODULE_PRESETS = {
139
139
  optionalIntegrations: [],
140
140
  docFragments: ['00_title', '10_overview', '20_scope', '90_status_implemented'],
141
141
  },
142
+ communications: {
143
+ id: 'communications',
144
+ label: 'Communications',
145
+ category: 'platform-communications',
146
+ implemented: true,
147
+ description:
148
+ 'Canonical communication orchestration module with file-based templates, Gmail SMTP email delivery, and SMS/PUSH stubs.',
149
+ detectionPaths: ['packages/communications/package.json'],
150
+ provides: ['communications-runtime'],
151
+ requires: [],
152
+ optionalIntegrations: [],
153
+ docFragments: ['00_title', '10_overview', '20_scope', '90_status_implemented'],
154
+ },
142
155
  accounts: {
143
156
  id: 'accounts',
144
157
  label: 'Accounts',
145
158
  category: 'auth-security',
146
159
  implemented: true,
147
160
  description:
148
- 'Accounts umbrella module with DB-backed users/auth runtime, argon2 passwords, JWT access+refresh rotation, and owner-scoped self-service routes.',
161
+ 'Accounts umbrella module with DB-backed users/auth runtime, argon2 passwords, JWT access+refresh rotation, owner-scoped self-service routes, and communications integration.',
149
162
  detectionPaths: ['packages/accounts-api/package.json'],
150
163
  provides: ['accounts-runtime'],
151
- requires: [{ type: 'capability', id: 'db-adapter' }],
164
+ requires: [{ type: 'capability', id: 'db-adapter' }, { type: 'capability', id: 'communications-runtime' }],
152
165
  optionalIntegrations: [
153
166
  {
154
167
  id: 'accounts-rbac',
@@ -307,3 +320,6 @@ export function ensureModuleExists(moduleId) {
307
320
 
308
321
 
309
322
 
323
+
324
+
325
+
@@ -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 { readJson } from '../../utils/fs.mjs';
4
4
 
@@ -17,6 +17,7 @@ const probeOrders = {
17
17
  validation: 30,
18
18
  db: 40,
19
19
  auth: 50,
20
+ communications: 55,
20
21
  rbac: 60,
21
22
  'rate-limit': 70,
22
23
  files: 80,
@@ -233,3 +234,4 @@ export function readManagedWebProbeDefinitions(targetRoot) {
233
234
  }
234
235
 
235
236
 
237
+
@@ -154,6 +154,7 @@ describe('runAddModule', () => {
154
154
  'i18n',
155
155
  'logger',
156
156
  'swagger',
157
+ 'communications',
157
158
  'accounts',
158
159
  'rate-limit',
159
160
  'rbac',
@@ -228,3 +229,6 @@ describe('runAddModule', () => {
228
229
  });
229
230
  });
230
231
 
232
+
233
+
234
+