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.
- package/README.md +14 -12
- package/package.json +1 -1
- package/src/modules/accounts.mjs +3 -1
- package/src/modules/communications.mjs +232 -0
- package/src/modules/dependencies.test.mjs +16 -2
- package/src/modules/executor.mjs +4 -0
- package/src/modules/executor.test.mjs +5 -2
- package/src/modules/registry.mjs +18 -2
- package/src/modules/shared/probes.mjs +3 -1
- package/src/run-add-module.test.mjs +4 -0
- package/templates/base/apps/web/src/App.tsx +75 -11
- package/templates/base/apps/web/src/probes.ts +11 -1
- package/templates/base/apps/web/src/styles.css +25 -0
- package/templates/module-fragments/accounts/20_scope.md +3 -2
- package/templates/module-fragments/accounts/90_status_implemented.md +2 -2
- package/templates/module-fragments/accounts/90_status_planned.md +1 -1
- package/templates/module-fragments/communications/00_title.md +1 -0
- package/templates/module-fragments/communications/10_overview.md +6 -0
- package/templates/module-fragments/communications/20_scope.md +20 -0
- package/templates/module-fragments/communications/90_status_implemented.md +8 -0
- package/templates/module-presets/accounts/packages/accounts-api/package.json +1 -0
- package/templates/module-presets/accounts/packages/accounts-api/src/auth-core.service.ts +60 -20
- package/templates/module-presets/accounts/packages/accounts-api/src/forgeon-accounts.module.ts +2 -6
- package/templates/module-presets/accounts/packages/accounts-api/src/index.ts +1 -1
- package/templates/module-presets/communications/packages/communications/package.json +23 -0
- package/templates/module-presets/communications/packages/communications/src/communications-config.loader.ts +58 -0
- package/templates/module-presets/communications/packages/communications/src/communications-config.module.ts +11 -0
- package/templates/module-presets/communications/packages/communications/src/communications-config.service.ts +60 -0
- package/templates/module-presets/communications/packages/communications/src/communications-env.schema.ts +24 -0
- package/templates/module-presets/communications/packages/communications/src/communications.constants.ts +3 -0
- package/templates/module-presets/communications/packages/communications/src/communications.probe.controller.ts +18 -0
- package/templates/module-presets/communications/packages/communications/src/communications.service.ts +104 -0
- package/templates/module-presets/communications/packages/communications/src/communications.types.ts +55 -0
- package/templates/module-presets/communications/packages/communications/src/dto/send-communications-probe.dto.ts +6 -0
- package/templates/module-presets/communications/packages/communications/src/email/email-channel.service.ts +90 -0
- package/templates/module-presets/communications/packages/communications/src/email/email-provider.port.ts +16 -0
- package/templates/module-presets/communications/packages/communications/src/email/providers/gmail-smtp-email.provider.ts +64 -0
- package/templates/module-presets/communications/packages/communications/src/forgeon-communications.module.ts +64 -0
- package/templates/module-presets/communications/packages/communications/src/index.ts +21 -0
- package/templates/module-presets/communications/packages/communications/src/push/providers/stub-push.provider.ts +16 -0
- package/templates/module-presets/communications/packages/communications/src/push/push-channel.service.ts +56 -0
- package/templates/module-presets/communications/packages/communications/src/push/push-provider.port.ts +14 -0
- package/templates/module-presets/communications/packages/communications/src/sms/providers/stub-sms.provider.ts +16 -0
- package/templates/module-presets/communications/packages/communications/src/sms/sms-channel.service.ts +56 -0
- package/templates/module-presets/communications/packages/communications/src/sms/sms-provider.port.ts +14 -0
- package/templates/module-presets/communications/packages/communications/src/template-loader.service.ts +98 -0
- package/templates/module-presets/communications/packages/communications/src/template-renderer.service.ts +30 -0
- package/templates/module-presets/communications/packages/communications/tsconfig.json +9 -0
- package/templates/module-presets/communications/resources/communications/email/communications_probe.html +9 -0
- package/templates/module-presets/communications/resources/communications/email/communications_probe.subject.txt +1 -0
- package/templates/module-presets/communications/resources/communications/email/email_verification_code.html +8 -0
- package/templates/module-presets/communications/resources/communications/email/email_verification_code.subject.txt +1 -0
- package/templates/module-presets/communications/resources/communications/email/password_reset.html +8 -0
- package/templates/module-presets/communications/resources/communications/email/password_reset.subject.txt +1 -0
- package/templates/module-presets/communications/resources/communications/email/welcome_email.html +8 -0
- package/templates/module-presets/communications/resources/communications/email/welcome_email.subject.txt +1 -0
- package/templates/module-presets/communications/resources/communications/push/login_alert.json +4 -0
- package/templates/module-presets/communications/resources/communications/sms/phone_verification.txt +1 -0
- package/templates/module-presets/i18n/apps/web/src/App.tsx +72 -8
- 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
|
> 
|
|
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
|
|
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
|
|
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
package/src/modules/accounts.mjs
CHANGED
|
@@ -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
|
|
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
|
+
|
package/src/modules/executor.mjs
CHANGED
|
@@ -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, /
|
|
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
|
|
package/src/modules/registry.mjs
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
+
|
|
@@ -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
|
|
22
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
80
|
+
responseBody = await response.json();
|
|
32
81
|
} catch {
|
|
33
|
-
|
|
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
|
-
|
|
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
|
+
}
|