create-forgeon 0.3.23 → 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 (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 +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 +5 -2
  8. package/src/modules/registry.mjs +18 -2
  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 +20 -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 +2 -6
  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 +58 -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 +24 -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 +64 -0
  38. package/templates/module-presets/communications/packages/communications/src/forgeon-communications.module.ts +64 -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.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
+
@@ -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'),
@@ -815,7 +815,8 @@ function assertAccountsWiring(projectRoot) {
815
815
  path.join(projectRoot, 'packages', 'accounts-api', 'package.json'),
816
816
  'utf8',
817
817
  );
818
- assert.match(accountsApiPackage, /@forgeon\/db-prisma/);
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'),
@@ -2707,5 +2708,7 @@ describe('addModule', () => {
2707
2708
 
2708
2709
 
2709
2710
 
2711
+
2712
+
2710
2713
 
2711
2714
 
@@ -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
+
@@ -1,5 +1,5 @@
1
1
  import { useState } from 'react';
2
- import { probeDefinitions, type ProbeDefinition, type ProbeResult } from './probes';
2
+ import { probeDefinitions, type ProbeDefinition, type ProbeInputDefinition, type ProbeResult } from './probes';
3
3
  import './styles.css';
4
4
 
5
5
  type ProbeState = {
@@ -8,34 +8,83 @@ type ProbeState = {
8
8
  loading: boolean;
9
9
  };
10
10
 
11
+ type ProbeInputState = Record<string, string>;
12
+
11
13
  const emptyProbeState: ProbeState = {
12
14
  result: null,
13
15
  error: null,
14
16
  loading: false,
15
17
  };
16
18
 
19
+ function resolveBodyTemplate(value: unknown, inputs: ProbeInputState): unknown {
20
+ if (typeof value === 'string') {
21
+ const match = value.match(/^\$INPUT\.([a-zA-Z0-9_-]+)\$$/);
22
+ if (match) {
23
+ return inputs[match[1]] ?? '';
24
+ }
25
+ return value;
26
+ }
27
+
28
+ if (Array.isArray(value)) {
29
+ return value.map((item) => resolveBodyTemplate(item, inputs));
30
+ }
31
+
32
+ if (value && typeof value === 'object') {
33
+ return Object.fromEntries(
34
+ Object.entries(value).map(([key, nestedValue]) => [key, resolveBodyTemplate(nestedValue, inputs)]),
35
+ );
36
+ }
37
+
38
+ return value;
39
+ }
40
+
17
41
  export default function App() {
18
42
  const [probeState, setProbeState] = useState<Record<string, ProbeState>>({});
43
+ const [probeInputs, setProbeInputs] = useState<Record<string, ProbeInputState>>({});
44
+
45
+ const getProbeInputValue = (probeId: string, input: ProbeInputDefinition): string => {
46
+ return probeInputs[probeId]?.[input.id] ?? input.defaultValue ?? '';
47
+ };
48
+
49
+ const updateProbeInput = (probeId: string, inputId: string, value: string) => {
50
+ setProbeInputs((current) => ({
51
+ ...current,
52
+ [probeId]: {
53
+ ...(current[probeId] ?? {}),
54
+ [inputId]: value,
55
+ },
56
+ }));
57
+ };
19
58
 
20
59
  const requestProbe = async (probe: ProbeDefinition): Promise<ProbeResult> => {
21
- const response = await fetch(`/api${probe.path}`, {
22
- ...(probe.request ?? {}),
60
+ const method = probe.request?.method ?? 'GET';
61
+ const headers: Record<string, string> = {
62
+ ...(probe.request?.headers ?? {}),
63
+ };
64
+
65
+ const requestInit: RequestInit = {
66
+ method,
23
67
  cache: 'no-store',
24
- headers: {
25
- ...(probe.request?.headers ?? {}),
26
- },
27
- });
28
- let body: unknown = null;
68
+ headers,
69
+ };
70
+
71
+ if (method !== 'GET' && probe.request?.body !== undefined) {
72
+ headers['Content-Type'] = 'application/json';
73
+ requestInit.body = JSON.stringify(resolveBodyTemplate(probe.request.body, probeInputs[probe.id] ?? {}));
74
+ }
75
+
76
+ const response = await fetch(`/api${probe.path}`, requestInit);
77
+ let responseBody: unknown = null;
29
78
 
30
79
  try {
31
- body = await response.json();
80
+ responseBody = await response.json();
32
81
  } catch {
33
- body = { message: 'Non-JSON response' };
82
+ responseBody = { message: 'Non-JSON response' };
34
83
  }
35
84
 
36
85
  return {
37
86
  statusCode: response.status,
38
- body,
87
+ body: responseBody,
39
88
  };
40
89
  };
41
90
 
@@ -87,6 +136,21 @@ export default function App() {
87
136
  {current.loading ? 'Running...' : probe.buttonLabel}
88
137
  </button>
89
138
  </div>
139
+ {probe.inputs?.length ? (
140
+ <div className="probe-inputs">
141
+ {probe.inputs.map((input) => (
142
+ <label key={`${probe.id}-${input.id}`} className="probe-input">
143
+ <span>{input.label}</span>
144
+ <input
145
+ type={input.type ?? 'text'}
146
+ value={getProbeInputValue(probe.id, input)}
147
+ placeholder={input.placeholder}
148
+ onChange={(event) => updateProbeInput(probe.id, input.id, event.target.value)}
149
+ />
150
+ </label>
151
+ ))}
152
+ </div>
153
+ ) : null}
90
154
  <div className="probe-output">
91
155
  <h3>{probe.resultTitle}</h3>
92
156
  {current.error ? <p className="error">{current.error}</p> : null}
@@ -1,11 +1,20 @@
1
- export type ProbeResult = {
1
+ export type ProbeResult = {
2
2
  statusCode: number;
3
3
  body: unknown;
4
4
  };
5
5
 
6
+ export type ProbeInputDefinition = {
7
+ id: string;
8
+ label: string;
9
+ type?: 'text' | 'email';
10
+ placeholder?: string;
11
+ defaultValue?: string;
12
+ };
13
+
6
14
  export type ProbeRequest = {
7
15
  method?: 'GET' | 'POST';
8
16
  headers?: Record<string, string>;
17
+ body?: unknown;
9
18
  };
10
19
 
11
20
  export type ProbeDefinition = {
@@ -16,6 +25,7 @@ export type ProbeDefinition = {
16
25
  resultTitle: string;
17
26
  path: string;
18
27
  request?: ProbeRequest;
28
+ inputs?: ProbeInputDefinition[];
19
29
  };
20
30
 
21
31
  const baseProbeDefinitions: ProbeDefinition[] = [
@@ -104,3 +104,28 @@ select {
104
104
  align-items: start;
105
105
  }
106
106
  }
107
+
108
+ .probe-inputs {
109
+ display: grid;
110
+ gap: 0.75rem;
111
+ }
112
+
113
+ .probe-input {
114
+ display: grid;
115
+ gap: 0.35rem;
116
+ margin: 0;
117
+ }
118
+
119
+ .probe-input span {
120
+ font-size: 0.9rem;
121
+ font-weight: 600;
122
+ }
123
+
124
+ .probe-input input {
125
+ width: 100%;
126
+ box-sizing: border-box;
127
+ padding: 0.65rem 0.75rem;
128
+ border-radius: 0.6rem;
129
+ border: 1px solid #cbd5e1;
130
+ background: #ffffff;
131
+ }