create-forgeon 0.3.23 → 0.3.25

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 (60) 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 +233 -0
  5. package/src/modules/dependencies.test.mjs +22 -11
  6. package/src/modules/executor.mjs +4 -0
  7. package/src/modules/executor.test.mjs +93 -2
  8. package/src/modules/registry.mjs +24 -8
  9. package/src/modules/shared/probes.mjs +3 -1
  10. package/src/run-add-module.test.mjs +4 -0
  11. package/templates/base/apps/web/src/App.tsx +75 -11
  12. package/templates/base/apps/web/src/probes.ts +11 -1
  13. package/templates/base/apps/web/src/styles.css +25 -0
  14. package/templates/module-fragments/accounts/20_scope.md +3 -2
  15. package/templates/module-fragments/accounts/90_status_implemented.md +2 -2
  16. package/templates/module-fragments/accounts/90_status_planned.md +1 -1
  17. package/templates/module-fragments/communications/00_title.md +1 -0
  18. package/templates/module-fragments/communications/10_overview.md +6 -0
  19. package/templates/module-fragments/communications/20_scope.md +23 -0
  20. package/templates/module-fragments/communications/90_status_implemented.md +8 -0
  21. package/templates/module-presets/accounts/packages/accounts-api/package.json +1 -0
  22. package/templates/module-presets/accounts/packages/accounts-api/src/auth-core.service.ts +60 -20
  23. package/templates/module-presets/accounts/packages/accounts-api/src/forgeon-accounts.module.ts +1 -7
  24. package/templates/module-presets/accounts/packages/accounts-api/src/index.ts +1 -1
  25. package/templates/module-presets/communications/packages/communications/package.json +23 -0
  26. package/templates/module-presets/communications/packages/communications/src/communications-config.loader.ts +59 -0
  27. package/templates/module-presets/communications/packages/communications/src/communications-config.module.ts +11 -0
  28. package/templates/module-presets/communications/packages/communications/src/communications-config.service.ts +60 -0
  29. package/templates/module-presets/communications/packages/communications/src/communications-env.schema.ts +40 -0
  30. package/templates/module-presets/communications/packages/communications/src/communications.constants.ts +3 -0
  31. package/templates/module-presets/communications/packages/communications/src/communications.probe.controller.ts +18 -0
  32. package/templates/module-presets/communications/packages/communications/src/communications.service.ts +104 -0
  33. package/templates/module-presets/communications/packages/communications/src/communications.types.ts +55 -0
  34. package/templates/module-presets/communications/packages/communications/src/dto/send-communications-probe.dto.ts +6 -0
  35. package/templates/module-presets/communications/packages/communications/src/email/email-channel.service.ts +90 -0
  36. package/templates/module-presets/communications/packages/communications/src/email/email-provider.port.ts +16 -0
  37. package/templates/module-presets/communications/packages/communications/src/email/providers/gmail-smtp-email.provider.ts +104 -0
  38. package/templates/module-presets/communications/packages/communications/src/forgeon-communications.module.ts +65 -0
  39. package/templates/module-presets/communications/packages/communications/src/index.ts +21 -0
  40. package/templates/module-presets/communications/packages/communications/src/push/providers/stub-push.provider.ts +16 -0
  41. package/templates/module-presets/communications/packages/communications/src/push/push-channel.service.ts +56 -0
  42. package/templates/module-presets/communications/packages/communications/src/push/push-provider.port.ts +14 -0
  43. package/templates/module-presets/communications/packages/communications/src/sms/providers/stub-sms.provider.ts +16 -0
  44. package/templates/module-presets/communications/packages/communications/src/sms/sms-channel.service.ts +56 -0
  45. package/templates/module-presets/communications/packages/communications/src/sms/sms-provider.port.ts +14 -0
  46. package/templates/module-presets/communications/packages/communications/src/template-loader.service.ts +98 -0
  47. package/templates/module-presets/communications/packages/communications/src/template-renderer.service.ts +30 -0
  48. package/templates/module-presets/communications/packages/communications/tsconfig.json +9 -0
  49. package/templates/module-presets/communications/resources/communications/email/communications_probe.html +9 -0
  50. package/templates/module-presets/communications/resources/communications/email/communications_probe.subject.txt +1 -0
  51. package/templates/module-presets/communications/resources/communications/email/email_verification_code.html +8 -0
  52. package/templates/module-presets/communications/resources/communications/email/email_verification_code.subject.txt +1 -0
  53. package/templates/module-presets/communications/resources/communications/email/password_reset.html +8 -0
  54. package/templates/module-presets/communications/resources/communications/email/password_reset.subject.txt +1 -0
  55. package/templates/module-presets/communications/resources/communications/email/welcome_email.html +8 -0
  56. package/templates/module-presets/communications/resources/communications/email/welcome_email.subject.txt +1 -0
  57. package/templates/module-presets/communications/resources/communications/push/login_alert.json +4 -0
  58. package/templates/module-presets/communications/resources/communications/sms/phone_verification.txt +1 -0
  59. package/templates/module-presets/i18n/apps/web/src/App.tsx +72 -8
  60. 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.23",
3
+ "version": "0.3.25",
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,233 @@
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_FROM=` (falls back to the SMTP user when left empty)',
169
+ '- `COMMUNICATIONS_EMAIL_SMTP_USER=`',
170
+ '- `COMMUNICATIONS_EMAIL_SMTP_PASS=`',
171
+ ].join('\n');
172
+
173
+ let content = fs.readFileSync(readmePath, 'utf8').replace(/\r\n/g, '\n');
174
+ const sectionHeading = '## Communications Module';
175
+ if (content.includes(sectionHeading)) {
176
+ const start = content.indexOf(sectionHeading);
177
+ const tail = content.slice(start + sectionHeading.length);
178
+ const nextHeadingMatch = tail.match(/\n##\s+/);
179
+ const end =
180
+ nextHeadingMatch && nextHeadingMatch.index !== undefined
181
+ ? start + sectionHeading.length + nextHeadingMatch.index + 1
182
+ : content.length;
183
+ content = `${content.slice(0, start)}${section}\n\n${content.slice(end).replace(/^\n+/, '')}`;
184
+ } else if (content.includes('## Prisma In Docker Start')) {
185
+ content = content.replace('## Prisma In Docker Start', `${section}\n\n## Prisma In Docker Start`);
186
+ } else {
187
+ content = `${content.trimEnd()}\n\n${section}\n`;
188
+ }
189
+
190
+ fs.writeFileSync(readmePath, `${content.trimEnd()}\n`, 'utf8');
191
+ }
192
+
193
+ export function applyCommunicationsModule({ packageRoot, targetRoot }) {
194
+ copyFromPreset(packageRoot, targetRoot, path.join('packages', 'communications'));
195
+ copyFromPreset(packageRoot, targetRoot, path.join('resources', 'communications'));
196
+
197
+ patchApiPackage(targetRoot);
198
+ patchAppModule(targetRoot);
199
+ registerWebProbe(targetRoot);
200
+ patchApiDockerfile(targetRoot);
201
+ patchCompose(targetRoot);
202
+ patchReadme(targetRoot);
203
+
204
+ upsertEnvLines(path.join(targetRoot, 'apps', 'api', '.env.example'), [
205
+ 'COMMUNICATIONS_TEMPLATES_ROOT=resources/communications',
206
+ 'COMMUNICATIONS_EMAIL_PROVIDER=gmail-smtp',
207
+ 'COMMUNICATIONS_EMAIL_FROM=',
208
+ 'COMMUNICATIONS_EMAIL_REPLY_TO=',
209
+ 'COMMUNICATIONS_EMAIL_SUBJECT_PREFIX=[Forgeon]',
210
+ 'COMMUNICATIONS_EMAIL_SMTP_HOST=smtp.gmail.com',
211
+ 'COMMUNICATIONS_EMAIL_SMTP_PORT=587',
212
+ 'COMMUNICATIONS_EMAIL_SMTP_SECURE=false',
213
+ 'COMMUNICATIONS_EMAIL_SMTP_USER=',
214
+ 'COMMUNICATIONS_EMAIL_SMTP_PASS=',
215
+ 'COMMUNICATIONS_SMS_PROVIDER=stub',
216
+ 'COMMUNICATIONS_PUSH_PROVIDER=stub',
217
+ ]);
218
+
219
+ upsertEnvLines(path.join(targetRoot, 'infra', 'docker', '.env.example'), [
220
+ 'COMMUNICATIONS_TEMPLATES_ROOT=resources/communications',
221
+ 'COMMUNICATIONS_EMAIL_PROVIDER=gmail-smtp',
222
+ 'COMMUNICATIONS_EMAIL_FROM=',
223
+ 'COMMUNICATIONS_EMAIL_REPLY_TO=',
224
+ 'COMMUNICATIONS_EMAIL_SUBJECT_PREFIX=[Forgeon]',
225
+ 'COMMUNICATIONS_EMAIL_SMTP_HOST=smtp.gmail.com',
226
+ 'COMMUNICATIONS_EMAIL_SMTP_PORT=587',
227
+ 'COMMUNICATIONS_EMAIL_SMTP_SECURE=false',
228
+ 'COMMUNICATIONS_EMAIL_SMTP_USER=',
229
+ 'COMMUNICATIONS_EMAIL_SMTP_PASS=',
230
+ 'COMMUNICATIONS_SMS_PROVIDER=stub',
231
+ 'COMMUNICATIONS_PUSH_PROVIDER=stub',
232
+ ]);
233
+ }
@@ -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',
@@ -110,7 +119,7 @@ const TEST_PRESETS = [
110
119
  implemented: true,
111
120
  detectionPaths: ['packages/files-access/package.json'],
112
121
  provides: ['files-access-runtime'],
113
- requires: [{ type: 'capability', id: 'files-runtime' }],
122
+ requires: [{ type: 'module', id: 'files' }],
114
123
  optionalIntegrations: [],
115
124
  },
116
125
  {
@@ -119,7 +128,7 @@ const TEST_PRESETS = [
119
128
  implemented: true,
120
129
  detectionPaths: ['packages/files-quotas/package.json'],
121
130
  provides: ['files-quotas-runtime'],
122
- requires: [{ type: 'capability', id: 'files-runtime' }],
131
+ requires: [{ type: 'module', id: 'files' }],
123
132
  optionalIntegrations: [],
124
133
  },
125
134
  {
@@ -128,7 +137,7 @@ const TEST_PRESETS = [
128
137
  implemented: true,
129
138
  detectionPaths: ['packages/files-image/package.json'],
130
139
  provides: ['files-image-runtime'],
131
- requires: [{ type: 'capability', id: 'files-runtime' }],
140
+ requires: [{ type: 'module', id: 'files' }],
132
141
  optionalIntegrations: [],
133
142
  },
134
143
  {
@@ -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 });
@@ -259,7 +269,7 @@ describe('module dependency helpers', () => {
259
269
  }
260
270
  });
261
271
 
262
- it('resolves files-access plan through files-runtime capability chain', async () => {
272
+ it('resolves files-access plan through files core module dependency', async () => {
263
273
  const targetRoot = mkTmp('forgeon-deps-files-access-plan-');
264
274
 
265
275
  try {
@@ -279,14 +289,13 @@ describe('module dependency helpers', () => {
279
289
  assert.deepEqual(result.selectedProviders, {
280
290
  'db-adapter': 'db-prisma',
281
291
  'files-storage-adapter': 'files-local',
282
- 'files-runtime': 'files',
283
292
  });
284
293
  } finally {
285
294
  fs.rmSync(targetRoot, { recursive: true, force: true });
286
295
  }
287
296
  });
288
297
 
289
- it('resolves files-quotas plan through files-runtime capability chain', async () => {
298
+ it('resolves files-quotas plan through files core module dependency', async () => {
290
299
  const targetRoot = mkTmp('forgeon-deps-files-quotas-plan-');
291
300
 
292
301
  try {
@@ -306,14 +315,13 @@ describe('module dependency helpers', () => {
306
315
  assert.deepEqual(result.selectedProviders, {
307
316
  'db-adapter': 'db-prisma',
308
317
  'files-storage-adapter': 'files-local',
309
- 'files-runtime': 'files',
310
318
  });
311
319
  } finally {
312
320
  fs.rmSync(targetRoot, { recursive: true, force: true });
313
321
  }
314
322
  });
315
323
 
316
- it('resolves files-image plan through files-runtime capability chain', async () => {
324
+ it('resolves files-image plan through files core module dependency', async () => {
317
325
  const targetRoot = mkTmp('forgeon-deps-files-image-plan-');
318
326
 
319
327
  try {
@@ -333,7 +341,6 @@ describe('module dependency helpers', () => {
333
341
  assert.deepEqual(result.selectedProviders, {
334
342
  'db-adapter': 'db-prisma',
335
343
  'files-storage-adapter': 'files-local',
336
- 'files-runtime': 'files',
337
344
  });
338
345
  } finally {
339
346
  fs.rmSync(targetRoot, { recursive: true, force: true });
@@ -375,6 +382,7 @@ describe('module dependency helpers', () => {
375
382
  assert.equal(result.cancelled, false);
376
383
  assert.deepEqual(result.moduleSequence, [
377
384
  'db-prisma',
385
+ 'communications',
378
386
  'accounts',
379
387
  'rbac',
380
388
  'files-local',
@@ -388,6 +396,7 @@ describe('module dependency helpers', () => {
388
396
  assert.deepEqual(result.selectedProviders, {
389
397
  'files-storage-adapter': 'files-local',
390
398
  'db-adapter': 'db-prisma',
399
+ 'communications-runtime': 'communications',
391
400
  'files-runtime': 'files',
392
401
  'queue-runtime': 'queue',
393
402
  });
@@ -499,3 +508,5 @@ describe('module dependency helpers', () => {
499
508
  }
500
509
  });
501
510
  });
511
+
512
+
@@ -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
+
@@ -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';
@@ -796,7 +796,7 @@ function assertAccountsWiring(projectRoot) {
796
796
  const readme = fs.readFileSync(path.join(projectRoot, 'README.md'), 'utf8');
797
797
  assert.match(readme, /## Accounts Module/);
798
798
  assert.match(readme, /owner-scoped user routes/);
799
- assert.match(readme, /AccountsEmailPort/);
799
+ assert.match(readme, /CommunicationsService/);
800
800
 
801
801
  const authServiceSource = fs.readFileSync(
802
802
  path.join(projectRoot, 'packages', 'accounts-api', 'src', 'auth.service.ts'),
@@ -816,6 +816,7 @@ function assertAccountsWiring(projectRoot) {
816
816
  'utf8',
817
817
  );
818
818
  assert.match(accountsApiPackage, /@forgeon\/db-prisma/);
819
+ assert.match(accountsApiPackage, /@forgeon\/communications/);
819
820
 
820
821
  const authStoreSource = fs.readFileSync(
821
822
  path.join(projectRoot, 'packages', 'accounts-api', 'src', 'auth.store.ts'),
@@ -2227,6 +2228,89 @@ describe('addModule', () => {
2227
2228
  }
2228
2229
  });
2229
2230
 
2231
+ it('applies communications on top of scaffold and wires SMTP-backed probe flow', () => {
2232
+ const targetRoot = mkTmp('forgeon-module-communications-');
2233
+ const projectRoot = path.join(targetRoot, 'demo-communications');
2234
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
2235
+
2236
+ try {
2237
+ scaffoldProject({
2238
+ templateRoot,
2239
+ packageRoot,
2240
+ targetRoot: projectRoot,
2241
+ projectName: 'demo-communications',
2242
+ frontend: 'react',
2243
+ db: 'prisma',
2244
+ dbPrismaEnabled: true,
2245
+ i18nEnabled: true,
2246
+ proxy: 'caddy',
2247
+ });
2248
+
2249
+ const result = addModule({
2250
+ moduleId: 'communications',
2251
+ targetRoot: projectRoot,
2252
+ packageRoot,
2253
+ });
2254
+
2255
+ assert.equal(result.applied, true);
2256
+
2257
+ const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
2258
+ assert.match(appModule, /communicationsConfig/);
2259
+ assert.match(appModule, /communicationsEnvSchema/);
2260
+ assert.match(appModule, /ForgeonCommunicationsModule\.register\(\)/);
2261
+
2262
+ const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
2263
+ assert.match(apiPackage, /@forgeon\/communications/);
2264
+ assert.match(apiPackage, /pnpm --filter @forgeon\/communications build/);
2265
+
2266
+ const communicationsEnv = fs.readFileSync(
2267
+ path.join(projectRoot, 'packages', 'communications', 'src', 'communications-env.schema.ts'),
2268
+ 'utf8',
2269
+ );
2270
+ assert.match(communicationsEnv, /normalizeEnvBoolean/);
2271
+ assert.doesNotMatch(communicationsEnv, /COMMUNICATIONS_EMAIL_SMTP_SECURE: z\.coerce\.boolean/);
2272
+
2273
+ const communicationsConfig = fs.readFileSync(
2274
+ path.join(projectRoot, 'packages', 'communications', 'src', 'communications-config.loader.ts'),
2275
+ 'utf8',
2276
+ );
2277
+ assert.match(communicationsConfig, /const derivedFrom = env\.COMMUNICATIONS_EMAIL_FROM \|\| env\.COMMUNICATIONS_EMAIL_SMTP_USER/);
2278
+
2279
+ const providerSource = fs.readFileSync(
2280
+ path.join(projectRoot, 'packages', 'communications', 'src', 'email', 'providers', 'gmail-smtp-email.provider.ts'),
2281
+ 'utf8',
2282
+ );
2283
+ assert.match(providerSource, /COMMUNICATIONS_EMAIL_PROVIDER_SEND_FAILED/);
2284
+ assert.match(providerSource, /extractErrorDetails/);
2285
+
2286
+ const communicationsModuleSource = fs.readFileSync(
2287
+ path.join(projectRoot, 'packages', 'communications', 'src', 'forgeon-communications.module.ts'),
2288
+ 'utf8',
2289
+ );
2290
+ assert.match(communicationsModuleSource, /@Global\(\)/);
2291
+
2292
+ const apiEnv = fs.readFileSync(path.join(projectRoot, 'apps', 'api', '.env.example'), 'utf8');
2293
+ assert.match(apiEnv, /COMMUNICATIONS_EMAIL_SMTP_SECURE=false/);
2294
+ assert.match(apiEnv, /^COMMUNICATIONS_EMAIL_FROM=$/m);
2295
+
2296
+ const dockerEnv = fs.readFileSync(path.join(projectRoot, 'infra', 'docker', '.env.example'), 'utf8');
2297
+ assert.match(dockerEnv, /^COMMUNICATIONS_EMAIL_FROM=$/m);
2298
+
2299
+ const probesTs = readWebProbes(projectRoot);
2300
+ assert.match(probesTs, /"id": "communications"/);
2301
+ assert.match(probesTs, /\$INPUT\.email\$/);
2302
+
2303
+ const readme = fs.readFileSync(path.join(projectRoot, 'README.md'), 'utf8');
2304
+ assert.match(readme, /## Communications Module/);
2305
+ assert.match(readme, /falls back to the SMTP user when left empty/);
2306
+
2307
+ const moduleDoc = fs.readFileSync(result.docsPath, 'utf8');
2308
+ assert.match(moduleDoc, /Status: implemented/);
2309
+ assert.match(moduleDoc, /STARTTLS/);
2310
+ } finally {
2311
+ fs.rmSync(targetRoot, { recursive: true, force: true });
2312
+ }
2313
+ });
2230
2314
  it('applies accounts with db-prisma and wires the DB-backed runtime immediately', () => {
2231
2315
  const targetRoot = mkTmp('forgeon-module-accounts-db-');
2232
2316
  const projectRoot = path.join(targetRoot, 'demo-accounts-db');
@@ -2702,6 +2786,13 @@ describe('addModule', () => {
2702
2786
 
2703
2787
 
2704
2788
 
2789
+
2790
+
2791
+
2792
+
2793
+
2794
+
2795
+
2705
2796
 
2706
2797
 
2707
2798
 
@@ -67,10 +67,10 @@ const MODULE_PRESETS = {
67
67
  category: 'file-storage',
68
68
  implemented: true,
69
69
  description:
70
- 'Resource-level access policy module for files metadata/download/delete operations. Requires files-runtime capability.',
70
+ 'Resource-level access policy extension for the files core module. Requires files.',
71
71
  detectionPaths: ['packages/files-access/package.json'],
72
72
  provides: ['files-access-runtime'],
73
- requires: [{ type: 'capability', id: 'files-runtime' }],
73
+ requires: [{ type: 'module', id: 'files' }],
74
74
  optionalIntegrations: [],
75
75
  docFragments: ['00_title', '10_overview', '20_scope', '90_status_implemented'],
76
76
  },
@@ -80,10 +80,10 @@ const MODULE_PRESETS = {
80
80
  category: 'file-storage',
81
81
  implemented: true,
82
82
  description:
83
- 'Owner-level upload quota policy module for files. Requires files-runtime capability.',
83
+ 'Owner-level upload quota extension for the files core module. Requires files.',
84
84
  detectionPaths: ['packages/files-quotas/package.json'],
85
85
  provides: ['files-quotas-runtime'],
86
- requires: [{ type: 'capability', id: 'files-runtime' }],
86
+ requires: [{ type: 'module', id: 'files' }],
87
87
  optionalIntegrations: [],
88
88
  docFragments: ['00_title', '10_overview', '20_scope', '90_status_implemented'],
89
89
  },
@@ -93,10 +93,10 @@ const MODULE_PRESETS = {
93
93
  category: 'file-storage',
94
94
  implemented: true,
95
95
  description:
96
- 'Image sanitation module for files runtime (magic-bytes detect + sharp re-encode, metadata stripped by default). Requires files-runtime capability.',
96
+ 'Image sanitation extension for the files core module (magic-bytes detect + sharp re-encode, metadata stripped by default). Requires files.',
97
97
  detectionPaths: ['packages/files-image/package.json'],
98
98
  provides: ['files-image-runtime'],
99
- requires: [{ type: 'capability', id: 'files-runtime' }],
99
+ requires: [{ type: 'module', id: 'files' }],
100
100
  optionalIntegrations: [],
101
101
  docFragments: ['00_title', '10_overview', '20_scope', '90_status_implemented'],
102
102
  },
@@ -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
+