@alfredmouelle/create-stack 0.1.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 +56 -0
- package/_stack/apps/next-base/.dockerignore +10 -0
- package/_stack/apps/next-base/Dockerfile +34 -0
- package/_stack/apps/next-base/README.md +32 -0
- package/_stack/apps/next-base/components.json +25 -0
- package/_stack/apps/next-base/drizzle.config.ts +16 -0
- package/_stack/apps/next-base/next.config.ts +8 -0
- package/_stack/apps/next-base/package.json +70 -0
- package/_stack/apps/next-base/postcss.config.mjs +7 -0
- package/_stack/apps/next-base/src/app/api/auth/[...all]/route.ts +4 -0
- package/_stack/apps/next-base/src/app/api/trpc/[trpc]/route.ts +23 -0
- package/_stack/apps/next-base/src/app/auth/_components/forgot-password-form.tsx +92 -0
- package/_stack/apps/next-base/src/app/auth/_components/reset-password-form.tsx +105 -0
- package/_stack/apps/next-base/src/app/auth/_components/sign-in-form.tsx +126 -0
- package/_stack/apps/next-base/src/app/auth/_components/sign-up-form.tsx +139 -0
- package/_stack/apps/next-base/src/app/auth/_components/verify-email-actions.tsx +45 -0
- package/_stack/apps/next-base/src/app/auth/forgot-password/page.tsx +19 -0
- package/_stack/apps/next-base/src/app/auth/layout.tsx +9 -0
- package/_stack/apps/next-base/src/app/auth/reset-password/page.tsx +26 -0
- package/_stack/apps/next-base/src/app/auth/sign-in/page.tsx +27 -0
- package/_stack/apps/next-base/src/app/auth/sign-up/page.tsx +27 -0
- package/_stack/apps/next-base/src/app/auth/verify-email/page.tsx +30 -0
- package/_stack/apps/next-base/src/app/dashboard/page.tsx +12 -0
- package/_stack/apps/next-base/src/app/globals.css +171 -0
- package/_stack/apps/next-base/src/app/layout.tsx +23 -0
- package/_stack/apps/next-base/src/app/page.tsx +15 -0
- package/_stack/apps/next-base/src/components/data-table.tsx +77 -0
- package/_stack/apps/next-base/src/components/infinite-data-table.tsx +102 -0
- package/_stack/apps/next-base/src/components/sortable-header.tsx +37 -0
- package/_stack/apps/next-base/src/components/theme-provider.tsx +8 -0
- package/_stack/apps/next-base/src/components/theme-toggle.tsx +37 -0
- package/_stack/apps/next-base/src/components/ui/button.tsx +64 -0
- package/_stack/apps/next-base/src/components/ui/calendar.tsx +185 -0
- package/_stack/apps/next-base/src/components/ui/card.tsx +84 -0
- package/_stack/apps/next-base/src/components/ui/date-picker.tsx +85 -0
- package/_stack/apps/next-base/src/components/ui/date-range-picker.tsx +138 -0
- package/_stack/apps/next-base/src/components/ui/dropdown-menu.tsx +246 -0
- package/_stack/apps/next-base/src/components/ui/form.tsx +149 -0
- package/_stack/apps/next-base/src/components/ui/input-group.tsx +97 -0
- package/_stack/apps/next-base/src/components/ui/input.tsx +18 -0
- package/_stack/apps/next-base/src/components/ui/label.tsx +18 -0
- package/_stack/apps/next-base/src/components/ui/popover.tsx +76 -0
- package/_stack/apps/next-base/src/components/ui/skeleton.tsx +13 -0
- package/_stack/apps/next-base/src/components/ui/spinner.tsx +8 -0
- package/_stack/apps/next-base/src/components/ui/table.tsx +87 -0
- package/_stack/apps/next-base/src/emails/components/components.tsx +199 -0
- package/_stack/apps/next-base/src/emails/components/context.tsx +18 -0
- package/_stack/apps/next-base/src/emails/components/index.ts +23 -0
- package/_stack/apps/next-base/src/emails/components/theme.ts +65 -0
- package/_stack/apps/next-base/src/emails/reset-password.tsx +16 -0
- package/_stack/apps/next-base/src/emails/verify-email.tsx +15 -0
- package/_stack/apps/next-base/src/env.ts +41 -0
- package/_stack/apps/next-base/src/features/auth/auth-card.tsx +30 -0
- package/_stack/apps/next-base/src/features/auth/form-alert.tsx +27 -0
- package/_stack/apps/next-base/src/features/auth/google-button.tsx +66 -0
- package/_stack/apps/next-base/src/features/auth/schemas.ts +35 -0
- package/_stack/apps/next-base/src/lib/date.ts +4 -0
- package/_stack/apps/next-base/src/lib/utils.ts +6 -0
- package/_stack/apps/next-base/src/server/api/root.ts +10 -0
- package/_stack/apps/next-base/src/server/api/routers/health.router.ts +8 -0
- package/_stack/apps/next-base/src/server/api/trpc.ts +56 -0
- package/_stack/apps/next-base/src/server/auth/guards.ts +10 -0
- package/_stack/apps/next-base/src/server/better-auth/client.ts +9 -0
- package/_stack/apps/next-base/src/server/better-auth/config.ts +60 -0
- package/_stack/apps/next-base/src/server/better-auth/emails.tsx +25 -0
- package/_stack/apps/next-base/src/server/better-auth/index.ts +1 -0
- package/_stack/apps/next-base/src/server/better-auth/server.ts +14 -0
- package/_stack/apps/next-base/src/server/db/index.ts +6 -0
- package/_stack/apps/next-base/src/server/db/keyset.ts +63 -0
- package/_stack/apps/next-base/src/server/db/schemas/auth.schema.ts +71 -0
- package/_stack/apps/next-base/src/server/db/schemas/index.ts +2 -0
- package/_stack/apps/next-base/src/server/db/seed.ts +27 -0
- package/_stack/apps/next-base/src/server/email/adapters/resend/config.ts +7 -0
- package/_stack/apps/next-base/src/server/email/adapters/resend/index.ts +75 -0
- package/_stack/apps/next-base/src/server/email/core/address.ts +21 -0
- package/_stack/apps/next-base/src/server/email/core/port.ts +89 -0
- package/_stack/apps/next-base/src/server/email/core/render.ts +16 -0
- package/_stack/apps/next-base/src/server/email/factory.ts +47 -0
- package/_stack/apps/next-base/src/server/email/index.ts +36 -0
- package/_stack/apps/next-base/src/trpc/query-client.ts +19 -0
- package/_stack/apps/next-base/src/trpc/react.tsx +62 -0
- package/_stack/apps/next-base/src/trpc/server.ts +23 -0
- package/_stack/apps/next-base/tsconfig.json +37 -0
- package/_stack/apps/tanstack-base/.dockerignore +13 -0
- package/_stack/apps/tanstack-base/Dockerfile +28 -0
- package/_stack/apps/tanstack-base/README.md +31 -0
- package/_stack/apps/tanstack-base/components.json +25 -0
- package/_stack/apps/tanstack-base/drizzle.config.ts +16 -0
- package/_stack/apps/tanstack-base/package.json +85 -0
- package/_stack/apps/tanstack-base/public/favicon.ico +0 -0
- package/_stack/apps/tanstack-base/public/logo192.png +0 -0
- package/_stack/apps/tanstack-base/public/logo512.png +0 -0
- package/_stack/apps/tanstack-base/public/manifest.json +25 -0
- package/_stack/apps/tanstack-base/public/robots.txt +3 -0
- package/_stack/apps/tanstack-base/src/components/data-table.tsx +77 -0
- package/_stack/apps/tanstack-base/src/components/form/field-error.tsx +18 -0
- package/_stack/apps/tanstack-base/src/components/form/text-field.tsx +47 -0
- package/_stack/apps/tanstack-base/src/components/infinite-data-table.tsx +102 -0
- package/_stack/apps/tanstack-base/src/components/sortable-header.tsx +37 -0
- package/_stack/apps/tanstack-base/src/components/theme-provider.tsx +69 -0
- package/_stack/apps/tanstack-base/src/components/theme-toggle.tsx +35 -0
- package/_stack/apps/tanstack-base/src/components/ui/button.tsx +64 -0
- package/_stack/apps/tanstack-base/src/components/ui/calendar.tsx +185 -0
- package/_stack/apps/tanstack-base/src/components/ui/card.tsx +84 -0
- package/_stack/apps/tanstack-base/src/components/ui/date-picker.tsx +83 -0
- package/_stack/apps/tanstack-base/src/components/ui/date-range-picker.tsx +136 -0
- package/_stack/apps/tanstack-base/src/components/ui/dropdown-menu.tsx +246 -0
- package/_stack/apps/tanstack-base/src/components/ui/input-group.tsx +97 -0
- package/_stack/apps/tanstack-base/src/components/ui/input.tsx +18 -0
- package/_stack/apps/tanstack-base/src/components/ui/label.tsx +18 -0
- package/_stack/apps/tanstack-base/src/components/ui/popover.tsx +74 -0
- package/_stack/apps/tanstack-base/src/components/ui/skeleton.tsx +13 -0
- package/_stack/apps/tanstack-base/src/components/ui/spinner.tsx +8 -0
- package/_stack/apps/tanstack-base/src/components/ui/table.tsx +87 -0
- package/_stack/apps/tanstack-base/src/emails/components/components.tsx +199 -0
- package/_stack/apps/tanstack-base/src/emails/components/context.tsx +18 -0
- package/_stack/apps/tanstack-base/src/emails/components/index.ts +23 -0
- package/_stack/apps/tanstack-base/src/emails/components/theme.ts +65 -0
- package/_stack/apps/tanstack-base/src/emails/reset-password.tsx +16 -0
- package/_stack/apps/tanstack-base/src/emails/verify-email.tsx +15 -0
- package/_stack/apps/tanstack-base/src/env.ts +41 -0
- package/_stack/apps/tanstack-base/src/features/auth/auth-card.tsx +30 -0
- package/_stack/apps/tanstack-base/src/features/auth/form-alert.tsx +27 -0
- package/_stack/apps/tanstack-base/src/features/auth/google-button.tsx +64 -0
- package/_stack/apps/tanstack-base/src/features/auth/schemas.ts +35 -0
- package/_stack/apps/tanstack-base/src/lib/date.ts +4 -0
- package/_stack/apps/tanstack-base/src/lib/utils.ts +6 -0
- package/_stack/apps/tanstack-base/src/router.tsx +40 -0
- package/_stack/apps/tanstack-base/src/routes/__root.tsx +73 -0
- package/_stack/apps/tanstack-base/src/routes/_authed/dashboard.tsx +12 -0
- package/_stack/apps/tanstack-base/src/routes/_authed.tsx +21 -0
- package/_stack/apps/tanstack-base/src/routes/api/auth/$.ts +14 -0
- package/_stack/apps/tanstack-base/src/routes/api.trpc.$.tsx +31 -0
- package/_stack/apps/tanstack-base/src/routes/auth/forgot-password.tsx +89 -0
- package/_stack/apps/tanstack-base/src/routes/auth/reset-password.tsx +111 -0
- package/_stack/apps/tanstack-base/src/routes/auth/sign-in.tsx +117 -0
- package/_stack/apps/tanstack-base/src/routes/auth/sign-up.tsx +119 -0
- package/_stack/apps/tanstack-base/src/routes/auth/verify-email.tsx +72 -0
- package/_stack/apps/tanstack-base/src/routes/auth.tsx +22 -0
- package/_stack/apps/tanstack-base/src/routes/index.tsx +18 -0
- package/_stack/apps/tanstack-base/src/server/api/root.ts +10 -0
- package/_stack/apps/tanstack-base/src/server/api/routers/health.router.ts +8 -0
- package/_stack/apps/tanstack-base/src/server/api/trpc.ts +61 -0
- package/_stack/apps/tanstack-base/src/server/better-auth/client.ts +9 -0
- package/_stack/apps/tanstack-base/src/server/better-auth/config.ts +68 -0
- package/_stack/apps/tanstack-base/src/server/better-auth/emails.tsx +25 -0
- package/_stack/apps/tanstack-base/src/server/better-auth/index.ts +1 -0
- package/_stack/apps/tanstack-base/src/server/better-auth/session.ts +9 -0
- package/_stack/apps/tanstack-base/src/server/db/index.ts +6 -0
- package/_stack/apps/tanstack-base/src/server/db/keyset.ts +63 -0
- package/_stack/apps/tanstack-base/src/server/db/schemas/auth.schema.ts +71 -0
- package/_stack/apps/tanstack-base/src/server/db/schemas/index.ts +2 -0
- package/_stack/apps/tanstack-base/src/server/db/seed.ts +27 -0
- package/_stack/apps/tanstack-base/src/server/email/adapters/resend/config.ts +7 -0
- package/_stack/apps/tanstack-base/src/server/email/adapters/resend/index.ts +75 -0
- package/_stack/apps/tanstack-base/src/server/email/core/address.ts +21 -0
- package/_stack/apps/tanstack-base/src/server/email/core/port.ts +89 -0
- package/_stack/apps/tanstack-base/src/server/email/core/render.ts +16 -0
- package/_stack/apps/tanstack-base/src/server/email/factory.ts +47 -0
- package/_stack/apps/tanstack-base/src/server/email/index.ts +36 -0
- package/_stack/apps/tanstack-base/src/styles.css +171 -0
- package/_stack/apps/tanstack-base/src/trpc/devtools.tsx +6 -0
- package/_stack/apps/tanstack-base/src/trpc/query-client.ts +19 -0
- package/_stack/apps/tanstack-base/src/trpc/react.tsx +49 -0
- package/_stack/apps/tanstack-base/src/trpc/server.ts +11 -0
- package/_stack/apps/tanstack-base/tsconfig.json +27 -0
- package/_stack/apps/tanstack-base/tsr.config.json +3 -0
- package/_stack/apps/tanstack-base/vite.config.ts +15 -0
- package/_stack/packages/analytics/capability.json +26 -0
- package/_stack/packages/cache/capability.json +21 -0
- package/_stack/packages/error-tracking/capability.json +21 -0
- package/_stack/packages/jobs/capability.json +26 -0
- package/_stack/packages/logger/capability.json +21 -0
- package/_stack/packages/mailer/capability.json +28 -0
- package/_stack/packages/mailer/package.json +37 -0
- package/_stack/packages/mailer/src/adapters/brevo/config.ts +7 -0
- package/_stack/packages/mailer/src/adapters/brevo/index.ts +90 -0
- package/_stack/packages/mailer/src/adapters/resend/config.ts +7 -0
- package/_stack/packages/mailer/src/adapters/resend/index.ts +75 -0
- package/_stack/packages/mailer/src/adapters/ses/config.ts +13 -0
- package/_stack/packages/mailer/src/adapters/ses/index.ts +103 -0
- package/_stack/packages/storage/capability.json +32 -0
- package/_stack/patterns/README.md +58 -0
- package/_stack/patterns/_baseline/README-author.md +10 -0
- package/_stack/patterns/_baseline/biome.jsonc +119 -0
- package/_stack/patterns/_baseline/env.ts +31 -0
- package/_stack/patterns/_baseline/tsconfig.json +27 -0
- package/_stack/patterns/better-auth/pattern.json +73 -0
- package/_stack/patterns/better-auth-next/pattern.json +76 -0
- package/_stack/patterns/data-table/pattern.json +43 -0
- package/_stack/patterns/drizzle/pattern.json +61 -0
- package/_stack/patterns/trpc/pattern.json +61 -0
- package/_stack/patterns/trpc-next/pattern.json +64 -0
- package/index.mjs +216 -0
- package/lib/build.mjs +64 -0
- package/lib/env.mjs +56 -0
- package/lib/identity.mjs +33 -0
- package/lib/mailer.mjs +95 -0
- package/lib/manifests.mjs +61 -0
- package/lib/scaffold.mjs +49 -0
- package/lib/strip.mjs +132 -0
- package/lib/util.mjs +82 -0
- package/package.json +51 -0
- package/templates/next/layout.no-trpc.tsx +22 -0
- package/templates/tanstack/__root.no-trpc.tsx +63 -0
- package/templates/tanstack/router.no-trpc.tsx +24 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@alfredmouelle/mailer",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsdown",
|
|
11
|
+
"test": "vitest run",
|
|
12
|
+
"test:watch": "vitest",
|
|
13
|
+
"typecheck": "tsc --noEmit"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@aws-sdk/client-sesv2": "^3.1073.0",
|
|
17
|
+
"@getbrevo/brevo": "^5.0.4",
|
|
18
|
+
"resend": "^6.14.0",
|
|
19
|
+
"valibot": "^1.4.1"
|
|
20
|
+
},
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"react": ">=18",
|
|
23
|
+
"react-dom": ">=18",
|
|
24
|
+
"react-email": "^6.6.3"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/node": "^22.10.2",
|
|
28
|
+
"@types/react": "^19.0.1",
|
|
29
|
+
"@types/react-dom": "^19.0.2",
|
|
30
|
+
"react": "^19.0.0",
|
|
31
|
+
"react-dom": "^19.0.0",
|
|
32
|
+
"react-email": "^6.6.3",
|
|
33
|
+
"tsdown": "^0.22.3",
|
|
34
|
+
"typescript": "^5.9.3",
|
|
35
|
+
"vitest": "^4.1.9"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { BrevoClient } from '@getbrevo/brevo'
|
|
2
|
+
import * as v from 'valibot'
|
|
3
|
+
import type { MailAddress } from '../../core/port.js'
|
|
4
|
+
import { type MailerAdapter, MailerError, type RenderedMessage } from '../../core/port.js'
|
|
5
|
+
import { BrevoConfigSchema } from './config.js'
|
|
6
|
+
|
|
7
|
+
interface BrevoContact {
|
|
8
|
+
email: string
|
|
9
|
+
name?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface BrevoSendRequest {
|
|
13
|
+
sender: BrevoContact
|
|
14
|
+
to: BrevoContact[]
|
|
15
|
+
subject: string
|
|
16
|
+
htmlContent: string
|
|
17
|
+
textContent: string
|
|
18
|
+
replyTo?: BrevoContact
|
|
19
|
+
cc?: BrevoContact[]
|
|
20
|
+
bcc?: BrevoContact[]
|
|
21
|
+
headers?: Record<string, unknown>
|
|
22
|
+
tags?: string[]
|
|
23
|
+
attachment?: { name: string; content: string }[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface BrevoSendResponse {
|
|
27
|
+
messageId?: string
|
|
28
|
+
messageIds?: string[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Minimal structural view of the Brevo client we depend on (eases testing). */
|
|
32
|
+
export interface BrevoClientLike {
|
|
33
|
+
transactionalEmails: {
|
|
34
|
+
sendTransacEmail(request: BrevoSendRequest): Promise<BrevoSendResponse>
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface BrevoAdapterOptions {
|
|
39
|
+
apiKey: string
|
|
40
|
+
/** Inject a custom/mock client. Defaults to a real `BrevoClient`. */
|
|
41
|
+
client?: BrevoClientLike
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function toContact(address: MailAddress): BrevoContact {
|
|
45
|
+
return { email: address.email, name: address.name }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function toBase64(content: Uint8Array | string): string {
|
|
49
|
+
return typeof content === 'string' ? content : Buffer.from(content).toString('base64')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function brevoAdapter(options: BrevoAdapterOptions): MailerAdapter {
|
|
53
|
+
// Validate config early so a missing key fails at construction, not at send().
|
|
54
|
+
const config = v.parse(BrevoConfigSchema, { apiKey: options.apiKey })
|
|
55
|
+
const client: BrevoClientLike =
|
|
56
|
+
options.client ?? (new BrevoClient({ apiKey: config.apiKey }) as unknown as BrevoClientLike)
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
name: 'brevo',
|
|
60
|
+
async send(message: RenderedMessage) {
|
|
61
|
+
try {
|
|
62
|
+
const response = await client.transactionalEmails.sendTransacEmail({
|
|
63
|
+
sender: toContact(message.from),
|
|
64
|
+
to: message.to.map(toContact),
|
|
65
|
+
subject: message.subject,
|
|
66
|
+
htmlContent: message.html,
|
|
67
|
+
textContent: message.text,
|
|
68
|
+
replyTo: message.replyTo ? toContact(message.replyTo) : undefined,
|
|
69
|
+
cc: message.cc?.map(toContact),
|
|
70
|
+
bcc: message.bcc?.map(toContact),
|
|
71
|
+
headers: message.headers,
|
|
72
|
+
tags: message.tags
|
|
73
|
+
? Object.entries(message.tags).map(([k, val]) => `${k}:${val}`)
|
|
74
|
+
: undefined,
|
|
75
|
+
attachment: message.attachments?.map((a) => ({
|
|
76
|
+
name: a.filename,
|
|
77
|
+
content: toBase64(a.content),
|
|
78
|
+
})),
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const id = response.messageId ?? response.messageIds?.[0]
|
|
82
|
+
if (!id) throw new MailerError('Brevo returned no messageId', { adapter: 'brevo' })
|
|
83
|
+
return { id }
|
|
84
|
+
} catch (cause) {
|
|
85
|
+
if (cause instanceof MailerError) throw cause
|
|
86
|
+
throw new MailerError('Brevo request failed', { adapter: 'brevo', cause })
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { Resend } from 'resend'
|
|
2
|
+
import * as v from 'valibot'
|
|
3
|
+
import { formatAddress } from '../../core/address.js'
|
|
4
|
+
import { type MailerAdapter, MailerError, type RenderedMessage } from '../../core/port.js'
|
|
5
|
+
import { ResendConfigSchema } from './config.js'
|
|
6
|
+
|
|
7
|
+
/** Minimal structural view of the Resend client we depend on (eases testing). */
|
|
8
|
+
export interface ResendClient {
|
|
9
|
+
emails: {
|
|
10
|
+
send(payload: ResendSendPayload): Promise<{
|
|
11
|
+
data: { id: string } | null
|
|
12
|
+
error: { message: string; name?: string } | null
|
|
13
|
+
}>
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ResendSendPayload {
|
|
18
|
+
from: string
|
|
19
|
+
to: string[]
|
|
20
|
+
subject: string
|
|
21
|
+
html: string
|
|
22
|
+
text: string
|
|
23
|
+
replyTo?: string
|
|
24
|
+
cc?: string[]
|
|
25
|
+
bcc?: string[]
|
|
26
|
+
headers?: Record<string, string>
|
|
27
|
+
tags?: { name: string; value: string }[]
|
|
28
|
+
attachments?: { filename: string; content: Buffer | string; contentType?: string }[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ResendAdapterOptions {
|
|
32
|
+
apiKey: string
|
|
33
|
+
/** Inject a custom/mock client. Defaults to a real `Resend` instance. */
|
|
34
|
+
client?: ResendClient
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function toAttachmentContent(content: Uint8Array | string): Buffer | string {
|
|
38
|
+
return typeof content === 'string' ? content : Buffer.from(content)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function resendAdapter(options: ResendAdapterOptions): MailerAdapter {
|
|
42
|
+
// Validate config early so a missing key fails at construction, not at send().
|
|
43
|
+
const config = v.parse(ResendConfigSchema, { apiKey: options.apiKey })
|
|
44
|
+
const client: ResendClient =
|
|
45
|
+
options.client ?? (new Resend(config.apiKey) as unknown as ResendClient)
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
name: 'resend',
|
|
49
|
+
async send(message: RenderedMessage) {
|
|
50
|
+
const { data, error } = await client.emails.send({
|
|
51
|
+
from: formatAddress(message.from),
|
|
52
|
+
to: message.to.map(formatAddress),
|
|
53
|
+
subject: message.subject,
|
|
54
|
+
html: message.html,
|
|
55
|
+
text: message.text,
|
|
56
|
+
replyTo: message.replyTo ? formatAddress(message.replyTo) : undefined,
|
|
57
|
+
cc: message.cc?.map(formatAddress),
|
|
58
|
+
bcc: message.bcc?.map(formatAddress),
|
|
59
|
+
headers: message.headers,
|
|
60
|
+
tags: message.tags
|
|
61
|
+
? Object.entries(message.tags).map(([name, value]) => ({ name, value }))
|
|
62
|
+
: undefined,
|
|
63
|
+
attachments: message.attachments?.map((a) => ({
|
|
64
|
+
filename: a.filename,
|
|
65
|
+
content: toAttachmentContent(a.content),
|
|
66
|
+
contentType: a.contentType,
|
|
67
|
+
})),
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
if (error) throw new MailerError(error.message, { adapter: 'resend', cause: error })
|
|
71
|
+
if (!data) throw new MailerError('Resend returned no data', { adapter: 'resend' })
|
|
72
|
+
return { id: data.id }
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import * as v from 'valibot'
|
|
2
|
+
|
|
3
|
+
export const SesConfigSchema = v.object({
|
|
4
|
+
/** AWS region. Falls back to the SDK's resolution (e.g. `AWS_REGION`). */
|
|
5
|
+
region: v.optional(v.string()),
|
|
6
|
+
/** Static credentials. Omit to let the SDK resolve them (env, profile, IAM). */
|
|
7
|
+
accessKeyId: v.optional(v.string()),
|
|
8
|
+
secretAccessKey: v.optional(v.string()),
|
|
9
|
+
/** SES configuration set to attach (event publishing, dedicated IPs, …). */
|
|
10
|
+
configurationSetName: v.optional(v.string()),
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
export type SesConfig = v.InferOutput<typeof SesConfigSchema>
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// biome-ignore-all lint/style/useNamingConvention: SESv2's API uses PascalCase keys
|
|
2
|
+
import { SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2'
|
|
3
|
+
import * as v from 'valibot'
|
|
4
|
+
import { formatAddress } from '../../core/address.js'
|
|
5
|
+
import { type MailerAdapter, MailerError, type RenderedMessage } from '../../core/port.js'
|
|
6
|
+
import { SesConfigSchema } from './config.js'
|
|
7
|
+
|
|
8
|
+
/** Minimal structural view of the SESv2 client we depend on (eases testing). */
|
|
9
|
+
export interface SesClientLike {
|
|
10
|
+
send(command: { input: SesSendEmailInput }): Promise<{ MessageId?: string }>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface SesSendEmailInput {
|
|
14
|
+
FromEmailAddress: string
|
|
15
|
+
Destination: { ToAddresses: string[]; CcAddresses?: string[]; BccAddresses?: string[] }
|
|
16
|
+
Content: {
|
|
17
|
+
Simple: {
|
|
18
|
+
Subject: { Data: string }
|
|
19
|
+
Body: { Html: { Data: string }; Text: { Data: string } }
|
|
20
|
+
Headers?: { Name: string; Value: string }[]
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
ReplyToAddresses?: string[]
|
|
24
|
+
EmailTags?: { Name: string; Value: string }[]
|
|
25
|
+
ConfigurationSetName?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SesAdapterOptions {
|
|
29
|
+
/** AWS region. Falls back to the SDK's resolution (e.g. `AWS_REGION`). */
|
|
30
|
+
region?: string
|
|
31
|
+
accessKeyId?: string
|
|
32
|
+
secretAccessKey?: string
|
|
33
|
+
configurationSetName?: string
|
|
34
|
+
/** Inject a custom/mock client. Defaults to a real `SESv2Client`. */
|
|
35
|
+
client?: SesClientLike
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function sesAdapter(options: SesAdapterOptions = {}): MailerAdapter {
|
|
39
|
+
const config = v.parse(SesConfigSchema, {
|
|
40
|
+
region: options.region,
|
|
41
|
+
accessKeyId: options.accessKeyId,
|
|
42
|
+
secretAccessKey: options.secretAccessKey,
|
|
43
|
+
configurationSetName: options.configurationSetName,
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const client: SesClientLike =
|
|
47
|
+
options.client ??
|
|
48
|
+
(new SESv2Client({
|
|
49
|
+
region: config.region,
|
|
50
|
+
credentials:
|
|
51
|
+
config.accessKeyId && config.secretAccessKey
|
|
52
|
+
? { accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey }
|
|
53
|
+
: undefined,
|
|
54
|
+
}) as unknown as SesClientLike)
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
name: 'ses',
|
|
58
|
+
async send(message: RenderedMessage) {
|
|
59
|
+
// SES's Simple content can't carry attachments — that needs a raw MIME
|
|
60
|
+
// message, which this adapter intentionally doesn't build. Fail loudly.
|
|
61
|
+
if (message.attachments?.length) {
|
|
62
|
+
throw new MailerError('SES adapter does not support attachments (requires raw MIME)', {
|
|
63
|
+
adapter: 'ses',
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const input: SesSendEmailInput = {
|
|
68
|
+
FromEmailAddress: formatAddress(message.from),
|
|
69
|
+
Destination: {
|
|
70
|
+
ToAddresses: message.to.map(formatAddress),
|
|
71
|
+
CcAddresses: message.cc?.map(formatAddress),
|
|
72
|
+
BccAddresses: message.bcc?.map(formatAddress),
|
|
73
|
+
},
|
|
74
|
+
Content: {
|
|
75
|
+
Simple: {
|
|
76
|
+
Subject: { Data: message.subject },
|
|
77
|
+
Body: { Html: { Data: message.html }, Text: { Data: message.text } },
|
|
78
|
+
Headers: message.headers
|
|
79
|
+
? Object.entries(message.headers).map(([Name, Value]) => ({ Name, Value }))
|
|
80
|
+
: undefined,
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
ReplyToAddresses: message.replyTo ? [formatAddress(message.replyTo)] : undefined,
|
|
84
|
+
EmailTags: message.tags
|
|
85
|
+
? Object.entries(message.tags).map(([Name, Value]) => ({ Name, Value }))
|
|
86
|
+
: undefined,
|
|
87
|
+
ConfigurationSetName: config.configurationSetName,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const result = await client.send(
|
|
92
|
+
new SendEmailCommand(input) as { input: SesSendEmailInput },
|
|
93
|
+
)
|
|
94
|
+
if (!result.MessageId)
|
|
95
|
+
throw new MailerError('SES returned no MessageId', { adapter: 'ses' })
|
|
96
|
+
return { id: result.MessageId }
|
|
97
|
+
} catch (cause) {
|
|
98
|
+
if (cause instanceof MailerError) throw cause
|
|
99
|
+
throw new MailerError('SES request failed', { adapter: 'ses', cause })
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../capability.schema.json",
|
|
3
|
+
"name": "storage",
|
|
4
|
+
"description": "Object storage behind a swappable port: put/get/delete/exists plus signed URLs. Adapters for S3, Cloudflare R2, Google Cloud Storage and the local filesystem.",
|
|
5
|
+
"port": "src/core/port.ts",
|
|
6
|
+
"factory": "src/index.ts",
|
|
7
|
+
"defaultAdapter": "s3",
|
|
8
|
+
"adapters": {
|
|
9
|
+
"s3": {
|
|
10
|
+
"deps": ["@aws-sdk/client-s3", "@aws-sdk/s3-request-presigner"],
|
|
11
|
+
"env": ["S3_BUCKET", "S3_REGION", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"],
|
|
12
|
+
"files": ["src/adapters/s3"]
|
|
13
|
+
},
|
|
14
|
+
"r2": {
|
|
15
|
+
"deps": ["@aws-sdk/client-s3", "@aws-sdk/s3-request-presigner"],
|
|
16
|
+
"env": ["R2_BUCKET", "R2_ACCOUNT_ID", "R2_ACCESS_KEY_ID", "R2_SECRET_ACCESS_KEY"],
|
|
17
|
+
"files": ["src/adapters/r2", "src/adapters/s3"]
|
|
18
|
+
},
|
|
19
|
+
"gcs": {
|
|
20
|
+
"deps": ["@google-cloud/storage"],
|
|
21
|
+
"env": ["GCS_BUCKET", "GOOGLE_CLOUD_PROJECT"],
|
|
22
|
+
"files": ["src/adapters/gcs"]
|
|
23
|
+
},
|
|
24
|
+
"local": {
|
|
25
|
+
"deps": [],
|
|
26
|
+
"env": ["STORAGE_LOCAL_DIR"],
|
|
27
|
+
"files": ["src/adapters/local"]
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"sharedDeps": ["valibot"],
|
|
31
|
+
"sharedFiles": ["src/core", "src/index.ts"]
|
|
32
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Patterns
|
|
2
|
+
|
|
3
|
+
Foundational, framework-coupled **patterns** the `bootstrap` skill vendors into a
|
|
4
|
+
freshly scaffolded app — the counterpart to `packages/` (swappable capabilities).
|
|
5
|
+
|
|
6
|
+
A *capability* is a provider behind a port (mailer, storage, …), swappable by
|
|
7
|
+
changing one line. A *pattern* is a foundation you don't swap but always set up
|
|
8
|
+
the same way: tRPC wiring, the better-auth instance, the Drizzle client. They are
|
|
9
|
+
**framework-coupled** (currently `tanstack-start`, mirrored from the reference
|
|
10
|
+
base apps) and depend on each other.
|
|
11
|
+
|
|
12
|
+
**The code lives in the base apps, not here.** `patterns/` is a pure *manifest
|
|
13
|
+
layer*: each `pattern.json` describes a foundation (how to detect it, its deps,
|
|
14
|
+
env, framework, dependencies) and lists the files that make it up — by pointing
|
|
15
|
+
**into the base apps** (`apps/tanstack-base`, `apps/next-base`), the single source
|
|
16
|
+
of truth. No code is duplicated.
|
|
17
|
+
|
|
18
|
+
Each pattern is `<name>/pattern.json` (see `../pattern.schema.json`).
|
|
19
|
+
`_baseline/` is special: real always-applied config files (Biome, tsconfig, env
|
|
20
|
+
skeleton, the `# Author` README footer) that a standalone fork needs but the base
|
|
21
|
+
apps don't carry on their own (they inherit the monorepo's Biome).
|
|
22
|
+
|
|
23
|
+
## How the skills use these
|
|
24
|
+
|
|
25
|
+
The manifests drive two flows:
|
|
26
|
+
|
|
27
|
+
- **bootstrap — create mode** (empty folder): fork a base app, then *strip* every
|
|
28
|
+
foundation/capability the user didn't pick, using each manifest's `files`/`deps`/
|
|
29
|
+
`env` to know its exact footprint.
|
|
30
|
+
- **bootstrap — existing project / add-capability**: match each manifest's `detect`
|
|
31
|
+
against the project → the opt-in set, then *vendor* the listed files (copied from
|
|
32
|
+
the base apps) + deps + env, wire `integratesWith` when both sides are opt-in, and
|
|
33
|
+
pull required `capabilities`. A pattern not referenced is never pulled.
|
|
34
|
+
|
|
35
|
+
## Available patterns
|
|
36
|
+
|
|
37
|
+
- **drizzle** — Drizzle ORM + drizzle-kit (Postgres). Client, schema barrel,
|
|
38
|
+
cursor pagination, seed harness.
|
|
39
|
+
- **better-auth** — better-auth v1 with the Drizzle adapter. Email+password,
|
|
40
|
+
verification, optional Google OAuth, rate limiting, auth tables, client +
|
|
41
|
+
session helpers, route guard. `dependsOn` drizzle; needs the mailer + email-kit
|
|
42
|
+
capabilities.
|
|
43
|
+
- **trpc** — tRPC v11 + TanStack React Query. Context, procedure tiers, error
|
|
44
|
+
formatter, client + SSR caller, fetch handler. `dependsOn` drizzle,
|
|
45
|
+
`integratesWith` better-auth.
|
|
46
|
+
- **data-table** — headless tables with TanStack Table (table + skeleton
|
|
47
|
+
primitives, DataTable, InfiniteDataTable, SortableHeader). `framework: agnostic`
|
|
48
|
+
— works in both Next and TanStack Start.
|
|
49
|
+
|
|
50
|
+
Next.js variants (App Router) of the framework-coupled patterns:
|
|
51
|
+
|
|
52
|
+
- **better-auth-next** — better-auth with `next/headers` session, `toNextJsHandler`
|
|
53
|
+
catch-all, server-component guards (`requireAuth`).
|
|
54
|
+
- **trpc-next** — tRPC with the classic `api.x.useQuery` hooks (createTRPCReact) +
|
|
55
|
+
RSC hydration. `integratesWith` better-auth-next.
|
|
56
|
+
|
|
57
|
+
bootstrap picks the variant matching the project's framework: `trpc`/`better-auth`
|
|
58
|
+
for TanStack Start, `trpc-next`/`better-auth-next` for Next.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Author
|
|
2
|
+
|
|
3
|
+
Alfred MOUELLE | FullStack Developer
|
|
4
|
+
|
|
5
|
+
[](https://comeup.com/@alfredmouelle)
|
|
6
|
+
[](https://github.com/alfredmouelle)
|
|
7
|
+
[](https://www.linkedin.com/in/alfredmouelle)
|
|
8
|
+
[](https://twitter.com/kali47_)
|
|
9
|
+
[](mailto:alfredmouelle@gmail.com)
|
|
10
|
+
[](https://alfredmouelle.com)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
|
|
3
|
+
"root": true,
|
|
4
|
+
"files": {
|
|
5
|
+
"includes": [
|
|
6
|
+
"**",
|
|
7
|
+
"!public",
|
|
8
|
+
"!**/dist",
|
|
9
|
+
"!**/.next",
|
|
10
|
+
"!**/.output",
|
|
11
|
+
"!**/.nitro",
|
|
12
|
+
"!**/routeTree.gen.ts",
|
|
13
|
+
"!**/components/ui"
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
"vcs": {
|
|
17
|
+
"enabled": true,
|
|
18
|
+
"useIgnoreFile": true,
|
|
19
|
+
"clientKind": "git"
|
|
20
|
+
},
|
|
21
|
+
"assist": {
|
|
22
|
+
"enabled": true,
|
|
23
|
+
"actions": {
|
|
24
|
+
"source": {
|
|
25
|
+
"organizeImports": "on",
|
|
26
|
+
"useSortedAttributes": "on"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"formatter": {
|
|
31
|
+
"enabled": true,
|
|
32
|
+
"indentWidth": 2,
|
|
33
|
+
"indentStyle": "space",
|
|
34
|
+
"lineWidth": 100
|
|
35
|
+
},
|
|
36
|
+
"linter": {
|
|
37
|
+
"enabled": true,
|
|
38
|
+
"domains": {
|
|
39
|
+
"react": "recommended",
|
|
40
|
+
"test": "recommended"
|
|
41
|
+
},
|
|
42
|
+
"rules": {
|
|
43
|
+
"suspicious": {
|
|
44
|
+
"noArrayIndexKey": "off",
|
|
45
|
+
"noConsole": {
|
|
46
|
+
"level": "warn",
|
|
47
|
+
"options": { "allow": ["warn", "error"] }
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"complexity": {
|
|
51
|
+
"noExcessiveCognitiveComplexity": "warn"
|
|
52
|
+
},
|
|
53
|
+
"correctness": {
|
|
54
|
+
"noUndeclaredDependencies": "error"
|
|
55
|
+
},
|
|
56
|
+
"style": {
|
|
57
|
+
"noInferrableTypes": "error",
|
|
58
|
+
"useNamingConvention": {
|
|
59
|
+
"level": "warn",
|
|
60
|
+
"options": {
|
|
61
|
+
"strictCase": false,
|
|
62
|
+
"conventions": [{ "selector": { "kind": "objectLiteralProperty" }, "match": ".*" }]
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
"useConsistentArrayType": {
|
|
66
|
+
"level": "error",
|
|
67
|
+
"options": { "syntax": "shorthand" }
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
"a11y": {
|
|
71
|
+
"noStaticElementInteractions": "off",
|
|
72
|
+
"useKeyWithClickEvents": "off"
|
|
73
|
+
},
|
|
74
|
+
"nursery": {
|
|
75
|
+
"useSortedClasses": {
|
|
76
|
+
"level": "warn",
|
|
77
|
+
"fix": "safe",
|
|
78
|
+
"options": {
|
|
79
|
+
"functions": ["clsx", "cva", "cn"]
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
"html": {
|
|
86
|
+
"formatter": {
|
|
87
|
+
"enabled": true
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
"javascript": {
|
|
91
|
+
"assist": {
|
|
92
|
+
"enabled": true
|
|
93
|
+
},
|
|
94
|
+
"formatter": {
|
|
95
|
+
"enabled": true,
|
|
96
|
+
"quoteStyle": "single",
|
|
97
|
+
"semicolons": "asNeeded",
|
|
98
|
+
"trailingCommas": "all"
|
|
99
|
+
},
|
|
100
|
+
"linter": {
|
|
101
|
+
"enabled": true
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
"css": {
|
|
105
|
+
"assist": {
|
|
106
|
+
"enabled": true
|
|
107
|
+
},
|
|
108
|
+
"formatter": {
|
|
109
|
+
"enabled": true
|
|
110
|
+
},
|
|
111
|
+
"linter": {
|
|
112
|
+
"enabled": true
|
|
113
|
+
},
|
|
114
|
+
"parser": {
|
|
115
|
+
"cssModules": true,
|
|
116
|
+
"tailwindDirectives": true
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { createEnv } from '@t3-oss/env-core';
|
|
2
|
+
import * as v from 'valibot';
|
|
3
|
+
|
|
4
|
+
/** Makes a var required only in production (optional in dev/test). */
|
|
5
|
+
export const requiredInProduction = <T extends v.GenericSchema>(schema: T) =>
|
|
6
|
+
process.env.NODE_ENV === 'production' ? schema : v.optional(schema);
|
|
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
|
+
*/
|
|
13
|
+
export const env = createEnv({
|
|
14
|
+
shared: {
|
|
15
|
+
NODE_ENV: v.optional(
|
|
16
|
+
v.picklist(['development', 'test', 'production']),
|
|
17
|
+
'development',
|
|
18
|
+
),
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
server: {
|
|
22
|
+
// Extended by patterns/capabilities (DATABASE_URL, BETTER_AUTH_SECRET, …).
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
runtimeEnv: {
|
|
26
|
+
NODE_ENV: process.env.NODE_ENV,
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
|
|
30
|
+
emptyStringAsUndefined: true,
|
|
31
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"include": ["**/*.ts", "**/*.tsx"],
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"target": "ES2022",
|
|
5
|
+
"jsx": "react-jsx",
|
|
6
|
+
"module": "ESNext",
|
|
7
|
+
"paths": {
|
|
8
|
+
"~/*": ["./src/*"]
|
|
9
|
+
},
|
|
10
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
11
|
+
"types": ["vite/client"],
|
|
12
|
+
|
|
13
|
+
/* Bundler mode */
|
|
14
|
+
"moduleResolution": "bundler",
|
|
15
|
+
"allowImportingTsExtensions": true,
|
|
16
|
+
"verbatimModuleSyntax": true,
|
|
17
|
+
"noEmit": true,
|
|
18
|
+
|
|
19
|
+
/* Linting */
|
|
20
|
+
"skipLibCheck": true,
|
|
21
|
+
"strict": true,
|
|
22
|
+
"noUnusedLocals": true,
|
|
23
|
+
"noUnusedParameters": true,
|
|
24
|
+
"noFallthroughCasesInSwitch": true,
|
|
25
|
+
"noUncheckedSideEffectImports": true
|
|
26
|
+
}
|
|
27
|
+
}
|