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.
- package/README.md +14 -12
- package/package.json +1 -1
- package/src/modules/accounts.mjs +3 -1
- package/src/modules/communications.mjs +233 -0
- package/src/modules/dependencies.test.mjs +22 -11
- package/src/modules/executor.mjs +4 -0
- package/src/modules/executor.test.mjs +93 -2
- package/src/modules/registry.mjs +24 -8
- 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 +23 -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 +1 -7
- 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 +59 -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 +40 -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 +104 -0
- package/templates/module-presets/communications/packages/communications/src/forgeon-communications.module.ts +65 -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,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: '
|
|
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: '
|
|
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: '
|
|
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
|
|
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
|
|
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
|
|
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
|
+
|
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
|
+
|
|
@@ -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, /
|
|
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
|
|
package/src/modules/registry.mjs
CHANGED
|
@@ -67,10 +67,10 @@ const MODULE_PRESETS = {
|
|
|
67
67
|
category: 'file-storage',
|
|
68
68
|
implemented: true,
|
|
69
69
|
description:
|
|
70
|
-
'Resource-level access policy
|
|
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: '
|
|
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
|
|
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: '
|
|
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
|
|
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: '
|
|
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,
|
|
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
|
+
|