@thinhnguyencth1204/nextcli 0.4.2 → 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 +12 -8
- package/dist/cli.js +314 -216
- package/package.json +2 -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 +12 -7
- package/templates/next-base/.env.development +12 -7
- package/templates/next-base/.env.example +12 -7
- package/templates/next-base/PROJECT_STRUCTURE.md +11 -12
- package/templates/next-base/SETUP.md +29 -17
- package/templates/next-base/package.json +1 -0
- package/templates/next-base/prisma/schema.prisma +3 -1
- package/templates/{features/supabase → next-base}/src/lib/supabase/client.ts +1 -4
- package/templates/{features/supabase → next-base}/src/lib/supabase/storage.ts +1 -4
- 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/templates/{features/supabase → next-base}/src/lib/supabase/storage-config.ts +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thinhnguyencth1204/nextcli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "CLI scaffolder for outsourced Next.js projects",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"dev": "tsup --watch",
|
|
17
17
|
"typecheck": "tsc --noEmit",
|
|
18
18
|
"smoke": "node dist/cli.js --help",
|
|
19
|
+
"smoke:full": "bun run scripts/pre-publish-smoke.ts",
|
|
19
20
|
"prepublishOnly": "npm run build"
|
|
20
21
|
},
|
|
21
22
|
"keywords": [
|
|
@@ -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,11 +1,16 @@
|
|
|
1
|
-
|
|
1
|
+
# --- Database (Supabase) ---
|
|
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"
|
|
4
|
+
NEXT_PUBLIC_SUPABASE_URL=""
|
|
5
|
+
# Public/browser-safe anon key from Supabase Project Settings -> API -> anon public
|
|
6
|
+
NEXT_PUBLIC_SUPABASE_ANON_KEY=""
|
|
7
|
+
# Storage bucket name used by the app (default scaffold bucket name: public)
|
|
8
|
+
NEXT_PUBLIC_SUPABASE_STORAGE_BUCKET="public"
|
|
9
|
+
|
|
10
|
+
# --- Auth ---
|
|
2
11
|
BETTER_AUTH_SECRET="__BETTER_AUTH_SECRET__"
|
|
3
12
|
BETTER_AUTH_URL="http://localhost:3000"
|
|
4
13
|
|
|
14
|
+
# --- App ---
|
|
5
15
|
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
|
6
|
-
|
|
7
|
-
NEXT_PUBLIC_DEFAULT_LOCALE="en"
|
|
8
|
-
GOOGLE_CLIENT_ID=""
|
|
9
|
-
GOOGLE_CLIENT_SECRET=""
|
|
10
|
-
FACEBOOK_CLIENT_ID=""
|
|
11
|
-
FACEBOOK_CLIENT_SECRET=""
|
|
16
|
+
NEXT_PUBLIC_DEFAULT_LOCALE="vi"
|
|
@@ -1,11 +1,16 @@
|
|
|
1
|
-
|
|
1
|
+
# --- Database (Supabase) ---
|
|
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"
|
|
4
|
+
NEXT_PUBLIC_SUPABASE_URL=""
|
|
5
|
+
# Public/browser-safe anon key from Supabase Project Settings -> API -> anon public
|
|
6
|
+
NEXT_PUBLIC_SUPABASE_ANON_KEY=""
|
|
7
|
+
# Storage bucket name used by the app (default scaffold bucket name: public)
|
|
8
|
+
NEXT_PUBLIC_SUPABASE_STORAGE_BUCKET="public"
|
|
9
|
+
|
|
10
|
+
# --- Auth ---
|
|
2
11
|
BETTER_AUTH_SECRET="__BETTER_AUTH_SECRET__"
|
|
3
12
|
BETTER_AUTH_URL="http://localhost:3000"
|
|
4
13
|
|
|
14
|
+
# --- App ---
|
|
5
15
|
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
|
6
|
-
|
|
7
|
-
NEXT_PUBLIC_DEFAULT_LOCALE="en"
|
|
8
|
-
GOOGLE_CLIENT_ID=""
|
|
9
|
-
GOOGLE_CLIENT_SECRET=""
|
|
10
|
-
FACEBOOK_CLIENT_ID=""
|
|
11
|
-
FACEBOOK_CLIENT_SECRET=""
|
|
16
|
+
NEXT_PUBLIC_DEFAULT_LOCALE="vi"
|
|
@@ -1,11 +1,16 @@
|
|
|
1
|
-
|
|
1
|
+
# --- Database (Supabase) ---
|
|
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"
|
|
4
|
+
NEXT_PUBLIC_SUPABASE_URL=""
|
|
5
|
+
# Public/browser-safe anon key from Supabase Project Settings -> API -> anon public
|
|
6
|
+
NEXT_PUBLIC_SUPABASE_ANON_KEY=""
|
|
7
|
+
# Storage bucket name used by the app (default scaffold bucket name: public)
|
|
8
|
+
NEXT_PUBLIC_SUPABASE_STORAGE_BUCKET="public"
|
|
9
|
+
|
|
10
|
+
# --- Auth ---
|
|
2
11
|
BETTER_AUTH_SECRET="your-32-plus-char-random-secret"
|
|
3
12
|
BETTER_AUTH_URL="http://localhost:3000"
|
|
4
13
|
|
|
14
|
+
# --- App ---
|
|
5
15
|
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
|
6
|
-
|
|
7
|
-
NEXT_PUBLIC_DEFAULT_LOCALE="en"
|
|
8
|
-
GOOGLE_CLIENT_ID=""
|
|
9
|
-
GOOGLE_CLIENT_SECRET=""
|
|
10
|
-
FACEBOOK_CLIENT_ID=""
|
|
11
|
-
FACEBOOK_CLIENT_SECRET=""
|
|
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/` |
|
|
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,17 +67,16 @@ 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
|
|
|
74
74
|
| Module | Adds |
|
|
75
75
|
| ------------------- | ------------------------------------------------------------------- |
|
|
76
76
|
| `chat` | Chat routes, hooks, Prisma chat models (+ auto `supabase-realtime`) |
|
|
77
|
-
| `supabase` | `src/lib/supabase/*`, Storage upload helpers |
|
|
78
77
|
| `supabase-realtime` | Realtime channel helpers |
|
|
79
78
|
| `seo` | robots/sitemap/JSON-LD helpers |
|
|
80
|
-
| `
|
|
79
|
+
| `email` | `src/lib/email/*`, `src/emails/*` (provider: SMTP or Resend) |
|
|
81
80
|
|
|
82
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).
|
|
83
82
|
|
|
@@ -4,7 +4,7 @@ Quick reference for env vars and branding after `nextcli create`.
|
|
|
4
4
|
|
|
5
5
|
## First run (required)
|
|
6
6
|
|
|
7
|
-
1.
|
|
7
|
+
1. Create a Supabase project and set `DATABASE_URL` in `.env`.
|
|
8
8
|
2. `bun run db:migrate` — applies `prisma/migrations` (includes `Role`, `User.username`, `requirePasswordChange`).
|
|
9
9
|
3. `bun run dev` — bootstrap seeds `admin` / `admin` on first start.
|
|
10
10
|
|
|
@@ -23,12 +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
|
|
27
|
-
|
|
|
28
|
-
| `DATABASE_URL`
|
|
29
|
-
| `
|
|
30
|
-
| `
|
|
31
|
-
| `
|
|
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 |
|
|
32
36
|
|
|
33
37
|
### Default admin account
|
|
34
38
|
|
|
@@ -54,29 +58,37 @@ Reference for all optional modules. Matching keys are merged into `.env` when se
|
|
|
54
58
|
|
|
55
59
|
Requires `supabase-realtime` (auto-added). Run `db:migrate` after add — chat Prisma models are appended.
|
|
56
60
|
|
|
57
|
-
### Module: Supabase (`supabase`)
|
|
58
|
-
|
|
59
|
-
| Variable | Where to get |
|
|
60
|
-
| ------------------------------------- | ---------------------------------------------------------------------- |
|
|
61
|
-
| `NEXT_PUBLIC_SUPABASE_URL` | Supabase Dashboard → Project Settings → API → Project URL |
|
|
62
|
-
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Same page → Project API keys → `anon` `public` |
|
|
63
|
-
| `NEXT_PUBLIC_SUPABASE_STORAGE_BUCKET` | Storage → create bucket → use bucket name (default scaffold: `public`) |
|
|
64
|
-
|
|
65
61
|
### Module: Supabase Realtime (`supabase-realtime`)
|
|
66
62
|
|
|
67
|
-
Uses
|
|
63
|
+
Uses the base Supabase URL/anon key. Enable Realtime on tables in Supabase Dashboard → Database → Replication.
|
|
68
64
|
|
|
69
65
|
### Module: SEO pack (`seo`)
|
|
70
66
|
|
|
71
67
|
No extra env keys. Edit `src/app/robots.ts`, `sitemap.ts`, and JSON-LD helpers after add.
|
|
72
68
|
|
|
73
|
-
### 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`:
|
|
74
76
|
|
|
75
77
|
| Variable | Where to get |
|
|
76
78
|
| ------------------- | ------------------------------------------------------------------ |
|
|
77
79
|
| `RESEND_API_KEY` | resend.com → API Keys → Create |
|
|
78
80
|
| `RESEND_FROM_EMAIL` | resend.com → Domains → verify domain → use `Name <you@domain.com>` |
|
|
79
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
|
+
|
|
80
92
|
<!-- nextcli:module-env:end -->
|
|
81
93
|
|
|
82
94
|
## After adding modules
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"@radix-ui/react-slot": "^1.2.3",
|
|
28
28
|
"@radix-ui/react-tabs": "^1.1.13",
|
|
29
29
|
"@radix-ui/react-tooltip": "^1.2.8",
|
|
30
|
+
"@supabase/supabase-js": "^2.44.2",
|
|
30
31
|
"@tanstack/react-form": "^1.0.3",
|
|
31
32
|
"@tanstack/react-query": "^5.56.2",
|
|
32
33
|
"@tanstack/react-query-devtools": "^5.56.2",
|
|
@@ -3,7 +3,4 @@ import { createClient } from "@supabase/supabase-js";
|
|
|
3
3
|
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
4
4
|
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
|
5
5
|
|
|
6
|
-
export const supabase =
|
|
7
|
-
url && anonKey
|
|
8
|
-
? createClient(url, anonKey)
|
|
9
|
-
: null;
|
|
6
|
+
export const supabase = url && anonKey ? createClient(url, anonKey) : null;
|
|
@@ -86,10 +86,7 @@ function assertCategoryRules(
|
|
|
86
86
|
|
|
87
87
|
const mime = contentType ?? (file instanceof File ? file.type : "");
|
|
88
88
|
|
|
89
|
-
if (
|
|
90
|
-
!mime ||
|
|
91
|
-
!allowedMimePrefixes.some((prefix) => mime.startsWith(prefix))
|
|
92
|
-
) {
|
|
89
|
+
if (!mime || !allowedMimePrefixes.some((prefix) => mime.startsWith(prefix))) {
|
|
93
90
|
throw new StorageHelperError(
|
|
94
91
|
`File type "${mime || "unknown"}" is not allowed for category "${category}".`,
|
|
95
92
|
"VALIDATION",
|
|
@@ -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
|
|
File without changes
|