@alfredmouelle/create-stack 0.1.1 → 0.1.2
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 +8 -13
- package/_stack/apps/next-base/.turbo/turbo-typecheck.log +1 -0
- package/_stack/apps/next-base/.vscode/settings.json +35 -0
- package/_stack/apps/next-base/.zed/settings.json +45 -0
- package/_stack/apps/next-base/src/components/ui/spinner.tsx +1 -1
- package/_stack/apps/next-base/src/emails/components/components.tsx +2 -2
- package/_stack/apps/next-base/src/emails/components/context.tsx +1 -1
- package/_stack/apps/next-base/src/emails/components/theme.ts +6 -7
- package/_stack/apps/next-base/src/env.ts +2 -3
- package/_stack/apps/next-base/src/lib/date.ts +1 -1
- package/_stack/apps/next-base/src/server/auth/guards.ts +1 -1
- package/_stack/apps/next-base/src/server/better-auth/config.ts +1 -1
- package/_stack/apps/next-base/src/server/better-auth/server.ts +2 -2
- package/_stack/apps/next-base/src/server/db/schemas/index.ts +1 -1
- package/_stack/apps/next-base/src/server/db/seed.ts +2 -2
- package/_stack/apps/next-base/src/server/email/adapters/resend/index.ts +3 -3
- package/_stack/apps/next-base/src/server/email/core/address.ts +3 -3
- package/_stack/apps/next-base/src/server/email/core/port.ts +13 -20
- package/_stack/apps/next-base/src/server/email/core/render.ts +2 -2
- package/_stack/apps/next-base/src/server/email/factory.ts +7 -9
- package/_stack/apps/next-base/src/server/email/index.ts +1 -1
- package/_stack/apps/next-base/src/trpc/react.tsx +2 -2
- package/_stack/apps/next-base/src/trpc/server.ts +1 -1
- package/_stack/apps/tanstack-base/.turbo/turbo-typecheck.log +1 -0
- package/_stack/apps/tanstack-base/.vscode/settings.json +35 -0
- package/_stack/apps/tanstack-base/.zed/settings.json +45 -0
- package/_stack/apps/tanstack-base/src/components/form/text-field.tsx +1 -1
- package/_stack/apps/tanstack-base/src/components/ui/spinner.tsx +1 -1
- package/_stack/apps/tanstack-base/src/emails/components/components.tsx +2 -2
- package/_stack/apps/tanstack-base/src/emails/components/context.tsx +1 -1
- package/_stack/apps/tanstack-base/src/emails/components/theme.ts +6 -7
- package/_stack/apps/tanstack-base/src/env.ts +2 -6
- package/_stack/apps/tanstack-base/src/lib/date.ts +1 -1
- package/_stack/apps/tanstack-base/src/routes/__root.tsx +1 -1
- package/_stack/apps/tanstack-base/src/routes/_authed.tsx +1 -4
- package/_stack/apps/tanstack-base/src/routes/api/auth/$.ts +1 -1
- package/_stack/apps/tanstack-base/src/routes/api.trpc.$.tsx +1 -2
- package/_stack/apps/tanstack-base/src/server/better-auth/config.ts +1 -1
- package/_stack/apps/tanstack-base/src/server/better-auth/session.ts +6 -7
- package/_stack/apps/tanstack-base/src/server/db/schemas/index.ts +1 -1
- package/_stack/apps/tanstack-base/src/server/db/seed.ts +2 -2
- package/_stack/apps/tanstack-base/src/server/email/adapters/resend/index.ts +3 -3
- package/_stack/apps/tanstack-base/src/server/email/core/address.ts +3 -3
- package/_stack/apps/tanstack-base/src/server/email/core/port.ts +12 -22
- package/_stack/apps/tanstack-base/src/server/email/core/render.ts +1 -1
- package/_stack/apps/tanstack-base/src/server/email/factory.ts +7 -8
- package/_stack/apps/tanstack-base/src/server/email/index.ts +1 -1
- package/_stack/packages/mailer/src/adapters/brevo/index.ts +3 -3
- package/_stack/packages/mailer/src/adapters/resend/index.ts +3 -3
- package/_stack/packages/mailer/src/adapters/ses/config.ts +3 -3
- package/_stack/packages/mailer/src/adapters/ses/index.ts +4 -5
- package/index.mjs +30 -47
- package/lib/build.mjs +6 -14
- package/lib/env.mjs +5 -6
- package/lib/foundations.mjs +35 -0
- package/lib/identity.mjs +4 -5
- package/lib/mailer.mjs +9 -13
- package/lib/paths.mjs +15 -0
- package/lib/scaffold.mjs +11 -11
- package/lib/strip.mjs +9 -24
- package/lib/util.mjs +8 -9
- package/package.json +1 -1
- package/_stack/packages/analytics/capability.json +0 -26
- package/_stack/packages/cache/capability.json +0 -21
- package/_stack/packages/error-tracking/capability.json +0 -21
- package/_stack/packages/jobs/capability.json +0 -26
- package/_stack/packages/logger/capability.json +0 -21
- package/_stack/packages/mailer/capability.json +0 -28
- package/_stack/packages/storage/capability.json +0 -32
- package/_stack/patterns/README.md +0 -58
- package/_stack/patterns/_baseline/env.ts +0 -31
- package/_stack/patterns/_baseline/tsconfig.json +0 -27
- package/_stack/patterns/better-auth/pattern.json +0 -73
- package/_stack/patterns/better-auth-next/pattern.json +0 -76
- package/_stack/patterns/data-table/pattern.json +0 -43
- package/_stack/patterns/drizzle/pattern.json +0 -61
- package/_stack/patterns/trpc/pattern.json +0 -61
- package/_stack/patterns/trpc-next/pattern.json +0 -64
- package/lib/manifests.mjs +0 -61
- /package/{_stack/patterns/_baseline → templates}/README-author.md +0 -0
- /package/{_stack/patterns/_baseline → templates}/biome.jsonc +0 -0
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* or wrap a tree in `<EmailThemeProvider>`.
|
|
2
|
+
* Swappable email design tokens. Override a subset via {@link createEmailTheme}
|
|
3
|
+
* + `<EmailLayout theme={...}>`, or wrap a tree in `<EmailThemeProvider>`.
|
|
5
4
|
*/
|
|
6
5
|
export interface EmailTheme {
|
|
7
6
|
brand: {
|
|
8
|
-
/**
|
|
7
|
+
/** Header band + footer. */
|
|
9
8
|
name: string
|
|
10
|
-
/** Footer line (
|
|
9
|
+
/** Footer line (year prefixed automatically). */
|
|
11
10
|
footer: string
|
|
12
11
|
}
|
|
13
12
|
fontFamily: string
|
|
@@ -21,7 +20,7 @@ export interface EmailTheme {
|
|
|
21
20
|
border: string
|
|
22
21
|
borderSubtle: string
|
|
23
22
|
destructive: string
|
|
24
|
-
/** Text
|
|
23
|
+
/** Text over accent/destructive buttons. */
|
|
25
24
|
onAccent: string
|
|
26
25
|
}
|
|
27
26
|
}
|
|
@@ -52,7 +51,7 @@ export type EmailThemeOverride = {
|
|
|
52
51
|
colors?: Partial<EmailTheme['colors']>
|
|
53
52
|
}
|
|
54
53
|
|
|
55
|
-
/** Deep-merge
|
|
54
|
+
/** Deep-merge override onto base (defaults to {@link defaultTheme}). */
|
|
56
55
|
export function createEmailTheme(
|
|
57
56
|
override: EmailThemeOverride = {},
|
|
58
57
|
base: EmailTheme = defaultTheme,
|
|
@@ -1,15 +1,11 @@
|
|
|
1
1
|
import { createEnv } from '@t3-oss/env-core'
|
|
2
2
|
import * as v from 'valibot'
|
|
3
3
|
|
|
4
|
-
/**
|
|
4
|
+
/** Required in production, optional in dev/test. */
|
|
5
5
|
export const requiredInProduction = <T extends v.GenericSchema>(schema: T) =>
|
|
6
6
|
process.env.NODE_ENV === 'production' ? schema : v.optional(schema)
|
|
7
7
|
|
|
8
|
-
/**
|
|
9
|
-
* Typed environment. Start minimal — patterns (drizzle, better-auth, …) and
|
|
10
|
-
* capabilities (add-capability) extend the `server` block and `runtimeEnv` with
|
|
11
|
-
* the keys they need.
|
|
12
|
-
*/
|
|
8
|
+
/** Typed env; foundations/capabilities extend `server` + `runtimeEnv` as needed. */
|
|
13
9
|
export const env = createEnv({
|
|
14
10
|
shared: {
|
|
15
11
|
NODE_ENV: v.optional(v.picklist(['development', 'test', 'production']), 'development'),
|
|
@@ -8,7 +8,7 @@ import TanStackQueryDevtools from '~/trpc/devtools'
|
|
|
8
8
|
|
|
9
9
|
import appCss from '../styles.css?url'
|
|
10
10
|
|
|
11
|
-
//
|
|
11
|
+
// Pre-hydration: set theme class to avoid flash of wrong theme.
|
|
12
12
|
const themeScript = `(function(){try{var t=localStorage.getItem('theme')||'system';var m=window.matchMedia('(prefers-color-scheme:dark)').matches;document.documentElement.classList.toggle('dark',t==='dark'||(t==='system'&&m));}catch(e){}})();`
|
|
13
13
|
|
|
14
14
|
interface RouterContext {
|
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
|
|
2
2
|
import { getServerSession } from '~/server/better-auth/session'
|
|
3
3
|
|
|
4
|
-
/**
|
|
5
|
-
* Layout group guarding every route under it: redirects to sign-in when there's
|
|
6
|
-
* no session, and exposes the session to child loaders via `beforeLoad`.
|
|
7
|
-
*/
|
|
4
|
+
/** Guards child routes: redirects to sign-in when no session; exposes session via `beforeLoad`. */
|
|
8
5
|
export const Route = createFileRoute('/_authed')({
|
|
9
6
|
beforeLoad: async () => {
|
|
10
7
|
const session = await getServerSession()
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
// Loads
|
|
2
|
-
// (so this server route typechecks even without other react-start imports).
|
|
1
|
+
// Loads TanStack Start augmentation adding `server` to route options (typechecks without other react-start imports).
|
|
3
2
|
|
|
4
3
|
import { createFileRoute } from '@tanstack/react-router'
|
|
5
4
|
import type {} from '@tanstack/react-start'
|
|
@@ -43,7 +43,7 @@ export const auth = betterAuth({
|
|
|
43
43
|
},
|
|
44
44
|
socialProviders,
|
|
45
45
|
user: {
|
|
46
|
-
//
|
|
46
|
+
// Add extra columns here (mirror in auth.schema.ts):
|
|
47
47
|
// additionalFields: {
|
|
48
48
|
// role: { type: 'string', defaultValue: 'user', input: false },
|
|
49
49
|
// },
|
|
@@ -3,14 +3,13 @@ import { getRequest } from '@tanstack/react-start/server'
|
|
|
3
3
|
import { auth } from '.'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* Server-
|
|
7
|
-
*
|
|
6
|
+
* Server-fn resolving the better-auth session from request headers; use in
|
|
7
|
+
* loaders / `beforeLoad`.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* imported transitively by routes) and trip the import-protection plugin.
|
|
9
|
+
* `getRequest` is called INSIDE the handler on purpose: the Start plugin
|
|
10
|
+
* extracts handlers server-side and strips the import from the client bundle.
|
|
11
|
+
* Calling it at module scope would leak `@tanstack/react-start/server` into
|
|
12
|
+
* client code (routes import this transitively) and trip import-protection.
|
|
14
13
|
*/
|
|
15
14
|
export const getServerSession = createServerFn({ method: 'GET' }).handler(() =>
|
|
16
15
|
auth.api.getSession({ headers: getRequest().headers }),
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Drizzle schema barrel; one `export *` per `*.schema.ts`.
|
|
2
2
|
export * from './auth.schema'
|
|
@@ -3,11 +3,11 @@ import { config } from 'dotenv'
|
|
|
3
3
|
|
|
4
4
|
config({ path: ['.env.local', '.env'] })
|
|
5
5
|
|
|
6
|
-
// Deterministic
|
|
6
|
+
// Deterministic across runs.
|
|
7
7
|
faker.seed(42)
|
|
8
8
|
|
|
9
9
|
async function main() {
|
|
10
|
-
//
|
|
10
|
+
// Seed your tables here (idempotent) via faker:
|
|
11
11
|
// const { db } = await import('~/server/db')
|
|
12
12
|
// await db.insert(user).values({
|
|
13
13
|
// id: faker.string.uuid(),
|
|
@@ -4,7 +4,7 @@ import { formatAddress } from '../../core/address'
|
|
|
4
4
|
import { type MailerAdapter, MailerError, type RenderedMessage } from '../../core/port'
|
|
5
5
|
import { ResendConfigSchema } from './config'
|
|
6
6
|
|
|
7
|
-
/** Minimal structural view of the Resend client
|
|
7
|
+
/** Minimal structural view of the Resend client (eases testing). */
|
|
8
8
|
export interface ResendClient {
|
|
9
9
|
emails: {
|
|
10
10
|
send(payload: ResendSendPayload): Promise<{
|
|
@@ -30,7 +30,7 @@ interface ResendSendPayload {
|
|
|
30
30
|
|
|
31
31
|
export interface ResendAdapterOptions {
|
|
32
32
|
apiKey: string
|
|
33
|
-
/**
|
|
33
|
+
/** Custom/mock client; defaults to a real `Resend`. */
|
|
34
34
|
client?: ResendClient
|
|
35
35
|
}
|
|
36
36
|
|
|
@@ -39,7 +39,7 @@ function toAttachmentContent(content: Uint8Array | string): Buffer | string {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
export function resendAdapter(options: ResendAdapterOptions): MailerAdapter {
|
|
42
|
-
// Validate
|
|
42
|
+
// Validate early so a missing key fails at construction, not send().
|
|
43
43
|
const config = v.parse(ResendConfigSchema, { apiKey: options.apiKey })
|
|
44
44
|
const client: ResendClient =
|
|
45
45
|
options.client ?? (new Resend(config.apiKey) as unknown as ResendClient)
|
|
@@ -2,7 +2,7 @@ import type { MailAddress, MailRecipient } from './port'
|
|
|
2
2
|
|
|
3
3
|
const ADDRESS_RE = /^\s*(.*?)\s*<([^>]+)>\s*$/
|
|
4
4
|
|
|
5
|
-
/** Coerce a recipient
|
|
5
|
+
/** Coerce a recipient to a structured {@link MailAddress}. */
|
|
6
6
|
export function normalizeAddress(input: MailRecipient): MailAddress {
|
|
7
7
|
if (typeof input !== 'string') return input
|
|
8
8
|
const match = input.match(ADDRESS_RE)
|
|
@@ -10,12 +10,12 @@ export function normalizeAddress(input: MailRecipient): MailAddress {
|
|
|
10
10
|
return { email: input.trim() }
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
/** Coerce a
|
|
13
|
+
/** Coerce a recipient or list to a normalized array. */
|
|
14
14
|
export function normalizeRecipients(input: MailRecipient | MailRecipient[]): MailAddress[] {
|
|
15
15
|
return (Array.isArray(input) ? input : [input]).map(normalizeAddress)
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
/** Render an address
|
|
18
|
+
/** Render an address to RFC-style `Name <email>`. */
|
|
19
19
|
export function formatAddress(address: MailAddress): string {
|
|
20
20
|
return address.name ? `${address.name} <${address.email}>` : address.email
|
|
21
21
|
}
|
|
@@ -1,41 +1,37 @@
|
|
|
1
1
|
import type { ReactElement } from 'react'
|
|
2
2
|
|
|
3
|
-
/**
|
|
3
|
+
/** Structured email address. */
|
|
4
4
|
export interface MailAddress {
|
|
5
5
|
email: string
|
|
6
6
|
name?: string
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
/**
|
|
10
|
-
* A recipient, either as a plain string (`"hi@acme.com"` or
|
|
11
|
-
* `"Acme <hi@acme.com>"`) or a structured {@link MailAddress}.
|
|
12
|
-
*/
|
|
9
|
+
/** Recipient: string (`"hi@acme.com"` / `"Acme <hi@acme.com>"`) or {@link MailAddress}. */
|
|
13
10
|
export type MailRecipient = string | MailAddress
|
|
14
11
|
|
|
15
12
|
export interface MailAttachment {
|
|
16
13
|
filename: string
|
|
17
|
-
/** Raw bytes or
|
|
14
|
+
/** Raw bytes or base64 string. */
|
|
18
15
|
content: Uint8Array | string
|
|
19
16
|
contentType?: string
|
|
20
17
|
}
|
|
21
18
|
|
|
22
19
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* the mailer before it reaches the adapter.
|
|
20
|
+
* Message as authored by app code. Never raw HTML: the body is always a React
|
|
21
|
+
* Email component, rendered to HTML + text by the mailer before the adapter.
|
|
26
22
|
*/
|
|
27
23
|
export interface MailMessage {
|
|
28
24
|
to: MailRecipient | MailRecipient[]
|
|
29
25
|
subject: string
|
|
30
|
-
/**
|
|
26
|
+
/** React Email component; rendered to HTML + text automatically. */
|
|
31
27
|
react: ReactElement
|
|
32
|
-
/** Overrides the default `from
|
|
28
|
+
/** Overrides the mailer's default `from`. */
|
|
33
29
|
from?: MailRecipient
|
|
34
30
|
replyTo?: MailRecipient
|
|
35
31
|
cc?: MailRecipient | MailRecipient[]
|
|
36
32
|
bcc?: MailRecipient | MailRecipient[]
|
|
37
33
|
headers?: Record<string, string>
|
|
38
|
-
/** Provider-agnostic
|
|
34
|
+
/** Provider-agnostic tags for analytics & filtering. */
|
|
39
35
|
tags?: Record<string, string>
|
|
40
36
|
attachments?: MailAttachment[]
|
|
41
37
|
}
|
|
@@ -45,18 +41,12 @@ export interface SentMail {
|
|
|
45
41
|
id: string
|
|
46
42
|
}
|
|
47
43
|
|
|
48
|
-
/**
|
|
49
|
-
* The port the application depends on. Swapping providers means swapping the
|
|
50
|
-
* adapter passed to {@link createMailer}; this interface never changes.
|
|
51
|
-
*/
|
|
44
|
+
/** The port the app depends on; swap providers via {@link createMailer}'s adapter — this never changes. */
|
|
52
45
|
export interface Mailer {
|
|
53
46
|
send(message: MailMessage): Promise<SentMail>
|
|
54
47
|
}
|
|
55
48
|
|
|
56
|
-
/**
|
|
57
|
-
* The message shape an adapter receives: addresses are normalized and the body
|
|
58
|
-
* is already rendered to `html` + `text`. Adapters do no rendering.
|
|
59
|
-
*/
|
|
49
|
+
/** Shape an adapter receives: normalized addresses, body pre-rendered to `html` + `text`. Adapters don't render. */
|
|
60
50
|
export interface RenderedMessage {
|
|
61
51
|
to: MailAddress[]
|
|
62
52
|
from: MailAddress
|
|
@@ -71,13 +61,13 @@ export interface RenderedMessage {
|
|
|
71
61
|
attachments?: MailAttachment[]
|
|
72
62
|
}
|
|
73
63
|
|
|
74
|
-
/**
|
|
64
|
+
/** Contract each provider implements; intentionally tiny. */
|
|
75
65
|
export interface MailerAdapter {
|
|
76
66
|
readonly name: string
|
|
77
67
|
send(message: RenderedMessage): Promise<SentMail>
|
|
78
68
|
}
|
|
79
69
|
|
|
80
|
-
/** Normalized error
|
|
70
|
+
/** Normalized adapter error so callers never catch provider types. */
|
|
81
71
|
export class MailerError extends Error {
|
|
82
72
|
readonly adapter: string
|
|
83
73
|
|
|
@@ -6,7 +6,7 @@ export interface RenderedBody {
|
|
|
6
6
|
text: string
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
/**
|
|
9
|
+
/** Turns a React Email component into HTML + plain text. */
|
|
10
10
|
export type EmailRenderer = (react: ReactElement) => Promise<RenderedBody>
|
|
11
11
|
|
|
12
12
|
/** Default renderer backed by `@react-email/render`. */
|
|
@@ -3,21 +3,20 @@ import type { Mailer, MailerAdapter, MailRecipient, RenderedMessage } from './co
|
|
|
3
3
|
import { type EmailRenderer, renderEmail } from './core/render'
|
|
4
4
|
|
|
5
5
|
export interface CreateMailerOptions {
|
|
6
|
-
/**
|
|
6
|
+
/** Provider impl (Resend, Brevo, …). */
|
|
7
7
|
adapter: MailerAdapter
|
|
8
|
-
/** Default sender
|
|
8
|
+
/** Default sender when a message omits `from`. */
|
|
9
9
|
from: MailRecipient
|
|
10
|
-
/** Override the
|
|
10
|
+
/** Override the renderer (mostly for tests). */
|
|
11
11
|
render?: EmailRenderer
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* Build a {@link Mailer}.
|
|
16
|
-
* the
|
|
15
|
+
* Build a {@link Mailer}. Composition root: pick the adapter here; the rest of
|
|
16
|
+
* the app depends only on the `Mailer` port.
|
|
17
17
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* the adapter.
|
|
18
|
+
* Renders the React body to HTML + text, normalizes addresses, applies the
|
|
19
|
+
* default sender, then hands a {@link RenderedMessage} to the adapter.
|
|
21
20
|
*/
|
|
22
21
|
export function createMailer(options: CreateMailerOptions): Mailer {
|
|
23
22
|
const defaultFrom = normalizeAddress(options.from)
|
|
@@ -11,7 +11,7 @@ function required(value: string | undefined, name: string): string {
|
|
|
11
11
|
return value
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
// Lazy so the app boots without RESEND_API_KEY;
|
|
14
|
+
// Lazy so the app boots without RESEND_API_KEY; adapter built on first send.
|
|
15
15
|
let mailer: Mailer | null = null
|
|
16
16
|
function getMailer(): Mailer {
|
|
17
17
|
if (!mailer) {
|
|
@@ -28,7 +28,7 @@ interface BrevoSendResponse {
|
|
|
28
28
|
messageIds?: string[]
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
/**
|
|
31
|
+
/** Structural view of the Brevo client (eases testing). */
|
|
32
32
|
export interface BrevoClientLike {
|
|
33
33
|
transactionalEmails: {
|
|
34
34
|
sendTransacEmail(request: BrevoSendRequest): Promise<BrevoSendResponse>
|
|
@@ -37,7 +37,7 @@ export interface BrevoClientLike {
|
|
|
37
37
|
|
|
38
38
|
export interface BrevoAdapterOptions {
|
|
39
39
|
apiKey: string
|
|
40
|
-
/** Inject
|
|
40
|
+
/** Inject custom/mock client. Defaults to real `BrevoClient`. */
|
|
41
41
|
client?: BrevoClientLike
|
|
42
42
|
}
|
|
43
43
|
|
|
@@ -50,7 +50,7 @@ function toBase64(content: Uint8Array | string): string {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
export function brevoAdapter(options: BrevoAdapterOptions): MailerAdapter {
|
|
53
|
-
// Validate
|
|
53
|
+
// Validate early: missing key fails at construction, not send().
|
|
54
54
|
const config = v.parse(BrevoConfigSchema, { apiKey: options.apiKey })
|
|
55
55
|
const client: BrevoClientLike =
|
|
56
56
|
options.client ?? (new BrevoClient({ apiKey: config.apiKey }) as unknown as BrevoClientLike)
|
|
@@ -4,7 +4,7 @@ import { formatAddress } from '../../core/address.js'
|
|
|
4
4
|
import { type MailerAdapter, MailerError, type RenderedMessage } from '../../core/port.js'
|
|
5
5
|
import { ResendConfigSchema } from './config.js'
|
|
6
6
|
|
|
7
|
-
/**
|
|
7
|
+
/** Structural view of the Resend client (eases testing). */
|
|
8
8
|
export interface ResendClient {
|
|
9
9
|
emails: {
|
|
10
10
|
send(payload: ResendSendPayload): Promise<{
|
|
@@ -30,7 +30,7 @@ interface ResendSendPayload {
|
|
|
30
30
|
|
|
31
31
|
export interface ResendAdapterOptions {
|
|
32
32
|
apiKey: string
|
|
33
|
-
/** Inject
|
|
33
|
+
/** Inject custom/mock client. Defaults to real `Resend`. */
|
|
34
34
|
client?: ResendClient
|
|
35
35
|
}
|
|
36
36
|
|
|
@@ -39,7 +39,7 @@ function toAttachmentContent(content: Uint8Array | string): Buffer | string {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
export function resendAdapter(options: ResendAdapterOptions): MailerAdapter {
|
|
42
|
-
// Validate
|
|
42
|
+
// Validate early: missing key fails at construction, not send().
|
|
43
43
|
const config = v.parse(ResendConfigSchema, { apiKey: options.apiKey })
|
|
44
44
|
const client: ResendClient =
|
|
45
45
|
options.client ?? (new Resend(config.apiKey) as unknown as ResendClient)
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import * as v from 'valibot'
|
|
2
2
|
|
|
3
3
|
export const SesConfigSchema = v.object({
|
|
4
|
-
/** AWS region. Falls back to
|
|
4
|
+
/** AWS region. Falls back to SDK resolution (e.g. `AWS_REGION`). */
|
|
5
5
|
region: v.optional(v.string()),
|
|
6
|
-
/** Static credentials. Omit to let the SDK resolve
|
|
6
|
+
/** Static credentials. Omit to let the SDK resolve (env, profile, IAM). */
|
|
7
7
|
accessKeyId: v.optional(v.string()),
|
|
8
8
|
secretAccessKey: v.optional(v.string()),
|
|
9
|
-
/** SES configuration set
|
|
9
|
+
/** SES configuration set (event publishing, dedicated IPs, …). */
|
|
10
10
|
configurationSetName: v.optional(v.string()),
|
|
11
11
|
})
|
|
12
12
|
|
|
@@ -5,7 +5,7 @@ import { formatAddress } from '../../core/address.js'
|
|
|
5
5
|
import { type MailerAdapter, MailerError, type RenderedMessage } from '../../core/port.js'
|
|
6
6
|
import { SesConfigSchema } from './config.js'
|
|
7
7
|
|
|
8
|
-
/**
|
|
8
|
+
/** Structural view of the SESv2 client (eases testing). */
|
|
9
9
|
export interface SesClientLike {
|
|
10
10
|
send(command: { input: SesSendEmailInput }): Promise<{ MessageId?: string }>
|
|
11
11
|
}
|
|
@@ -26,12 +26,12 @@ interface SesSendEmailInput {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
export interface SesAdapterOptions {
|
|
29
|
-
/** AWS region. Falls back to
|
|
29
|
+
/** AWS region. Falls back to SDK resolution (e.g. `AWS_REGION`). */
|
|
30
30
|
region?: string
|
|
31
31
|
accessKeyId?: string
|
|
32
32
|
secretAccessKey?: string
|
|
33
33
|
configurationSetName?: string
|
|
34
|
-
/** Inject
|
|
34
|
+
/** Inject custom/mock client. Defaults to real `SESv2Client`. */
|
|
35
35
|
client?: SesClientLike
|
|
36
36
|
}
|
|
37
37
|
|
|
@@ -56,8 +56,7 @@ export function sesAdapter(options: SesAdapterOptions = {}): MailerAdapter {
|
|
|
56
56
|
return {
|
|
57
57
|
name: 'ses',
|
|
58
58
|
async send(message: RenderedMessage) {
|
|
59
|
-
// SES
|
|
60
|
-
// message, which this adapter intentionally doesn't build. Fail loudly.
|
|
59
|
+
// SES Simple content can't carry attachments (needs raw MIME, not built here). Fail loudly.
|
|
61
60
|
if (message.attachments?.length) {
|
|
62
61
|
throw new MailerError('SES adapter does not support attachments (requires raw MIME)', {
|
|
63
62
|
adapter: 'ses',
|
package/index.mjs
CHANGED
|
@@ -1,20 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// create-stack —
|
|
3
|
-
//
|
|
4
|
-
// app, strip it to the selection, stamp identity, verify.
|
|
5
|
-
//
|
|
6
|
-
// Interactive by default. Non-interactive when any selection flag (or --yes) is
|
|
7
|
-
// passed — useful for scripts/CI and for headless end-to-end testing:
|
|
2
|
+
// create-stack — fork a base app, strip to selection, stamp identity, verify.
|
|
3
|
+
// Interactive by default; non-interactive when any selection flag (or --yes) is passed:
|
|
8
4
|
// create-stack my-app --framework next --foundations drizzle,trpc --mailer ses --no-install
|
|
9
5
|
|
|
10
6
|
import { resolve } from 'node:path'
|
|
11
7
|
import * as p from '@clack/prompts'
|
|
12
8
|
import { buildProject } from './lib/build.mjs'
|
|
13
|
-
import { loadCapabilities, loadPatterns } from './lib/manifests.mjs'
|
|
14
9
|
import { isDirEmpty, run } from './lib/util.mjs'
|
|
15
10
|
|
|
16
|
-
// Capabilities baked into the base apps vs. the ones add-capability supplies.
|
|
17
|
-
const BAKED_CAPS = new Set(['mailer', 'email-kit'])
|
|
18
11
|
const ALL_FOUNDATIONS = ['drizzle', 'trpc', 'better-auth', 'data-table']
|
|
19
12
|
|
|
20
13
|
const cancelled = (v) => {
|
|
@@ -54,7 +47,7 @@ const csv = (v) =>
|
|
|
54
47
|
.filter(Boolean)
|
|
55
48
|
: []
|
|
56
49
|
|
|
57
|
-
/** Resolve hard deps +
|
|
50
|
+
/** Resolve hard deps + mailer's better-auth requirement. */
|
|
58
51
|
function normalize(picked, mailer) {
|
|
59
52
|
const kept = new Set(picked.filter((f) => ALL_FOUNDATIONS.includes(f)))
|
|
60
53
|
if (kept.has('trpc') || kept.has('better-auth')) kept.add('drizzle')
|
|
@@ -63,19 +56,17 @@ function normalize(picked, mailer) {
|
|
|
63
56
|
return { kept, mailerProvider }
|
|
64
57
|
}
|
|
65
58
|
|
|
66
|
-
function collectFromFlags(args
|
|
59
|
+
function collectFromFlags(args) {
|
|
67
60
|
const argDir = args._[0]
|
|
68
61
|
if (!argDir) throw new Error('Project name is required (positional) in non-interactive mode')
|
|
69
62
|
const framework = args.flags.framework === 'next' ? 'next' : 'tanstack'
|
|
70
63
|
const picked = args.flags.foundations ? csv(args.flags.foundations) : [...ALL_FOUNDATIONS]
|
|
71
64
|
const { kept, mailerProvider } = normalize(picked, args.flags.mailer)
|
|
72
|
-
const validCaps = new Set(Object.keys(capabilities).filter((c) => !BAKED_CAPS.has(c)))
|
|
73
|
-
const extraCaps = csv(args.flags.caps).filter((c) => validCaps.has(c))
|
|
74
65
|
const doInstall = !args.flags['no-install']
|
|
75
|
-
return { argDir, projectName: argDir, framework, kept, mailerProvider,
|
|
66
|
+
return { argDir, projectName: argDir, framework, kept, mailerProvider, doInstall }
|
|
76
67
|
}
|
|
77
68
|
|
|
78
|
-
async function collectFromPrompts(argDir
|
|
69
|
+
async function collectFromPrompts(argDir) {
|
|
79
70
|
p.intro('create-stack — fork a base app, strip it to your selection')
|
|
80
71
|
|
|
81
72
|
const name = cancelled(
|
|
@@ -127,26 +118,15 @@ async function collectFromPrompts(argDir, capabilities) {
|
|
|
127
118
|
}),
|
|
128
119
|
)
|
|
129
120
|
|
|
130
|
-
const extraCaps = cancelled(
|
|
131
|
-
await p.multiselect({
|
|
132
|
-
message: 'Extra capabilities (optional — added via add-capability)',
|
|
133
|
-
required: false,
|
|
134
|
-
initialValues: [],
|
|
135
|
-
options: Object.keys(capabilities)
|
|
136
|
-
.filter((c) => !BAKED_CAPS.has(c))
|
|
137
|
-
.map((c) => ({ value: c, label: c, hint: capabilities[c].description?.slice(0, 40) })),
|
|
138
|
-
}),
|
|
139
|
-
)
|
|
140
|
-
|
|
141
121
|
const doInstall = cancelled(
|
|
142
122
|
await p.confirm({ message: 'Install dependencies and verify now?', initialValue: true }),
|
|
143
123
|
)
|
|
144
124
|
|
|
145
125
|
const { kept, mailerProvider } = normalize(picked, mailer)
|
|
146
|
-
return { argDir, projectName, framework, kept, mailerProvider,
|
|
126
|
+
return { argDir, projectName, framework, kept, mailerProvider, doInstall }
|
|
147
127
|
}
|
|
148
128
|
|
|
149
|
-
function execute(a
|
|
129
|
+
function execute(a) {
|
|
150
130
|
const projectDir = resolve(process.cwd(), a.argDir ?? a.projectName)
|
|
151
131
|
if (!isDirEmpty(projectDir)) {
|
|
152
132
|
p.cancel(`Target directory is not empty: ${projectDir}`)
|
|
@@ -155,7 +135,7 @@ function execute(a, patterns) {
|
|
|
155
135
|
|
|
156
136
|
const s = p.spinner()
|
|
157
137
|
s.start('Forking + stripping the base app')
|
|
158
|
-
buildProject({ ...a, projectDir
|
|
138
|
+
buildProject({ ...a, projectDir })
|
|
159
139
|
s.stop('Project scaffolded')
|
|
160
140
|
|
|
161
141
|
if (a.doInstall) {
|
|
@@ -173,10 +153,20 @@ function execute(a, patterns) {
|
|
|
173
153
|
)
|
|
174
154
|
}
|
|
175
155
|
|
|
176
|
-
//
|
|
177
|
-
if
|
|
156
|
+
// fresh repo + initial commit (also satisfies Biome vcs.useIgnoreFile).
|
|
157
|
+
// commit is best-effort: skipped if git identity unset, staged tree left in place.
|
|
158
|
+
if (run('git', ['-C', projectDir, 'init', '-q'])) {
|
|
178
159
|
run('git', ['-C', projectDir, 'add', '-A'])
|
|
179
|
-
|
|
160
|
+
const committed = run(
|
|
161
|
+
'git',
|
|
162
|
+
['-C', projectDir, 'commit', '-q', '-m', 'chore: initial commit from create-stack'],
|
|
163
|
+
{ stdio: 'ignore' },
|
|
164
|
+
)
|
|
165
|
+
p.log.step(
|
|
166
|
+
committed
|
|
167
|
+
? 'git repository initialized (initial commit created)'
|
|
168
|
+
: 'git repository initialized — set git user.name/email, then commit',
|
|
169
|
+
)
|
|
180
170
|
}
|
|
181
171
|
|
|
182
172
|
const keptMailer = a.mailerProvider !== 'none'
|
|
@@ -184,15 +174,12 @@ function execute(a, patterns) {
|
|
|
184
174
|
`Framework: ${a.framework === 'next' ? 'Next.js' : 'TanStack Start'}`,
|
|
185
175
|
`Foundations: ${[...a.kept].sort().join(', ') || '(none)'}`,
|
|
186
176
|
`Mailer: ${keptMailer ? a.mailerProvider : '(none)'}`,
|
|
177
|
+
'',
|
|
178
|
+
'Add more tools (storage, jobs, cache, analytics, …) with the add-capability skill.',
|
|
179
|
+
'',
|
|
180
|
+
'Next:',
|
|
181
|
+
` cd ${a.argDir ?? a.projectName}`,
|
|
187
182
|
]
|
|
188
|
-
if (a.extraCaps.length) {
|
|
189
|
-
lines.push(
|
|
190
|
-
'',
|
|
191
|
-
'Add the extra capabilities with the add-capability skill:',
|
|
192
|
-
...a.extraCaps.map((c) => ` • ${c}`),
|
|
193
|
-
)
|
|
194
|
-
}
|
|
195
|
-
lines.push('', 'Next:', ` cd ${a.argDir ?? a.projectName}`)
|
|
196
183
|
if (!a.doInstall) lines.push(' pnpm install')
|
|
197
184
|
lines.push(' cp .env.example .env # fill in the values', ' pnpm dev')
|
|
198
185
|
p.note(lines.join('\n'), 'Done')
|
|
@@ -200,20 +187,16 @@ function execute(a, patterns) {
|
|
|
200
187
|
}
|
|
201
188
|
|
|
202
189
|
async function main() {
|
|
203
|
-
const patterns = loadPatterns()
|
|
204
|
-
const capabilities = loadCapabilities()
|
|
205
190
|
const args = parseArgs(process.argv.slice(2))
|
|
206
191
|
|
|
207
192
|
const nonInteractive =
|
|
208
193
|
args.flags.yes ||
|
|
209
194
|
args.flags.y ||
|
|
210
|
-
['framework', 'foundations', 'mailer', '
|
|
195
|
+
['framework', 'foundations', 'mailer', 'no-install'].some((k) => k in args.flags)
|
|
211
196
|
|
|
212
|
-
const answers = nonInteractive
|
|
213
|
-
? collectFromFlags(args, capabilities)
|
|
214
|
-
: await collectFromPrompts(args._[0], capabilities)
|
|
197
|
+
const answers = nonInteractive ? collectFromFlags(args) : await collectFromPrompts(args._[0])
|
|
215
198
|
|
|
216
|
-
execute(answers
|
|
199
|
+
execute(answers)
|
|
217
200
|
}
|
|
218
201
|
|
|
219
202
|
main().catch((err) => {
|