create-forgeon 0.3.24 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-forgeon",
3
- "version": "0.3.24",
3
+ "version": "0.3.25",
4
4
  "description": "Forgeon project generator CLI",
5
5
  "license": "MIT",
6
6
  "author": "Forgeon",
@@ -1,4 +1,4 @@
1
- import fs from 'node:fs';
1
+ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { copyRecursive, writeJson } from '../utils/fs.mjs';
4
4
  import {
@@ -165,6 +165,7 @@ function patchReadme(targetRoot) {
165
165
  'Example env keys:',
166
166
  '- `COMMUNICATIONS_EMAIL_PROVIDER=gmail-smtp`',
167
167
  '- `COMMUNICATIONS_EMAIL_SMTP_HOST=smtp.gmail.com`',
168
+ '- `COMMUNICATIONS_EMAIL_FROM=` (falls back to the SMTP user when left empty)',
168
169
  '- `COMMUNICATIONS_EMAIL_SMTP_USER=`',
169
170
  '- `COMMUNICATIONS_EMAIL_SMTP_PASS=`',
170
171
  ].join('\n');
@@ -203,7 +204,7 @@ export function applyCommunicationsModule({ packageRoot, targetRoot }) {
203
204
  upsertEnvLines(path.join(targetRoot, 'apps', 'api', '.env.example'), [
204
205
  'COMMUNICATIONS_TEMPLATES_ROOT=resources/communications',
205
206
  'COMMUNICATIONS_EMAIL_PROVIDER=gmail-smtp',
206
- 'COMMUNICATIONS_EMAIL_FROM=Forgeon <no-reply@example.com>',
207
+ 'COMMUNICATIONS_EMAIL_FROM=',
207
208
  'COMMUNICATIONS_EMAIL_REPLY_TO=',
208
209
  'COMMUNICATIONS_EMAIL_SUBJECT_PREFIX=[Forgeon]',
209
210
  'COMMUNICATIONS_EMAIL_SMTP_HOST=smtp.gmail.com',
@@ -218,7 +219,7 @@ export function applyCommunicationsModule({ packageRoot, targetRoot }) {
218
219
  upsertEnvLines(path.join(targetRoot, 'infra', 'docker', '.env.example'), [
219
220
  'COMMUNICATIONS_TEMPLATES_ROOT=resources/communications',
220
221
  'COMMUNICATIONS_EMAIL_PROVIDER=gmail-smtp',
221
- 'COMMUNICATIONS_EMAIL_FROM=Forgeon <no-reply@example.com>',
222
+ 'COMMUNICATIONS_EMAIL_FROM=',
222
223
  'COMMUNICATIONS_EMAIL_REPLY_TO=',
223
224
  'COMMUNICATIONS_EMAIL_SUBJECT_PREFIX=[Forgeon]',
224
225
  'COMMUNICATIONS_EMAIL_SMTP_HOST=smtp.gmail.com',
@@ -119,7 +119,7 @@ const TEST_PRESETS = [
119
119
  implemented: true,
120
120
  detectionPaths: ['packages/files-access/package.json'],
121
121
  provides: ['files-access-runtime'],
122
- requires: [{ type: 'capability', id: 'files-runtime' }],
122
+ requires: [{ type: 'module', id: 'files' }],
123
123
  optionalIntegrations: [],
124
124
  },
125
125
  {
@@ -128,7 +128,7 @@ const TEST_PRESETS = [
128
128
  implemented: true,
129
129
  detectionPaths: ['packages/files-quotas/package.json'],
130
130
  provides: ['files-quotas-runtime'],
131
- requires: [{ type: 'capability', id: 'files-runtime' }],
131
+ requires: [{ type: 'module', id: 'files' }],
132
132
  optionalIntegrations: [],
133
133
  },
134
134
  {
@@ -137,7 +137,7 @@ const TEST_PRESETS = [
137
137
  implemented: true,
138
138
  detectionPaths: ['packages/files-image/package.json'],
139
139
  provides: ['files-image-runtime'],
140
- requires: [{ type: 'capability', id: 'files-runtime' }],
140
+ requires: [{ type: 'module', id: 'files' }],
141
141
  optionalIntegrations: [],
142
142
  },
143
143
  {
@@ -269,7 +269,7 @@ describe('module dependency helpers', () => {
269
269
  }
270
270
  });
271
271
 
272
- it('resolves files-access plan through files-runtime capability chain', async () => {
272
+ it('resolves files-access plan through files core module dependency', async () => {
273
273
  const targetRoot = mkTmp('forgeon-deps-files-access-plan-');
274
274
 
275
275
  try {
@@ -289,14 +289,13 @@ describe('module dependency helpers', () => {
289
289
  assert.deepEqual(result.selectedProviders, {
290
290
  'db-adapter': 'db-prisma',
291
291
  'files-storage-adapter': 'files-local',
292
- 'files-runtime': 'files',
293
292
  });
294
293
  } finally {
295
294
  fs.rmSync(targetRoot, { recursive: true, force: true });
296
295
  }
297
296
  });
298
297
 
299
- it('resolves files-quotas plan through files-runtime capability chain', async () => {
298
+ it('resolves files-quotas plan through files core module dependency', async () => {
300
299
  const targetRoot = mkTmp('forgeon-deps-files-quotas-plan-');
301
300
 
302
301
  try {
@@ -316,14 +315,13 @@ describe('module dependency helpers', () => {
316
315
  assert.deepEqual(result.selectedProviders, {
317
316
  'db-adapter': 'db-prisma',
318
317
  'files-storage-adapter': 'files-local',
319
- 'files-runtime': 'files',
320
318
  });
321
319
  } finally {
322
320
  fs.rmSync(targetRoot, { recursive: true, force: true });
323
321
  }
324
322
  });
325
323
 
326
- it('resolves files-image plan through files-runtime capability chain', async () => {
324
+ it('resolves files-image plan through files core module dependency', async () => {
327
325
  const targetRoot = mkTmp('forgeon-deps-files-image-plan-');
328
326
 
329
327
  try {
@@ -343,7 +341,6 @@ describe('module dependency helpers', () => {
343
341
  assert.deepEqual(result.selectedProviders, {
344
342
  'db-adapter': 'db-prisma',
345
343
  'files-storage-adapter': 'files-local',
346
- 'files-runtime': 'files',
347
344
  });
348
345
  } finally {
349
346
  fs.rmSync(targetRoot, { recursive: true, force: true });
@@ -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';
@@ -815,7 +815,7 @@ 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
819
  assert.match(accountsApiPackage, /@forgeon\/communications/);
820
820
 
821
821
  const authStoreSource = fs.readFileSync(
@@ -2228,6 +2228,89 @@ describe('addModule', () => {
2228
2228
  }
2229
2229
  });
2230
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
+ });
2231
2314
  it('applies accounts with db-prisma and wires the DB-backed runtime immediately', () => {
2232
2315
  const targetRoot = mkTmp('forgeon-module-accounts-db-');
2233
2316
  const projectRoot = path.join(targetRoot, 'demo-accounts-db');
@@ -2706,6 +2789,11 @@ describe('addModule', () => {
2706
2789
 
2707
2790
 
2708
2791
 
2792
+
2793
+
2794
+
2795
+
2796
+
2709
2797
 
2710
2798
 
2711
2799
 
@@ -67,10 +67,10 @@ const MODULE_PRESETS = {
67
67
  category: 'file-storage',
68
68
  implemented: true,
69
69
  description:
70
- 'Resource-level access policy module for files metadata/download/delete operations. Requires files-runtime capability.',
70
+ 'Resource-level access policy extension for the files core module. Requires files.',
71
71
  detectionPaths: ['packages/files-access/package.json'],
72
72
  provides: ['files-access-runtime'],
73
- requires: [{ type: 'capability', id: 'files-runtime' }],
73
+ requires: [{ type: 'module', id: 'files' }],
74
74
  optionalIntegrations: [],
75
75
  docFragments: ['00_title', '10_overview', '20_scope', '90_status_implemented'],
76
76
  },
@@ -80,10 +80,10 @@ const MODULE_PRESETS = {
80
80
  category: 'file-storage',
81
81
  implemented: true,
82
82
  description:
83
- 'Owner-level upload quota policy module for files. Requires files-runtime capability.',
83
+ 'Owner-level upload quota extension for the files core module. Requires files.',
84
84
  detectionPaths: ['packages/files-quotas/package.json'],
85
85
  provides: ['files-quotas-runtime'],
86
- requires: [{ type: 'capability', id: 'files-runtime' }],
86
+ requires: [{ type: 'module', id: 'files' }],
87
87
  optionalIntegrations: [],
88
88
  docFragments: ['00_title', '10_overview', '20_scope', '90_status_implemented'],
89
89
  },
@@ -93,10 +93,10 @@ const MODULE_PRESETS = {
93
93
  category: 'file-storage',
94
94
  implemented: true,
95
95
  description:
96
- 'Image sanitation module for files runtime (magic-bytes detect + sharp re-encode, metadata stripped by default). Requires files-runtime capability.',
96
+ 'Image sanitation extension for the files core module (magic-bytes detect + sharp re-encode, metadata stripped by default). Requires files.',
97
97
  detectionPaths: ['packages/files-image/package.json'],
98
98
  provides: ['files-image-runtime'],
99
- requires: [{ type: 'capability', id: 'files-runtime' }],
99
+ requires: [{ type: 'module', id: 'files' }],
100
100
  optionalIntegrations: [],
101
101
  docFragments: ['00_title', '10_overview', '20_scope', '90_status_implemented'],
102
102
  },
@@ -1,4 +1,4 @@
1
- ## Scope
1
+ ## Scope
2
2
 
3
3
  Implemented scope:
4
4
 
@@ -14,7 +14,10 @@ Implemented scope:
14
14
  - email channel with Gmail SMTP transport configuration
15
15
  - sms stub channel
16
16
  - push stub channel
17
- 5. Module checks:
17
+ 5. SMTP defaults:
18
+ - `COMMUNICATIONS_EMAIL_SMTP_SECURE=false` uses STARTTLS mode correctly on port `587`
19
+ - `COMMUNICATIONS_EMAIL_FROM` falls back to the authenticated SMTP user when left empty
20
+ 6. Module checks:
18
21
  - `GET /api/health/communications`
19
22
  - `POST /api/health/communications`
20
23
  - default web probe with email input + test send
@@ -1,4 +1,4 @@
1
- import {
1
+ import {
2
2
  DynamicModule,
3
3
  Module,
4
4
  ModuleMetadata,
@@ -6,7 +6,6 @@ import {
6
6
  } from '@nestjs/common';
7
7
  import { JwtModule } from '@nestjs/jwt';
8
8
  import { PassportModule } from '@nestjs/passport';
9
- import { ForgeonCommunicationsModule } from '@forgeon/communications';
10
9
  import { DbPrismaModule } from '@forgeon/db-prisma';
11
10
  import {
12
11
  ACCOUNTS_AUTHZ_CLAIMS_RESOLVER,
@@ -41,7 +40,6 @@ export class ForgeonAccountsModule {
41
40
  imports: [
42
41
  AuthConfigModule,
43
42
  DbPrismaModule,
44
- ForgeonCommunicationsModule.register(),
45
43
  PassportModule.register({ defaultStrategy: 'jwt' }),
46
44
  JwtModule.register({}),
47
45
  ...(options.imports ?? []),
@@ -31,12 +31,13 @@ export const communicationsConfig = registerAs(
31
31
  COMMUNICATIONS_CONFIG_NAMESPACE,
32
32
  (): CommunicationsConfigValues => {
33
33
  const env = parseCommunicationsEnv(process.env as unknown as Record<string, unknown>);
34
+ const derivedFrom = env.COMMUNICATIONS_EMAIL_FROM || env.COMMUNICATIONS_EMAIL_SMTP_USER;
34
35
 
35
36
  return {
36
37
  templatesRoot: path.resolve(process.cwd(), env.COMMUNICATIONS_TEMPLATES_ROOT),
37
38
  email: {
38
39
  provider: env.COMMUNICATIONS_EMAIL_PROVIDER,
39
- from: env.COMMUNICATIONS_EMAIL_FROM,
40
+ from: derivedFrom,
40
41
  replyTo: env.COMMUNICATIONS_EMAIL_REPLY_TO || null,
41
42
  subjectPrefix: env.COMMUNICATIONS_EMAIL_SUBJECT_PREFIX || null,
42
43
  smtp: {
@@ -1,19 +1,35 @@
1
- import { z } from 'zod';
1
+ import { z } from 'zod';
2
+
3
+ function normalizeEnvBoolean(value: unknown): unknown {
4
+ if (typeof value === 'string') {
5
+ const normalized = value.trim().toLowerCase();
6
+ if (['true', '1', 'yes', 'on'].includes(normalized)) {
7
+ return true;
8
+ }
9
+ if (['false', '0', 'no', 'off', ''].includes(normalized)) {
10
+ return false;
11
+ }
12
+ }
13
+
14
+ return value;
15
+ }
16
+
17
+ const envBoolean = z.preprocess(normalizeEnvBoolean, z.boolean());
2
18
 
3
19
  export const communicationsEnvSchema = z
4
20
  .object({
5
21
  COMMUNICATIONS_TEMPLATES_ROOT: z.string().trim().min(1).default('resources/communications'),
6
22
  COMMUNICATIONS_EMAIL_PROVIDER: z.enum(['gmail-smtp']).default('gmail-smtp'),
7
- COMMUNICATIONS_EMAIL_FROM: z.string().trim().min(1).default('Forgeon <no-reply@example.com>'),
23
+ COMMUNICATIONS_EMAIL_FROM: z.string().trim().default(''),
8
24
  COMMUNICATIONS_EMAIL_REPLY_TO: z.string().trim().default(''),
9
25
  COMMUNICATIONS_EMAIL_SUBJECT_PREFIX: z.string().trim().default('[Forgeon]'),
10
26
  COMMUNICATIONS_EMAIL_SMTP_HOST: z.string().trim().min(1).default('smtp.gmail.com'),
11
27
  COMMUNICATIONS_EMAIL_SMTP_PORT: z.coerce.number().int().min(1).max(65535).default(587),
12
- COMMUNICATIONS_EMAIL_SMTP_SECURE: z.coerce.boolean().default(false),
28
+ COMMUNICATIONS_EMAIL_SMTP_SECURE: envBoolean.default(false),
13
29
  COMMUNICATIONS_EMAIL_SMTP_USER: z.string().trim().default(''),
14
30
  COMMUNICATIONS_EMAIL_SMTP_PASS: z.string().trim().default(''),
15
31
  COMMUNICATIONS_SMS_PROVIDER: z.enum(['stub']).default('stub'),
16
- COMMUNICATIONS_PUSH_PROVIDER: z.enum(['stub']).default('stub')
32
+ COMMUNICATIONS_PUSH_PROVIDER: z.enum(['stub']).default('stub'),
17
33
  })
18
34
  .passthrough();
19
35
 
@@ -21,4 +37,4 @@ export type CommunicationsEnv = z.infer<typeof communicationsEnvSchema>;
21
37
 
22
38
  export function parseCommunicationsEnv(input: Record<string, unknown>): CommunicationsEnv {
23
39
  return communicationsEnvSchema.parse(input);
24
- }
40
+ }
@@ -6,6 +6,7 @@ import type { EmailProvider, EmailProviderSendInput, EmailProviderSendResult } f
6
6
 
7
7
  const EMAIL_ERROR_CODES = {
8
8
  providerNotConfigured: 'COMMUNICATIONS_EMAIL_PROVIDER_NOT_CONFIGURED',
9
+ providerSendFailed: 'COMMUNICATIONS_EMAIL_PROVIDER_SEND_FAILED',
9
10
  } as const;
10
11
 
11
12
  @Injectable()
@@ -30,20 +31,33 @@ export class GmailSmtpEmailProvider implements EmailProvider {
30
31
  });
31
32
  }
32
33
 
33
- const response = await this.getTransporter().sendMail({
34
- from: this.configService.emailFrom,
35
- to: input.to,
36
- subject: input.subject,
37
- html: input.html,
38
- replyTo: input.replyTo ?? this.configService.emailReplyTo ?? undefined,
39
- });
34
+ try {
35
+ const response = await this.getTransporter().sendMail({
36
+ from: this.configService.emailFrom,
37
+ to: input.to,
38
+ subject: input.subject,
39
+ html: input.html,
40
+ replyTo: input.replyTo ?? this.configService.emailReplyTo ?? undefined,
41
+ });
40
42
 
41
- this.logger.log(`email.sent provider=${this.providerId} to=${input.to} messageId=${response.messageId ?? 'n/a'}`);
43
+ this.logger.log(`email.sent provider=${this.providerId} to=${input.to} messageId=${response.messageId ?? 'n/a'}`);
42
44
 
43
- return {
44
- status: 'sent',
45
- messageId: response.messageId ?? null,
46
- };
45
+ return {
46
+ status: 'sent',
47
+ messageId: response.messageId ?? null,
48
+ };
49
+ } catch (error) {
50
+ const details = this.extractErrorDetails(error);
51
+ this.logger.error(`email.failed provider=${this.providerId} to=${input.to} details=${JSON.stringify(details)}`);
52
+ throw new ServiceUnavailableException({
53
+ message: 'Email delivery failed',
54
+ details: {
55
+ code: EMAIL_ERROR_CODES.providerSendFailed,
56
+ provider: this.providerId,
57
+ ...details,
58
+ },
59
+ });
60
+ }
47
61
  }
48
62
 
49
63
  private getTransporter(): Transporter {
@@ -61,4 +75,30 @@ export class GmailSmtpEmailProvider implements EmailProvider {
61
75
 
62
76
  return this.transporter;
63
77
  }
78
+
79
+ private extractErrorDetails(error: unknown): Record<string, unknown> {
80
+ if (!error || typeof error !== 'object') {
81
+ return { raw: String(error) };
82
+ }
83
+
84
+ const candidate = error as {
85
+ code?: unknown;
86
+ command?: unknown;
87
+ response?: unknown;
88
+ responseCode?: unknown;
89
+ errno?: unknown;
90
+ syscall?: unknown;
91
+ message?: unknown;
92
+ };
93
+
94
+ return {
95
+ message: typeof candidate.message === 'string' ? candidate.message : String(error),
96
+ code: candidate.code ?? null,
97
+ command: candidate.command ?? null,
98
+ responseCode: candidate.responseCode ?? null,
99
+ response: candidate.response ?? null,
100
+ errno: candidate.errno ?? null,
101
+ syscall: candidate.syscall ?? null,
102
+ };
103
+ }
64
104
  }
@@ -1,4 +1,4 @@
1
- import { DynamicModule, Module, ModuleMetadata } from '@nestjs/common';
1
+ import { DynamicModule, Global, Module, ModuleMetadata } from '@nestjs/common';
2
2
  import {
3
3
  COMMUNICATIONS_EMAIL_PROVIDER,
4
4
  COMMUNICATIONS_PUSH_PROVIDER,
@@ -20,6 +20,7 @@ export interface ForgeonCommunicationsModuleOptions {
20
20
  imports?: ModuleMetadata['imports'];
21
21
  }
22
22
 
23
+ @Global()
23
24
  @Module({})
24
25
  export class ForgeonCommunicationsModule {
25
26
  static register(options: ForgeonCommunicationsModuleOptions = {}): DynamicModule {