@thinhnguyencth1204/nextcli 0.5.0 → 0.6.0
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 +7 -4
- package/dist/cli.js +289 -163
- package/package.json +1 -1
- package/templates/features/{resend/src/lib/resend/send-email.ts → email/src/lib/email/providers/resend.ts} +14 -49
- package/templates/features/email/src/lib/email/providers/smtp.ts +116 -0
- package/templates/features/email/src/lib/email/send-email.ts +68 -0
- package/templates/features/email/src/lib/email/types.ts +34 -0
- package/templates/next-base/.env +5 -2
- package/templates/next-base/.env.development +5 -2
- package/templates/next-base/.env.example +5 -2
- package/templates/next-base/PROJECT_STRUCTURE.md +11 -11
- package/templates/next-base/SETUP.md +27 -10
- package/templates/next-base/prisma/schema.prisma +3 -1
- package/templates/features/resend/src/lib/resend/client.ts +0 -5
- package/templates/features/resend/src/lib/resend/config.ts +0 -8
- /package/templates/features/{resend → email}/src/emails/welcome-email.tsx +0 -0
package/README.md
CHANGED
|
@@ -27,7 +27,7 @@ This command is fully interactive:
|
|
|
27
27
|
|
|
28
28
|
- enter project name
|
|
29
29
|
- select package manager in CLI UI (`npm`, `pnpm`, `yarn`, `bun`)
|
|
30
|
-
- multi-select optional modules (`chat`, `supabase-realtime`, `seo`, `
|
|
30
|
+
- multi-select optional modules (`chat`, `supabase-realtime`, `seo`, `email`)
|
|
31
31
|
- confirm install step
|
|
32
32
|
- normalizes project directory name into a safe project slug for generated `package.json` and env placeholders
|
|
33
33
|
- ships `SETUP.md` and `PROJECT_STRUCTURE.md` in the generated project root
|
|
@@ -97,7 +97,7 @@ Interactive multiselect shows available module catalog:
|
|
|
97
97
|
- `chat`
|
|
98
98
|
- `supabase-realtime`
|
|
99
99
|
- `seo`
|
|
100
|
-
- `
|
|
100
|
+
- `email`
|
|
101
101
|
|
|
102
102
|
Non-interactive example:
|
|
103
103
|
|
|
@@ -106,6 +106,9 @@ node ../dist/cli.js add module --module seo --module supabase-realtime --yes
|
|
|
106
106
|
```
|
|
107
107
|
|
|
108
108
|
`supabase` is no longer an optional module; Supabase client, Storage helpers, and Supabase Postgres env placeholders are part of the base template.
|
|
109
|
+
`resend` is now handled by the `email` module (legacy `--module resend` maps to `email` with provider `resend`).
|
|
110
|
+
|
|
111
|
+
When `email` is selected, CLI asks for provider (`resend` or `smtp`) and only merges env keys/dependencies for that provider.
|
|
109
112
|
|
|
110
113
|
## Command: add auth-provider
|
|
111
114
|
|
|
@@ -166,7 +169,7 @@ node ../dist/cli.js migrate --name init_auth --skip-generate
|
|
|
166
169
|
- i18n base with `next-intl`
|
|
167
170
|
- Sonner notifications
|
|
168
171
|
- date-fns utility library
|
|
169
|
-
- Optional modules: chat, supabase-realtime, seo,
|
|
172
|
+
- Optional modules: chat, supabase-realtime, seo, email
|
|
170
173
|
|
|
171
174
|
## Template structure
|
|
172
175
|
|
|
@@ -179,7 +182,7 @@ Optional module templates:
|
|
|
179
182
|
- `templates/features/chat`
|
|
180
183
|
- `templates/features/supabase-realtime`
|
|
181
184
|
- `templates/features/seo`
|
|
182
|
-
- `templates/features/
|
|
185
|
+
- `templates/features/email`
|
|
183
186
|
|
|
184
187
|
## Realtime chat schema foundation
|
|
185
188
|
|
package/dist/cli.js
CHANGED
|
@@ -216,7 +216,7 @@ var templatePaths = {
|
|
|
216
216
|
chat: path2.join(rootDir, "templates/features/chat"),
|
|
217
217
|
supabaseRealtime: path2.join(rootDir, "templates/features/supabase-realtime"),
|
|
218
218
|
seo: path2.join(rootDir, "templates/features/seo"),
|
|
219
|
-
|
|
219
|
+
email: path2.join(rootDir, "templates/features/email")
|
|
220
220
|
};
|
|
221
221
|
|
|
222
222
|
// src/core/modules.ts
|
|
@@ -258,23 +258,17 @@ Requires \`supabase-realtime\` (auto-added). Run \`db:migrate\` after add \u2014
|
|
|
258
258
|
setupSection: `No extra env keys. Edit \`src/app/robots.ts\`, \`sitemap.ts\`, and JSON-LD helpers after add.`
|
|
259
259
|
},
|
|
260
260
|
{
|
|
261
|
-
id: "
|
|
262
|
-
label: "Resend
|
|
263
|
-
description: "Adds
|
|
264
|
-
templatePath: templatePaths.
|
|
265
|
-
env: {
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
"react-email": "^4.0.0"
|
|
273
|
-
},
|
|
274
|
-
setupSection: `| Variable | Where to get |
|
|
275
|
-
| -------- | ------------ |
|
|
276
|
-
| \`RESEND_API_KEY\` | resend.com \u2192 API Keys \u2192 Create |
|
|
277
|
-
| \`RESEND_FROM_EMAIL\` | resend.com \u2192 Domains \u2192 verify domain \u2192 use \`Name <you@domain.com>\` |`
|
|
261
|
+
id: "email",
|
|
262
|
+
label: "Email module (SMTP or Resend)",
|
|
263
|
+
description: "Adds provider-agnostic sendEmail helper with SMTP/Resend adapters",
|
|
264
|
+
templatePath: templatePaths.email,
|
|
265
|
+
env: {},
|
|
266
|
+
setupSection: `When you add this module, NexTCLI asks which provider to configure:
|
|
267
|
+
|
|
268
|
+
- **Resend**: \`EMAIL_PROVIDER=resend\`, \`RESEND_API_KEY\`, \`RESEND_FROM_EMAIL\`
|
|
269
|
+
- **SMTP**: \`EMAIL_PROVIDER=smtp\`, \`SMTP_HOST\`, \`SMTP_PORT\`, \`SMTP_USER\`, \`SMTP_PASSWORD\`, \`SMTP_FROM\`
|
|
270
|
+
|
|
271
|
+
Only the selected provider keys are merged into your env files.`
|
|
278
272
|
}
|
|
279
273
|
];
|
|
280
274
|
function getModuleById(moduleId) {
|
|
@@ -448,6 +442,42 @@ async function ensureBetterAuthGenerate(cwd, options = {}) {
|
|
|
448
442
|
}
|
|
449
443
|
}
|
|
450
444
|
|
|
445
|
+
// src/core/email-module.ts
|
|
446
|
+
var EMAIL_PROVIDER_OPTIONS = ["resend", "smtp"];
|
|
447
|
+
function isEmailProvider(value) {
|
|
448
|
+
return EMAIL_PROVIDER_OPTIONS.includes(value);
|
|
449
|
+
}
|
|
450
|
+
function getEmailProviderEnv(provider) {
|
|
451
|
+
if (provider === "smtp") {
|
|
452
|
+
return {
|
|
453
|
+
EMAIL_PROVIDER: "smtp",
|
|
454
|
+
SMTP_HOST: "",
|
|
455
|
+
SMTP_PORT: "587",
|
|
456
|
+
SMTP_USER: "",
|
|
457
|
+
SMTP_PASSWORD: "",
|
|
458
|
+
SMTP_FROM: ""
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
return {
|
|
462
|
+
EMAIL_PROVIDER: "resend",
|
|
463
|
+
RESEND_API_KEY: "",
|
|
464
|
+
RESEND_FROM_EMAIL: ""
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
function getEmailProviderDependencies(provider) {
|
|
468
|
+
if (provider === "smtp") {
|
|
469
|
+
return {
|
|
470
|
+
nodemailer: "^6.10.1",
|
|
471
|
+
"@types/nodemailer": "^6.4.17"
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
return {
|
|
475
|
+
resend: "^6.9.2",
|
|
476
|
+
"@react-email/components": "^1.0.12",
|
|
477
|
+
"react-email": "^4.0.0"
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
451
481
|
// src/core/chat-schema.ts
|
|
452
482
|
import path3 from "path";
|
|
453
483
|
import { readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
|
|
@@ -552,7 +582,7 @@ import { readdir as readdir3, readFile as readFile4, writeFile as writeFile4 } f
|
|
|
552
582
|
import path4 from "path";
|
|
553
583
|
import { readdir as readdir2, readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
|
|
554
584
|
var defaultManifest = {
|
|
555
|
-
cli: "0.
|
|
585
|
+
cli: "0.6.0",
|
|
556
586
|
defaultLocale: "vi",
|
|
557
587
|
locales: ["vi"],
|
|
558
588
|
namespaces: ["common", "auth", "example"],
|
|
@@ -617,9 +647,7 @@ async function reconcileManifest(projectDir) {
|
|
|
617
647
|
]
|
|
618
648
|
};
|
|
619
649
|
merged.defaultLocale = merged.locales.includes(merged.defaultLocale) ? merged.defaultLocale : merged.locales[0] ?? "vi";
|
|
620
|
-
merged.modules = [...new Set(merged.modules)].filter(
|
|
621
|
-
(moduleId) => moduleId !== "supabase"
|
|
622
|
-
);
|
|
650
|
+
merged.modules = [...new Set(merged.modules)].map((moduleId) => moduleId === "resend" ? "email" : moduleId).filter((moduleId) => moduleId !== "supabase");
|
|
623
651
|
merged.features = [...new Set(merged.features)];
|
|
624
652
|
await writeManifest(projectDir, merged);
|
|
625
653
|
return merged;
|
|
@@ -1419,166 +1447,239 @@ function registerAddCommand(program2) {
|
|
|
1419
1447
|
"No migration was executed. Run your migration command manually when ready."
|
|
1420
1448
|
);
|
|
1421
1449
|
});
|
|
1422
|
-
add.command("module").description("Add optional modules using interactive multi-select").option("--module <module...>", "Preselect module ids").option(
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
);
|
|
1434
|
-
const requestedValues = options.module ? options.module.flatMap((value) => value.split(",")).map((value) => value.trim()).filter(Boolean) : [];
|
|
1435
|
-
const requestedIds = [];
|
|
1436
|
-
for (const moduleId of requestedValues) {
|
|
1437
|
-
if (validIds.has(moduleId)) {
|
|
1438
|
-
requestedIds.push(moduleId);
|
|
1439
|
-
continue;
|
|
1440
|
-
}
|
|
1441
|
-
if (moduleId === "supabase") {
|
|
1442
|
-
log.info(
|
|
1443
|
-
"Supabase is now part of the base template; only supabase-realtime is optional."
|
|
1450
|
+
add.command("module").description("Add optional modules using interactive multi-select").option("--module <module...>", "Preselect module ids").option(
|
|
1451
|
+
"--email-provider <provider>",
|
|
1452
|
+
"Email provider for email module: resend or smtp"
|
|
1453
|
+
).option("--yes", "Skip prompts").action(
|
|
1454
|
+
async (options) => {
|
|
1455
|
+
const cwd = process.cwd();
|
|
1456
|
+
const hasSrc = await pathExists(path7.join(cwd, "src"));
|
|
1457
|
+
const hasPackageJson = await pathExists(path7.join(cwd, "package.json"));
|
|
1458
|
+
if (!hasSrc || !hasPackageJson) {
|
|
1459
|
+
log.error(
|
|
1460
|
+
"Run this command from your generated Next.js project root."
|
|
1444
1461
|
);
|
|
1445
|
-
|
|
1462
|
+
process.exitCode = 1;
|
|
1463
|
+
return;
|
|
1446
1464
|
}
|
|
1447
|
-
|
|
1448
|
-
|
|
1465
|
+
const validIds = new Set(
|
|
1466
|
+
optionalModules.map((module) => module.id)
|
|
1467
|
+
);
|
|
1468
|
+
const requestedValues = options.module ? options.module.flatMap((value) => value.split(",")).map((value) => value.trim()).filter(Boolean) : [];
|
|
1469
|
+
const requestedIds = [];
|
|
1470
|
+
let emailProviderFromModuleAlias;
|
|
1471
|
+
const optionEmailProvider = options.emailProvider ? options.emailProvider.trim().toLowerCase() : void 0;
|
|
1472
|
+
if (optionEmailProvider && !isEmailProvider(optionEmailProvider)) {
|
|
1473
|
+
log.error(`Unknown email provider: ${options.emailProvider}`);
|
|
1449
1474
|
process.exitCode = 1;
|
|
1450
1475
|
return;
|
|
1451
1476
|
}
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
moduleId,
|
|
1477
|
-
paths: copyReport.skippedConflicts
|
|
1478
|
-
});
|
|
1477
|
+
for (const moduleId of requestedValues) {
|
|
1478
|
+
if (validIds.has(moduleId)) {
|
|
1479
|
+
requestedIds.push(moduleId);
|
|
1480
|
+
continue;
|
|
1481
|
+
}
|
|
1482
|
+
if (moduleId === "supabase") {
|
|
1483
|
+
log.info(
|
|
1484
|
+
"Supabase is now part of the base template; only supabase-realtime is optional."
|
|
1485
|
+
);
|
|
1486
|
+
continue;
|
|
1487
|
+
}
|
|
1488
|
+
if (moduleId === "resend") {
|
|
1489
|
+
log.info(
|
|
1490
|
+
"Module 'resend' is now 'email'; selecting email with provider resend."
|
|
1491
|
+
);
|
|
1492
|
+
requestedIds.push("email");
|
|
1493
|
+
emailProviderFromModuleAlias = "resend";
|
|
1494
|
+
continue;
|
|
1495
|
+
}
|
|
1496
|
+
if (!validIds.has(moduleId)) {
|
|
1497
|
+
log.error(`Unknown module: ${moduleId}`);
|
|
1498
|
+
process.exitCode = 1;
|
|
1499
|
+
return;
|
|
1500
|
+
}
|
|
1479
1501
|
}
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1502
|
+
startPrompt("NexTCLI optional modules");
|
|
1503
|
+
const rawModules = requestedIds.length > 0 ? [...new Set(requestedIds)] : options.yes ? [] : await askMultiSelect(
|
|
1504
|
+
"Select modules to add:",
|
|
1505
|
+
optionalModules.map((module) => ({
|
|
1506
|
+
value: module.id,
|
|
1507
|
+
label: module.label,
|
|
1508
|
+
hint: module.description
|
|
1509
|
+
})),
|
|
1510
|
+
[]
|
|
1511
|
+
);
|
|
1512
|
+
const { selectedModules, autoAddedModules } = normalizeModuleSelection(rawModules);
|
|
1513
|
+
if (optionEmailProvider && !selectedModules.includes("email")) {
|
|
1514
|
+
log.warn(
|
|
1515
|
+
"Ignoring --email-provider because email module is not selected."
|
|
1516
|
+
);
|
|
1490
1517
|
}
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
if (
|
|
1494
|
-
|
|
1495
|
-
|
|
1518
|
+
let emailProvider;
|
|
1519
|
+
if (selectedModules.includes("email")) {
|
|
1520
|
+
if (emailProviderFromModuleAlias) {
|
|
1521
|
+
emailProvider = emailProviderFromModuleAlias;
|
|
1522
|
+
} else if (optionEmailProvider && isEmailProvider(optionEmailProvider)) {
|
|
1523
|
+
emailProvider = optionEmailProvider;
|
|
1524
|
+
} else if (options.yes) {
|
|
1525
|
+
emailProvider = "resend";
|
|
1526
|
+
log.info(
|
|
1527
|
+
"No --email-provider supplied in --yes mode; defaulting email provider to resend."
|
|
1528
|
+
);
|
|
1529
|
+
} else {
|
|
1530
|
+
emailProvider = await askSelect(
|
|
1531
|
+
"Select email provider:",
|
|
1532
|
+
[
|
|
1533
|
+
{
|
|
1534
|
+
value: "resend",
|
|
1535
|
+
label: "Resend",
|
|
1536
|
+
hint: "Transactional email API with React Email support"
|
|
1537
|
+
},
|
|
1538
|
+
{
|
|
1539
|
+
value: "smtp",
|
|
1540
|
+
label: "SMTP",
|
|
1541
|
+
hint: "Use your SMTP relay credentials"
|
|
1542
|
+
}
|
|
1543
|
+
],
|
|
1544
|
+
"resend"
|
|
1545
|
+
);
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
if (selectedModules.length === 0) {
|
|
1549
|
+
finishPrompt("No modules selected.");
|
|
1550
|
+
return;
|
|
1551
|
+
}
|
|
1552
|
+
const skippedConflictsByModule = [];
|
|
1553
|
+
let copiedFileCount = 0;
|
|
1554
|
+
for (const moduleId of selectedModules) {
|
|
1555
|
+
const module = getModuleById(moduleId);
|
|
1556
|
+
const copyReport = await copyDirectorySafely(
|
|
1557
|
+
module.templatePath,
|
|
1558
|
+
cwd
|
|
1559
|
+
);
|
|
1560
|
+
copiedFileCount += copyReport.copiedCount;
|
|
1561
|
+
if (copyReport.skippedConflicts.length > 0) {
|
|
1562
|
+
skippedConflictsByModule.push({
|
|
1563
|
+
moduleId,
|
|
1564
|
+
paths: copyReport.skippedConflicts
|
|
1496
1565
|
});
|
|
1497
1566
|
}
|
|
1498
1567
|
}
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1568
|
+
let chatSchemaStatus;
|
|
1569
|
+
if (selectedModules.includes("chat")) {
|
|
1570
|
+
chatSchemaStatus = await ensureChatSchemaInProject(cwd);
|
|
1571
|
+
}
|
|
1572
|
+
const envTargets = [".env", ".env.example", ".env.development"];
|
|
1573
|
+
for (const moduleId of selectedModules) {
|
|
1502
1574
|
const module = getModuleById(moduleId);
|
|
1575
|
+
const moduleEnv = moduleId === "email" && emailProvider ? getEmailProviderEnv(emailProvider) : module.env;
|
|
1576
|
+
if (Object.keys(moduleEnv).length === 0) {
|
|
1577
|
+
continue;
|
|
1578
|
+
}
|
|
1579
|
+
for (const envFile of envTargets) {
|
|
1580
|
+
const envPath = path7.join(cwd, envFile);
|
|
1581
|
+
if (await pathExists(envPath)) {
|
|
1582
|
+
await mergeEnvFile(envPath, moduleEnv, {
|
|
1583
|
+
header: `# --- module: ${module.id} ---`
|
|
1584
|
+
});
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
const dependencyEntries = selectedModules.reduce((acc, moduleId) => {
|
|
1589
|
+
const module = getModuleById(moduleId);
|
|
1590
|
+
const emailDependencies = moduleId === "email" && emailProvider ? getEmailProviderDependencies(emailProvider) : {};
|
|
1503
1591
|
return {
|
|
1504
1592
|
...acc,
|
|
1593
|
+
...emailDependencies,
|
|
1505
1594
|
...module.dependencies ?? {}
|
|
1506
1595
|
};
|
|
1507
|
-
},
|
|
1508
|
-
{
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
dependencyEntries
|
|
1514
|
-
);
|
|
1515
|
-
}
|
|
1516
|
-
const state = await detectProjectState(cwd);
|
|
1517
|
-
const namespaceSet = new Set(state.namespaces);
|
|
1518
|
-
for (const moduleId of selectedModules) {
|
|
1519
|
-
const moduleTemplateMessages = path7.join(
|
|
1520
|
-
getModuleById(moduleId).templatePath,
|
|
1521
|
-
"messages/vi"
|
|
1522
|
-
);
|
|
1523
|
-
if (!await pathExists(moduleTemplateMessages)) {
|
|
1524
|
-
continue;
|
|
1596
|
+
}, {});
|
|
1597
|
+
if (Object.keys(dependencyEntries).length > 0) {
|
|
1598
|
+
await addDependencies(
|
|
1599
|
+
path7.join(cwd, "package.json"),
|
|
1600
|
+
dependencyEntries
|
|
1601
|
+
);
|
|
1525
1602
|
}
|
|
1526
|
-
const
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1603
|
+
const state = await detectProjectState(cwd);
|
|
1604
|
+
const namespaceSet = new Set(state.namespaces);
|
|
1605
|
+
for (const moduleId of selectedModules) {
|
|
1606
|
+
const moduleTemplateMessages = path7.join(
|
|
1607
|
+
getModuleById(moduleId).templatePath,
|
|
1608
|
+
"messages/vi"
|
|
1609
|
+
);
|
|
1610
|
+
if (!await pathExists(moduleTemplateMessages)) {
|
|
1531
1611
|
continue;
|
|
1532
1612
|
}
|
|
1533
|
-
const
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1613
|
+
const files = await readdir4(moduleTemplateMessages, {
|
|
1614
|
+
withFileTypes: true
|
|
1615
|
+
});
|
|
1616
|
+
for (const file of files) {
|
|
1617
|
+
if (!file.isFile() || !file.name.endsWith(".json")) {
|
|
1618
|
+
continue;
|
|
1619
|
+
}
|
|
1620
|
+
const namespace = file.name.replace(/\.json$/, "");
|
|
1621
|
+
namespaceSet.add(namespace);
|
|
1622
|
+
const templateData = JSON.parse(
|
|
1623
|
+
await readFile6(
|
|
1624
|
+
path7.join(moduleTemplateMessages, file.name),
|
|
1625
|
+
"utf8"
|
|
1626
|
+
)
|
|
1627
|
+
);
|
|
1628
|
+
await writeNamespaceMessages(cwd, namespace, templateData);
|
|
1629
|
+
await appendNamespace(cwd, namespace);
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
await writeManifest(cwd, {
|
|
1633
|
+
...state,
|
|
1634
|
+
namespaces: [...namespaceSet],
|
|
1635
|
+
modules: [
|
|
1636
|
+
...new Set(
|
|
1637
|
+
[...state.modules, ...selectedModules].map(
|
|
1638
|
+
(moduleId) => moduleId === "resend" ? "email" : moduleId
|
|
1639
|
+
)
|
|
1640
|
+
)
|
|
1641
|
+
]
|
|
1642
|
+
});
|
|
1643
|
+
const mergedModules = [
|
|
1644
|
+
...new Set(
|
|
1645
|
+
[...state.modules, ...selectedModules].map(
|
|
1646
|
+
(moduleId) => moduleId === "resend" ? "email" : moduleId
|
|
1539
1647
|
)
|
|
1648
|
+
)
|
|
1649
|
+
];
|
|
1650
|
+
await mergeModuleSetupSections(cwd, selectedModules, mergedModules);
|
|
1651
|
+
finishPrompt(`Added modules: ${selectedModules.join(", ")}`);
|
|
1652
|
+
log.detail("Copied files", String(copiedFileCount));
|
|
1653
|
+
if (autoAddedModules.length > 0) {
|
|
1654
|
+
log.detail("Auto-added", autoAddedModules.join(", "));
|
|
1655
|
+
}
|
|
1656
|
+
if (emailProvider) {
|
|
1657
|
+
log.detail("Email provider", emailProvider);
|
|
1658
|
+
}
|
|
1659
|
+
if (skippedConflictsByModule.length > 0) {
|
|
1660
|
+
const totalSkipped = skippedConflictsByModule.reduce(
|
|
1661
|
+
(total, item) => total + item.paths.length,
|
|
1662
|
+
0
|
|
1663
|
+
);
|
|
1664
|
+
log.warn(
|
|
1665
|
+
`Skipped ${totalSkipped} existing file(s). Existing project files were kept unchanged.`
|
|
1540
1666
|
);
|
|
1541
|
-
|
|
1542
|
-
|
|
1667
|
+
for (const conflict of skippedConflictsByModule) {
|
|
1668
|
+
const preview = conflict.paths.slice(0, 3).join(", ");
|
|
1669
|
+
const suffix = conflict.paths.length > 3 ? ", ..." : "";
|
|
1670
|
+
log.detail(`Skipped (${conflict.moduleId})`, `${preview}${suffix}`);
|
|
1671
|
+
}
|
|
1543
1672
|
}
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
modules: [.../* @__PURE__ */ new Set([...state.modules, ...selectedModules])]
|
|
1549
|
-
});
|
|
1550
|
-
const mergedModules = [
|
|
1551
|
-
.../* @__PURE__ */ new Set([...state.modules, ...selectedModules])
|
|
1552
|
-
];
|
|
1553
|
-
await mergeModuleSetupSections(cwd, selectedModules, mergedModules);
|
|
1554
|
-
finishPrompt(`Added modules: ${selectedModules.join(", ")}`);
|
|
1555
|
-
log.detail("Copied files", String(copiedFileCount));
|
|
1556
|
-
if (autoAddedModules.length > 0) {
|
|
1557
|
-
log.detail("Auto-added", autoAddedModules.join(", "));
|
|
1558
|
-
}
|
|
1559
|
-
if (skippedConflictsByModule.length > 0) {
|
|
1560
|
-
const totalSkipped = skippedConflictsByModule.reduce(
|
|
1561
|
-
(total, item) => total + item.paths.length,
|
|
1562
|
-
0
|
|
1563
|
-
);
|
|
1564
|
-
log.warn(
|
|
1565
|
-
`Skipped ${totalSkipped} existing file(s). Existing project files were kept unchanged.`
|
|
1566
|
-
);
|
|
1567
|
-
for (const conflict of skippedConflictsByModule) {
|
|
1568
|
-
const preview = conflict.paths.slice(0, 3).join(", ");
|
|
1569
|
-
const suffix = conflict.paths.length > 3 ? ", ..." : "";
|
|
1570
|
-
log.detail(`Skipped (${conflict.moduleId})`, `${preview}${suffix}`);
|
|
1673
|
+
if (chatSchemaStatus === "added") {
|
|
1674
|
+
log.info(
|
|
1675
|
+
"Optional chat schema block was appended to prisma/schema.prisma."
|
|
1676
|
+
);
|
|
1571
1677
|
}
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
log.info(
|
|
1575
|
-
"Optional chat schema block was appended to prisma/schema.prisma."
|
|
1678
|
+
log.step(
|
|
1679
|
+
"Next: run your package manager install to apply new dependencies."
|
|
1576
1680
|
);
|
|
1577
1681
|
}
|
|
1578
|
-
|
|
1579
|
-
"Next: run your package manager install to apply new dependencies."
|
|
1580
|
-
);
|
|
1581
|
-
});
|
|
1682
|
+
);
|
|
1582
1683
|
add.command("language").description("Add locales and clone message files from Vietnamese").option("--locale <locale...>", "Preselect locales: en,ja,ko").option("--yes", "Skip prompts").action(async (options) => {
|
|
1583
1684
|
const cwd = process.cwd();
|
|
1584
1685
|
const hasMessages = await pathExists(path7.join(cwd, "messages"));
|
|
@@ -1821,6 +1922,25 @@ function registerCreateCommand(program2) {
|
|
|
1821
1922
|
[]
|
|
1822
1923
|
);
|
|
1823
1924
|
const { selectedModules, autoAddedModules } = normalizeModuleSelection2(rawModules);
|
|
1925
|
+
let emailProvider;
|
|
1926
|
+
if (selectedModules.includes("email")) {
|
|
1927
|
+
emailProvider = await askSelect(
|
|
1928
|
+
"Select email provider:",
|
|
1929
|
+
[
|
|
1930
|
+
{
|
|
1931
|
+
value: "resend",
|
|
1932
|
+
label: "Resend",
|
|
1933
|
+
hint: "Transactional email API with React Email support"
|
|
1934
|
+
},
|
|
1935
|
+
{
|
|
1936
|
+
value: "smtp",
|
|
1937
|
+
label: "SMTP",
|
|
1938
|
+
hint: "Use your SMTP relay credentials"
|
|
1939
|
+
}
|
|
1940
|
+
],
|
|
1941
|
+
"resend"
|
|
1942
|
+
);
|
|
1943
|
+
}
|
|
1824
1944
|
const shouldInstall = await askConfirm("Install dependencies now?", true);
|
|
1825
1945
|
await copyDirectory(templatePaths.base, targetPath);
|
|
1826
1946
|
for (const moduleId of selectedModules) {
|
|
@@ -1834,13 +1954,14 @@ function registerCreateCommand(program2) {
|
|
|
1834
1954
|
const envTargets = [".env", ".env.example", ".env.development"];
|
|
1835
1955
|
for (const moduleId of selectedModules) {
|
|
1836
1956
|
const module = getModuleById(moduleId);
|
|
1837
|
-
|
|
1957
|
+
const moduleEnv = moduleId === "email" && emailProvider ? getEmailProviderEnv(emailProvider) : module.env;
|
|
1958
|
+
if (Object.keys(moduleEnv).length === 0) {
|
|
1838
1959
|
continue;
|
|
1839
1960
|
}
|
|
1840
1961
|
for (const envFile of envTargets) {
|
|
1841
1962
|
const envPath = path8.join(targetPath, envFile);
|
|
1842
1963
|
if (await pathExists(envPath)) {
|
|
1843
|
-
await mergeEnvFile(envPath,
|
|
1964
|
+
await mergeEnvFile(envPath, moduleEnv, {
|
|
1844
1965
|
header: `# --- module: ${module.id} ---`
|
|
1845
1966
|
});
|
|
1846
1967
|
}
|
|
@@ -1849,8 +1970,10 @@ function registerCreateCommand(program2) {
|
|
|
1849
1970
|
const dependencyEntries = selectedModules.reduce(
|
|
1850
1971
|
(acc, moduleId) => {
|
|
1851
1972
|
const module = getModuleById(moduleId);
|
|
1973
|
+
const emailDependencies = moduleId === "email" && emailProvider ? getEmailProviderDependencies(emailProvider) : {};
|
|
1852
1974
|
return {
|
|
1853
1975
|
...acc,
|
|
1976
|
+
...emailDependencies,
|
|
1854
1977
|
...module.dependencies ?? {}
|
|
1855
1978
|
};
|
|
1856
1979
|
},
|
|
@@ -1866,7 +1989,7 @@ function registerCreateCommand(program2) {
|
|
|
1866
1989
|
await replaceTokensInDirectory(targetPath, {
|
|
1867
1990
|
__PROJECT_NAME__: projectSlug,
|
|
1868
1991
|
__BETTER_AUTH_SECRET__: betterAuthSecret,
|
|
1869
|
-
__NEXTCLI_VERSION__: "0.
|
|
1992
|
+
__NEXTCLI_VERSION__: "0.6.0"
|
|
1870
1993
|
});
|
|
1871
1994
|
await mergeModuleSetupSections(
|
|
1872
1995
|
targetPath,
|
|
@@ -1877,7 +2000,7 @@ function registerCreateCommand(program2) {
|
|
|
1877
2000
|
if (manifest) {
|
|
1878
2001
|
await writeManifest(targetPath, {
|
|
1879
2002
|
...manifest,
|
|
1880
|
-
cli: "0.
|
|
2003
|
+
cli: "0.6.0",
|
|
1881
2004
|
modules: selectedModules
|
|
1882
2005
|
});
|
|
1883
2006
|
}
|
|
@@ -1897,6 +2020,9 @@ function registerCreateCommand(program2) {
|
|
|
1897
2020
|
if (autoAddedModules.length > 0) {
|
|
1898
2021
|
log.detail("Auto-added", autoAddedModules.join(", "));
|
|
1899
2022
|
}
|
|
2023
|
+
if (emailProvider) {
|
|
2024
|
+
log.detail("Email provider", emailProvider);
|
|
2025
|
+
}
|
|
1900
2026
|
if (chatSchemaStatus === "added") {
|
|
1901
2027
|
log.info(
|
|
1902
2028
|
"Optional chat schema block was appended to prisma/schema.prisma."
|
|
@@ -2119,7 +2245,7 @@ var NexTCLICommand = class _NexTCLICommand extends Command {
|
|
|
2119
2245
|
|
|
2120
2246
|
// src/cli.ts
|
|
2121
2247
|
var program = new NexTCLICommand();
|
|
2122
|
-
program.name("nextcli").description("Scaffold outsource-ready Next.js projects").version("0.
|
|
2248
|
+
program.name("nextcli").description("Scaffold outsource-ready Next.js projects").version("0.6.0");
|
|
2123
2249
|
registerCreateCommand(program);
|
|
2124
2250
|
registerAddCommand(program);
|
|
2125
2251
|
registerMigrateCommand(program);
|
package/package.json
CHANGED
|
@@ -1,39 +1,19 @@
|
|
|
1
|
-
import type { ReactNode } from "react";
|
|
2
1
|
import type { CreateEmailOptions } from "resend";
|
|
2
|
+
import { Resend } from "resend";
|
|
3
3
|
|
|
4
|
-
import {
|
|
5
|
-
import { getResendFromAddress } from "@/lib/resend/config";
|
|
4
|
+
import type { SendEmailInput, SendEmailResult } from "@/lib/email/types";
|
|
6
5
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
name: string;
|
|
10
|
-
};
|
|
6
|
+
const apiKey = process.env.RESEND_API_KEY?.trim();
|
|
7
|
+
const resend = apiKey ? new Resend(apiKey) : null;
|
|
11
8
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}
|
|
9
|
+
function getResendFromAddress(): string | null {
|
|
10
|
+
const from = process.env.RESEND_FROM_EMAIL?.trim();
|
|
11
|
+
return from || null;
|
|
12
|
+
}
|
|
16
13
|
|
|
17
|
-
export
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
html?: string;
|
|
21
|
-
text?: string;
|
|
22
|
-
/** Pass as a function call: WelcomeEmail({ name }), not JSX. */
|
|
23
|
-
react?: ReactNode;
|
|
24
|
-
cc?: string | string[];
|
|
25
|
-
bcc?: string | string[];
|
|
26
|
-
replyTo?: string | string[];
|
|
27
|
-
scheduledAt?: string;
|
|
28
|
-
headers?: Record<string, string>;
|
|
29
|
-
tags?: Array<{ name: string; value: string }>;
|
|
30
|
-
attachments?: CreateEmailOptions["attachments"];
|
|
31
|
-
template?: {
|
|
32
|
-
id: string;
|
|
33
|
-
variables?: Record<string, string | number>;
|
|
34
|
-
};
|
|
35
|
-
idempotencyKey?: string;
|
|
36
|
-
};
|
|
14
|
+
export function isResendConfigured(): boolean {
|
|
15
|
+
return Boolean(process.env.RESEND_API_KEY?.trim() && getResendFromAddress());
|
|
16
|
+
}
|
|
37
17
|
|
|
38
18
|
function notConfigured(message: string): SendEmailResult {
|
|
39
19
|
return {
|
|
@@ -58,7 +38,7 @@ function countContentFields(input: SendEmailInput): number {
|
|
|
58
38
|
return count;
|
|
59
39
|
}
|
|
60
40
|
|
|
61
|
-
export async function
|
|
41
|
+
export async function sendWithResend(
|
|
62
42
|
input: SendEmailInput,
|
|
63
43
|
): Promise<SendEmailResult> {
|
|
64
44
|
if (!resend) {
|
|
@@ -101,7 +81,7 @@ export async function sendEmail(
|
|
|
101
81
|
scheduledAt: rest.scheduledAt,
|
|
102
82
|
headers: rest.headers,
|
|
103
83
|
tags: rest.tags,
|
|
104
|
-
attachments: rest.attachments,
|
|
84
|
+
attachments: rest.attachments as CreateEmailOptions["attachments"],
|
|
105
85
|
}
|
|
106
86
|
: {
|
|
107
87
|
from,
|
|
@@ -116,7 +96,7 @@ export async function sendEmail(
|
|
|
116
96
|
scheduledAt: rest.scheduledAt,
|
|
117
97
|
headers: rest.headers,
|
|
118
98
|
tags: rest.tags,
|
|
119
|
-
attachments: rest.attachments,
|
|
99
|
+
attachments: rest.attachments as CreateEmailOptions["attachments"],
|
|
120
100
|
};
|
|
121
101
|
|
|
122
102
|
const result = await resend.emails.send(
|
|
@@ -129,18 +109,3 @@ export async function sendEmail(
|
|
|
129
109
|
error: result.error,
|
|
130
110
|
};
|
|
131
111
|
}
|
|
132
|
-
|
|
133
|
-
export async function sendWelcomeEmail(input: {
|
|
134
|
-
to: string;
|
|
135
|
-
name: string;
|
|
136
|
-
idempotencyKey?: string;
|
|
137
|
-
}): Promise<SendEmailResult> {
|
|
138
|
-
const { WelcomeEmail } = await import("@/emails/welcome-email");
|
|
139
|
-
|
|
140
|
-
return sendEmail({
|
|
141
|
-
to: input.to,
|
|
142
|
-
subject: "Welcome",
|
|
143
|
-
react: WelcomeEmail({ name: input.name }),
|
|
144
|
-
idempotencyKey: input.idempotencyKey ?? `welcome-user/${input.to}`,
|
|
145
|
-
});
|
|
146
|
-
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import nodemailer from "nodemailer";
|
|
2
|
+
|
|
3
|
+
import type { SendEmailInput, SendEmailResult } from "@/lib/email/types";
|
|
4
|
+
|
|
5
|
+
type SmtpConfig = {
|
|
6
|
+
host: string;
|
|
7
|
+
port: number;
|
|
8
|
+
user: string;
|
|
9
|
+
password: string;
|
|
10
|
+
from: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function readSmtpConfig(): SmtpConfig | null {
|
|
14
|
+
const host = process.env.SMTP_HOST?.trim();
|
|
15
|
+
const portRaw = process.env.SMTP_PORT?.trim() || "587";
|
|
16
|
+
const user = process.env.SMTP_USER?.trim();
|
|
17
|
+
const password = process.env.SMTP_PASSWORD?.trim();
|
|
18
|
+
const from = process.env.SMTP_FROM?.trim();
|
|
19
|
+
const port = Number.parseInt(portRaw, 10);
|
|
20
|
+
|
|
21
|
+
if (!host || !user || !password || !from || Number.isNaN(port)) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return { host, port, user, password, from };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isSmtpConfigured(): boolean {
|
|
29
|
+
return readSmtpConfig() !== null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function notConfigured(message: string): SendEmailResult {
|
|
33
|
+
return {
|
|
34
|
+
data: null,
|
|
35
|
+
error: { message, name: "NOT_CONFIGURED" },
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function validationError(message: string): SendEmailResult {
|
|
40
|
+
return {
|
|
41
|
+
data: null,
|
|
42
|
+
error: { message, name: "VALIDATION_ERROR" },
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function countContentFields(input: SendEmailInput): number {
|
|
47
|
+
let count = 0;
|
|
48
|
+
if (input.html) count += 1;
|
|
49
|
+
if (input.text) count += 1;
|
|
50
|
+
if (input.react) count += 1;
|
|
51
|
+
if (input.template) count += 1;
|
|
52
|
+
return count;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function sendWithSmtp(
|
|
56
|
+
input: SendEmailInput,
|
|
57
|
+
): Promise<SendEmailResult> {
|
|
58
|
+
const config = readSmtpConfig();
|
|
59
|
+
if (!config) {
|
|
60
|
+
return notConfigured(
|
|
61
|
+
"SMTP is not configured. Set SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, and SMTP_FROM.",
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const contentCount = countContentFields(input);
|
|
66
|
+
if (contentCount === 0) {
|
|
67
|
+
return validationError("Provide one of html or text for SMTP email body.");
|
|
68
|
+
}
|
|
69
|
+
if (contentCount > 1) {
|
|
70
|
+
return validationError(
|
|
71
|
+
"Provide only one body source: html or text for SMTP transport.",
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
if (input.react || input.template) {
|
|
75
|
+
return validationError(
|
|
76
|
+
"SMTP adapter supports html/text only. Convert React/template content before sending.",
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const transporter = nodemailer.createTransport({
|
|
81
|
+
host: config.host,
|
|
82
|
+
port: config.port,
|
|
83
|
+
secure: config.port === 465,
|
|
84
|
+
auth: {
|
|
85
|
+
user: config.user,
|
|
86
|
+
pass: config.password,
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const info = await transporter.sendMail({
|
|
92
|
+
from: config.from,
|
|
93
|
+
to: input.to,
|
|
94
|
+
subject: input.subject,
|
|
95
|
+
html: input.html,
|
|
96
|
+
text: input.text,
|
|
97
|
+
cc: input.cc,
|
|
98
|
+
bcc: input.bcc,
|
|
99
|
+
replyTo: input.replyTo,
|
|
100
|
+
headers: input.headers,
|
|
101
|
+
attachments: input.attachments as never,
|
|
102
|
+
});
|
|
103
|
+
return {
|
|
104
|
+
data: { id: info.messageId || `smtp-${Date.now()}` },
|
|
105
|
+
error: null,
|
|
106
|
+
};
|
|
107
|
+
} catch (error) {
|
|
108
|
+
return {
|
|
109
|
+
data: null,
|
|
110
|
+
error: {
|
|
111
|
+
name: "SMTP_ERROR",
|
|
112
|
+
message: error instanceof Error ? error.message : "SMTP send failed.",
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
EmailProvider,
|
|
3
|
+
SendEmailInput,
|
|
4
|
+
SendEmailResult,
|
|
5
|
+
} from "@/lib/email/types";
|
|
6
|
+
import { sendWithResend } from "@/lib/email/providers/resend";
|
|
7
|
+
import { sendWithSmtp } from "@/lib/email/providers/smtp";
|
|
8
|
+
|
|
9
|
+
function notConfigured(message: string): SendEmailResult {
|
|
10
|
+
return {
|
|
11
|
+
data: null,
|
|
12
|
+
error: { message, name: "NOT_CONFIGURED" },
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function resolveEmailProvider(): EmailProvider | null {
|
|
17
|
+
const provider = process.env.EMAIL_PROVIDER?.trim().toLowerCase();
|
|
18
|
+
if (provider === "resend" || provider === "smtp") {
|
|
19
|
+
return provider;
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function sendEmail(
|
|
25
|
+
input: SendEmailInput,
|
|
26
|
+
): Promise<SendEmailResult> {
|
|
27
|
+
const provider = resolveEmailProvider();
|
|
28
|
+
if (!provider) {
|
|
29
|
+
return notConfigured(
|
|
30
|
+
"Email provider not configured. Set EMAIL_PROVIDER to 'resend' or 'smtp'.",
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (provider === "smtp") {
|
|
35
|
+
return sendWithSmtp(input);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return sendWithResend(input);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function sendWelcomeEmail(input: {
|
|
42
|
+
to: string;
|
|
43
|
+
name: string;
|
|
44
|
+
idempotencyKey?: string;
|
|
45
|
+
}): Promise<SendEmailResult> {
|
|
46
|
+
const provider = resolveEmailProvider();
|
|
47
|
+
if (!provider) {
|
|
48
|
+
return notConfigured(
|
|
49
|
+
"Email provider not configured. Set EMAIL_PROVIDER to 'resend' or 'smtp'.",
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (provider === "smtp") {
|
|
54
|
+
return sendWithSmtp({
|
|
55
|
+
to: input.to,
|
|
56
|
+
subject: "Welcome",
|
|
57
|
+
text: `Welcome, ${input.name}. Thanks for joining.`,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const { WelcomeEmail } = await import("@/emails/welcome-email");
|
|
62
|
+
return sendWithResend({
|
|
63
|
+
to: input.to,
|
|
64
|
+
subject: "Welcome",
|
|
65
|
+
react: WelcomeEmail({ name: input.name }),
|
|
66
|
+
idempotencyKey: input.idempotencyKey ?? `welcome-user/${input.to}`,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export type EmailProvider = "resend" | "smtp";
|
|
4
|
+
|
|
5
|
+
export type EmailError = {
|
|
6
|
+
message: string;
|
|
7
|
+
name: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type SendEmailResult = {
|
|
11
|
+
data: { id: string } | null;
|
|
12
|
+
error: EmailError | null;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type SendEmailInput = {
|
|
16
|
+
to: string | string[];
|
|
17
|
+
subject: string;
|
|
18
|
+
html?: string;
|
|
19
|
+
text?: string;
|
|
20
|
+
/** Pass as a function call: WelcomeEmail({ name }), not JSX. */
|
|
21
|
+
react?: ReactNode;
|
|
22
|
+
cc?: string | string[];
|
|
23
|
+
bcc?: string | string[];
|
|
24
|
+
replyTo?: string | string[];
|
|
25
|
+
scheduledAt?: string;
|
|
26
|
+
headers?: Record<string, string>;
|
|
27
|
+
tags?: Array<{ name: string; value: string }>;
|
|
28
|
+
attachments?: unknown;
|
|
29
|
+
template?: {
|
|
30
|
+
id: string;
|
|
31
|
+
variables?: Record<string, string | number>;
|
|
32
|
+
};
|
|
33
|
+
idempotencyKey?: string;
|
|
34
|
+
};
|
package/templates/next-base/.env
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
# --- Database (Supabase) ---
|
|
2
|
-
DATABASE_URL="postgresql://postgres:your-supabase-db-password@
|
|
2
|
+
DATABASE_URL="postgresql://postgres.your-project-ref:your-supabase-db-password@aws-0-us-east-1.pooler.supabase.com:6543/postgres?pgbouncer=true"
|
|
3
|
+
DIRECT_URL="postgresql://postgres.your-project-ref:your-supabase-db-password@aws-0-us-east-1.pooler.supabase.com:5432/postgres"
|
|
3
4
|
NEXT_PUBLIC_SUPABASE_URL=""
|
|
5
|
+
# Public/browser-safe anon key from Supabase Project Settings -> API -> anon public
|
|
4
6
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=""
|
|
7
|
+
# Storage bucket name used by the app (default scaffold bucket name: public)
|
|
5
8
|
NEXT_PUBLIC_SUPABASE_STORAGE_BUCKET="public"
|
|
6
9
|
|
|
7
10
|
# --- Auth ---
|
|
@@ -10,4 +13,4 @@ BETTER_AUTH_URL="http://localhost:3000"
|
|
|
10
13
|
|
|
11
14
|
# --- App ---
|
|
12
15
|
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
|
13
|
-
NEXT_PUBLIC_DEFAULT_LOCALE="
|
|
16
|
+
NEXT_PUBLIC_DEFAULT_LOCALE="vi"
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
# --- Database (Supabase) ---
|
|
2
|
-
DATABASE_URL="postgresql://postgres:your-supabase-db-password@
|
|
2
|
+
DATABASE_URL="postgresql://postgres.your-project-ref:your-supabase-db-password@aws-0-us-east-1.pooler.supabase.com:6543/postgres?pgbouncer=true"
|
|
3
|
+
DIRECT_URL="postgresql://postgres.your-project-ref:your-supabase-db-password@aws-0-us-east-1.pooler.supabase.com:5432/postgres"
|
|
3
4
|
NEXT_PUBLIC_SUPABASE_URL=""
|
|
5
|
+
# Public/browser-safe anon key from Supabase Project Settings -> API -> anon public
|
|
4
6
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=""
|
|
7
|
+
# Storage bucket name used by the app (default scaffold bucket name: public)
|
|
5
8
|
NEXT_PUBLIC_SUPABASE_STORAGE_BUCKET="public"
|
|
6
9
|
|
|
7
10
|
# --- Auth ---
|
|
@@ -10,4 +13,4 @@ BETTER_AUTH_URL="http://localhost:3000"
|
|
|
10
13
|
|
|
11
14
|
# --- App ---
|
|
12
15
|
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
|
13
|
-
NEXT_PUBLIC_DEFAULT_LOCALE="
|
|
16
|
+
NEXT_PUBLIC_DEFAULT_LOCALE="vi"
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
# --- Database (Supabase) ---
|
|
2
|
-
DATABASE_URL="postgresql://postgres:your-supabase-db-password@
|
|
2
|
+
DATABASE_URL="postgresql://postgres.your-project-ref:your-supabase-db-password@aws-0-us-east-1.pooler.supabase.com:6543/postgres?pgbouncer=true"
|
|
3
|
+
DIRECT_URL="postgresql://postgres.your-project-ref:your-supabase-db-password@aws-0-us-east-1.pooler.supabase.com:5432/postgres"
|
|
3
4
|
NEXT_PUBLIC_SUPABASE_URL=""
|
|
5
|
+
# Public/browser-safe anon key from Supabase Project Settings -> API -> anon public
|
|
4
6
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=""
|
|
7
|
+
# Storage bucket name used by the app (default scaffold bucket name: public)
|
|
5
8
|
NEXT_PUBLIC_SUPABASE_STORAGE_BUCKET="public"
|
|
6
9
|
|
|
7
10
|
# --- Auth ---
|
|
@@ -10,4 +13,4 @@ BETTER_AUTH_URL="http://localhost:3000"
|
|
|
10
13
|
|
|
11
14
|
# --- App ---
|
|
12
15
|
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
|
13
|
-
NEXT_PUBLIC_DEFAULT_LOCALE="
|
|
16
|
+
NEXT_PUBLIC_DEFAULT_LOCALE="vi"
|
|
@@ -34,15 +34,15 @@ Generated by NexTCLI. Folders marked **(module)** or **(feature)** appear only w
|
|
|
34
34
|
|
|
35
35
|
## `src/lib/` — Shared runtime
|
|
36
36
|
|
|
37
|
-
| Path | Purpose
|
|
38
|
-
| ---------------------------- |
|
|
39
|
-
| `auth.ts` / `auth-client.ts` | Better Auth (username + JWT)
|
|
40
|
-
| `bootstrap.ts` | Seeds `admin` role/user on startup
|
|
41
|
-
| `rbac.ts` | Role hierarchy guards
|
|
42
|
-
| `prisma.ts` | DB client
|
|
43
|
-
| `api-response.ts` | `/api/v1/*` envelope helpers
|
|
44
|
-
| `supabase/` | Supabase client + Storage helpers
|
|
45
|
-
| `
|
|
37
|
+
| Path | Purpose |
|
|
38
|
+
| ---------------------------- | --------------------------------------------------------------- |
|
|
39
|
+
| `auth.ts` / `auth-client.ts` | Better Auth (username + JWT) |
|
|
40
|
+
| `bootstrap.ts` | Seeds `admin` role/user on startup |
|
|
41
|
+
| `rbac.ts` | Role hierarchy guards |
|
|
42
|
+
| `prisma.ts` | DB client |
|
|
43
|
+
| `api-response.ts` | `/api/v1/*` envelope helpers |
|
|
44
|
+
| `supabase/` | Supabase client + Storage helpers |
|
|
45
|
+
| `email/` | **(module: `email`)** provider-agnostic email helper + adapters |
|
|
46
46
|
|
|
47
47
|
## `src/features/` vs `src/example/`
|
|
48
48
|
|
|
@@ -67,7 +67,7 @@ Locale config with `nextcli:locales` / `nextcli:namespaces` markers (patched by
|
|
|
67
67
|
|
|
68
68
|
## `src/emails/`
|
|
69
69
|
|
|
70
|
-
**(module: `
|
|
70
|
+
**(module: `email`)** React Email templates.
|
|
71
71
|
|
|
72
72
|
## Optional modules (via `nextcli add module`)
|
|
73
73
|
|
|
@@ -76,7 +76,7 @@ Locale config with `nextcli:locales` / `nextcli:namespaces` markers (patched by
|
|
|
76
76
|
| `chat` | Chat routes, hooks, Prisma chat models (+ auto `supabase-realtime`) |
|
|
77
77
|
| `supabase-realtime` | Realtime channel helpers |
|
|
78
78
|
| `seo` | robots/sitemap/JSON-LD helpers |
|
|
79
|
-
| `
|
|
79
|
+
| `email` | `src/lib/email/*`, `src/emails/*` (provider: SMTP or Resend) |
|
|
80
80
|
|
|
81
81
|
Module **env variables** and where to find them: see `SETUP.md` → **Optional module environment** (full catalog; `Enabled modules` line updates when you create/add modules).
|
|
82
82
|
|
|
@@ -23,15 +23,16 @@ Quick reference for env vars and branding after `nextcli create`.
|
|
|
23
23
|
|
|
24
24
|
Set in `.env` / `.env.development` (secrets are generated at create time).
|
|
25
25
|
|
|
26
|
-
| Variable | Purpose
|
|
27
|
-
| ------------------------------------- |
|
|
28
|
-
| `DATABASE_URL` | Supabase Postgres
|
|
29
|
-
| `
|
|
30
|
-
| `
|
|
31
|
-
| `
|
|
32
|
-
| `
|
|
33
|
-
| `
|
|
34
|
-
| `
|
|
26
|
+
| Variable | Purpose | Where to get |
|
|
27
|
+
| ------------------------------------- | ----------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
|
|
28
|
+
| `DATABASE_URL` | Supabase pooled Postgres URL (Prisma `url`) | Supabase Dashboard → Connect → ORMs → Prisma → pooled URL (`:6543`, `?pgbouncer=true`) |
|
|
29
|
+
| `DIRECT_URL` | Supabase direct Postgres URL (Prisma `directUrl`) | Supabase Dashboard → Connect → ORMs → Prisma → direct/session URL (`:5432`) |
|
|
30
|
+
| `NEXT_PUBLIC_SUPABASE_URL` | Supabase client URL | Supabase Dashboard → Project Settings → API → Project URL |
|
|
31
|
+
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Browser-safe anon API key (public key used by client SDK) | Same page → Project API keys → `anon` `public` |
|
|
32
|
+
| `NEXT_PUBLIC_SUPABASE_STORAGE_BUCKET` | Storage bucket name used by app uploads/reads (`public` is the default bucket name) | Storage → create/select bucket → use that bucket name |
|
|
33
|
+
| `BETTER_AUTH_SECRET` | Auth signing secret | Auto-generated on create; rotate in production |
|
|
34
|
+
| `BETTER_AUTH_URL` | Server auth base URL | Your app URL (e.g. `http://localhost:3000`) |
|
|
35
|
+
| `NEXT_PUBLIC_APP_URL` | Client-visible app URL | Same as public site URL |
|
|
35
36
|
|
|
36
37
|
### Default admin account
|
|
37
38
|
|
|
@@ -65,13 +66,29 @@ Uses the base Supabase URL/anon key. Enable Realtime on tables in Supabase Dashb
|
|
|
65
66
|
|
|
66
67
|
No extra env keys. Edit `src/app/robots.ts`, `sitemap.ts`, and JSON-LD helpers after add.
|
|
67
68
|
|
|
68
|
-
### Module:
|
|
69
|
+
### Module: Email module (`email`)
|
|
70
|
+
|
|
71
|
+
| Variable | Where to get |
|
|
72
|
+
| ---------------- | ------------------ |
|
|
73
|
+
| `EMAIL_PROVIDER` | `resend` or `smtp` |
|
|
74
|
+
|
|
75
|
+
When `EMAIL_PROVIDER=resend`:
|
|
69
76
|
|
|
70
77
|
| Variable | Where to get |
|
|
71
78
|
| ------------------- | ------------------------------------------------------------------ |
|
|
72
79
|
| `RESEND_API_KEY` | resend.com → API Keys → Create |
|
|
73
80
|
| `RESEND_FROM_EMAIL` | resend.com → Domains → verify domain → use `Name <you@domain.com>` |
|
|
74
81
|
|
|
82
|
+
When `EMAIL_PROVIDER=smtp`:
|
|
83
|
+
|
|
84
|
+
| Variable | Where to get |
|
|
85
|
+
| --------------- | -------------------------------------------- |
|
|
86
|
+
| `SMTP_HOST` | SMTP provider host (Mailgun, SES, etc.) |
|
|
87
|
+
| `SMTP_PORT` | SMTP provider port (commonly `587` or `465`) |
|
|
88
|
+
| `SMTP_USER` | SMTP username |
|
|
89
|
+
| `SMTP_PASSWORD` | SMTP password or app-specific token |
|
|
90
|
+
| `SMTP_FROM` | Sender email (or `Name <mail@domain.com>`) |
|
|
91
|
+
|
|
75
92
|
<!-- nextcli:module-env:end -->
|
|
76
93
|
|
|
77
94
|
## After adding modules
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
export function getResendFromAddress(): string | null {
|
|
2
|
-
const from = process.env.RESEND_FROM_EMAIL?.trim();
|
|
3
|
-
return from || null;
|
|
4
|
-
}
|
|
5
|
-
|
|
6
|
-
export function isResendConfigured(): boolean {
|
|
7
|
-
return Boolean(process.env.RESEND_API_KEY?.trim() && getResendFromAddress());
|
|
8
|
-
}
|
|
File without changes
|