create-skit 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 +36 -0
- package/bin/create-skit.mjs +1064 -0
- package/lib/module-application.mjs +281 -0
- package/lib/module-resolver.mjs +179 -0
- package/modules/README.md +22 -0
- package/modules/ai-dx/files/AGENTS.md +116 -0
- package/modules/ai-dx/files/ARCHITECTURE.md +103 -0
- package/modules/ai-dx/module.json +14 -0
- package/modules/ai-dx-claude/files/CLAUDE.md +8 -0
- package/modules/ai-dx-claude/module.json +13 -0
- package/modules/ai-dx-cursor/files/.cursor/rules/auth.mdc +53 -0
- package/modules/ai-dx-cursor/files/.cursor/rules/database.mdc +48 -0
- package/modules/ai-dx-cursor/files/.cursor/rules/env.mdc +43 -0
- package/modules/ai-dx-cursor/files/.cursor/rules/nextjs.mdc +58 -0
- package/modules/ai-dx-cursor/files/.cursor/rules/project.mdc +33 -0
- package/modules/ai-dx-cursor/files/.cursor/rules/testing.mdc +55 -0
- package/modules/ai-dx-cursor/module.json +18 -0
- package/modules/ai-dx-gemini/files/.gemini/GEMINI.md +5 -0
- package/modules/ai-dx-gemini/module.json +13 -0
- package/modules/auth-core/module.json +8 -0
- package/modules/auth-github/module.json +20 -0
- package/modules/billing-polar/module.json +20 -0
- package/modules/billing-stripe/module.json +23 -0
- package/modules/dashboard-shell/files/src/app/globals.css +756 -0
- package/modules/dashboard-shell/files/src/app/settings/page.tsx +67 -0
- package/modules/dashboard-shell/module.json +11 -0
- package/modules/db-pg/module.json +21 -0
- package/modules/db-postgresjs/module.json +21 -0
- package/modules/deploy-docker/files/.dockerignore +19 -0
- package/modules/deploy-docker/files/Dockerfile +25 -0
- package/modules/deploy-docker/module.json +11 -0
- package/modules/email-resend/module.json +21 -0
- package/modules/quality-baseline/module.json +8 -0
- package/modules/testing-baseline/module.json +8 -0
- package/package.json +40 -0
- package/presets/README.md +12 -0
- package/presets/blank.json +67 -0
- package/presets/dashboard.json +67 -0
- package/templates/base-web/.env.example +17 -0
- package/templates/base-web/.github/workflows/ci.yml +34 -0
- package/templates/base-web/.husky/pre-commit +3 -0
- package/templates/base-web/.husky/pre-push +3 -0
- package/templates/base-web/.prettierignore +3 -0
- package/templates/base-web/README.md +42 -0
- package/templates/base-web/drizzle.config.ts +16 -0
- package/templates/base-web/eslint.config.mjs +127 -0
- package/templates/base-web/manifest.json +5 -0
- package/templates/base-web/next-env.d.ts +4 -0
- package/templates/base-web/next.config.ts +5 -0
- package/templates/base-web/package.json +75 -0
- package/templates/base-web/playwright.config.ts +21 -0
- package/templates/base-web/prettier.config.mjs +9 -0
- package/templates/base-web/proxy.ts +23 -0
- package/templates/base-web/src/app/api/auth/[...all]/route.ts +5 -0
- package/templates/base-web/src/app/api/billing/checkout/route.ts +26 -0
- package/templates/base-web/src/app/api/billing/portal/route.ts +25 -0
- package/templates/base-web/src/app/api/email/test/route.ts +28 -0
- package/templates/base-web/src/app/api/webhooks/polar/route.ts +5 -0
- package/templates/base-web/src/app/api/webhooks/stripe/route.ts +5 -0
- package/templates/base-web/src/app/billing/page.tsx +55 -0
- package/templates/base-web/src/app/dashboard/page.tsx +15 -0
- package/templates/base-web/src/app/email/page.tsx +46 -0
- package/templates/base-web/src/app/error.tsx +27 -0
- package/templates/base-web/src/app/globals.css +534 -0
- package/templates/base-web/src/app/layout.tsx +19 -0
- package/templates/base-web/src/app/llms-full.txt/route.ts +158 -0
- package/templates/base-web/src/app/llms.txt/route.ts +59 -0
- package/templates/base-web/src/app/loading.tsx +24 -0
- package/templates/base-web/src/app/not-found.tsx +16 -0
- package/templates/base-web/src/app/page.tsx +5 -0
- package/templates/base-web/src/app/sign-in/page.tsx +14 -0
- package/templates/base-web/src/app/sign-up/page.tsx +14 -0
- package/templates/base-web/src/components/auth/email-auth-form.test.tsx +40 -0
- package/templates/base-web/src/components/auth/email-auth-form.tsx +128 -0
- package/templates/base-web/src/components/auth/sign-out-button.tsx +29 -0
- package/templates/base-web/src/db/index.ts +16 -0
- package/templates/base-web/src/db/schema/auth.ts +4 -0
- package/templates/base-web/src/db/schema/index.ts +2 -0
- package/templates/base-web/src/db/schema/projects.ts +17 -0
- package/templates/base-web/src/db/seeds/index.ts +32 -0
- package/templates/base-web/src/lib/auth-client.ts +5 -0
- package/templates/base-web/src/lib/auth-session.ts +21 -0
- package/templates/base-web/src/lib/auth.ts +23 -0
- package/templates/base-web/src/lib/billing/index.ts +37 -0
- package/templates/base-web/src/lib/billing/providers/polar.ts +80 -0
- package/templates/base-web/src/lib/billing/providers/stripe.ts +77 -0
- package/templates/base-web/src/lib/billing/types.ts +25 -0
- package/templates/base-web/src/lib/email/index.ts +19 -0
- package/templates/base-web/src/lib/email/templates.test.ts +12 -0
- package/templates/base-web/src/lib/email/templates.ts +40 -0
- package/templates/base-web/src/lib/env.ts +83 -0
- package/templates/base-web/tests/e2e/home.spec.ts +8 -0
- package/templates/base-web/tsconfig.json +34 -0
- package/templates/base-web/vitest.config.ts +19 -0
- package/templates/blank/.env.example +16 -0
- package/templates/blank/.github/workflows/ci.yml +34 -0
- package/templates/blank/.husky/pre-commit +3 -0
- package/templates/blank/.husky/pre-push +3 -0
- package/templates/blank/.prettierignore +3 -0
- package/templates/blank/drizzle.config.ts +16 -0
- package/templates/blank/eslint.config.mjs +127 -0
- package/templates/blank/next-env.d.ts +4 -0
- package/templates/blank/next.config.ts +5 -0
- package/templates/blank/package.json +75 -0
- package/templates/blank/playwright.config.ts +21 -0
- package/templates/blank/prettier.config.mjs +9 -0
- package/templates/blank/proxy.ts +28 -0
- package/templates/blank/src/app/api/auth/[...all]/route.ts +5 -0
- package/templates/blank/src/app/api/billing/checkout/route.ts +26 -0
- package/templates/blank/src/app/api/billing/portal/route.ts +25 -0
- package/templates/blank/src/app/api/email/test/route.ts +28 -0
- package/templates/blank/src/app/api/webhooks/polar/route.ts +5 -0
- package/templates/blank/src/app/api/webhooks/stripe/route.ts +5 -0
- package/templates/blank/src/app/billing/page.tsx +70 -0
- package/templates/blank/src/app/email/page.tsx +46 -0
- package/templates/blank/src/app/globals.css +394 -0
- package/templates/blank/src/app/layout.tsx +19 -0
- package/templates/blank/src/app/page.tsx +23 -0
- package/templates/blank/src/app/sign-in/page.tsx +18 -0
- package/templates/blank/src/app/sign-up/page.tsx +18 -0
- package/templates/blank/src/components/auth/email-auth-form.test.tsx +39 -0
- package/templates/blank/src/components/auth/email-auth-form.tsx +109 -0
- package/templates/blank/src/components/auth/sign-out-button.tsx +29 -0
- package/templates/blank/src/db/index.ts +16 -0
- package/templates/blank/src/db/schema/auth.ts +4 -0
- package/templates/blank/src/db/schema/index.ts +2 -0
- package/templates/blank/src/db/schema/projects.ts +17 -0
- package/templates/blank/src/db/seeds/index.ts +28 -0
- package/templates/blank/src/lib/auth-client.ts +5 -0
- package/templates/blank/src/lib/auth-session.ts +11 -0
- package/templates/blank/src/lib/auth.ts +23 -0
- package/templates/blank/src/lib/billing/index.ts +37 -0
- package/templates/blank/src/lib/billing/providers/polar.ts +80 -0
- package/templates/blank/src/lib/billing/providers/stripe.ts +77 -0
- package/templates/blank/src/lib/billing/types.ts +25 -0
- package/templates/blank/src/lib/email/index.ts +19 -0
- package/templates/blank/src/lib/email/templates.test.ts +15 -0
- package/templates/blank/src/lib/email/templates.ts +40 -0
- package/templates/blank/src/lib/env.ts +80 -0
- package/templates/blank/tsconfig.json +34 -0
- package/templates/blank/vitest.config.ts +19 -0
- package/templates/dashboard/.env.example +16 -0
- package/templates/dashboard/.github/workflows/ci.yml +34 -0
- package/templates/dashboard/.husky/pre-commit +3 -0
- package/templates/dashboard/.husky/pre-push +3 -0
- package/templates/dashboard/.prettierignore +3 -0
- package/templates/dashboard/drizzle.config.ts +16 -0
- package/templates/dashboard/eslint.config.mjs +127 -0
- package/templates/dashboard/next-env.d.ts +4 -0
- package/templates/dashboard/next.config.ts +5 -0
- package/templates/dashboard/package.json +75 -0
- package/templates/dashboard/playwright.config.ts +21 -0
- package/templates/dashboard/prettier.config.mjs +9 -0
- package/templates/dashboard/proxy.ts +36 -0
- package/templates/dashboard/src/app/api/auth/[...all]/route.ts +5 -0
- package/templates/dashboard/src/app/api/billing/checkout/route.ts +26 -0
- package/templates/dashboard/src/app/api/billing/portal/route.ts +25 -0
- package/templates/dashboard/src/app/api/email/test/route.ts +28 -0
- package/templates/dashboard/src/app/api/webhooks/polar/route.ts +5 -0
- package/templates/dashboard/src/app/api/webhooks/stripe/route.ts +5 -0
- package/templates/dashboard/src/app/billing/layout.tsx +22 -0
- package/templates/dashboard/src/app/billing/page.tsx +73 -0
- package/templates/dashboard/src/app/dashboard/layout.tsx +22 -0
- package/templates/dashboard/src/app/dashboard/page.tsx +104 -0
- package/templates/dashboard/src/app/email/layout.tsx +22 -0
- package/templates/dashboard/src/app/email/page.tsx +54 -0
- package/templates/dashboard/src/app/globals.css +1357 -0
- package/templates/dashboard/src/app/layout.tsx +25 -0
- package/templates/dashboard/src/app/page.tsx +154 -0
- package/templates/dashboard/src/app/settings/layout.tsx +22 -0
- package/templates/dashboard/src/app/settings/page.tsx +85 -0
- package/templates/dashboard/src/app/sign-in/page.tsx +47 -0
- package/templates/dashboard/src/app/sign-up/page.tsx +47 -0
- package/templates/dashboard/src/components/auth/email-auth-form.test.tsx +39 -0
- package/templates/dashboard/src/components/auth/email-auth-form.tsx +160 -0
- package/templates/dashboard/src/components/auth/sign-out-button.tsx +29 -0
- package/templates/dashboard/src/components/dashboard/shell.tsx +158 -0
- package/templates/dashboard/src/db/index.ts +16 -0
- package/templates/dashboard/src/db/schema/auth.ts +4 -0
- package/templates/dashboard/src/db/schema/index.ts +2 -0
- package/templates/dashboard/src/db/schema/projects.ts +17 -0
- package/templates/dashboard/src/db/seeds/index.ts +28 -0
- package/templates/dashboard/src/lib/auth-client.ts +5 -0
- package/templates/dashboard/src/lib/auth-session.ts +11 -0
- package/templates/dashboard/src/lib/auth.ts +41 -0
- package/templates/dashboard/src/lib/billing/index.ts +37 -0
- package/templates/dashboard/src/lib/billing/providers/polar.ts +80 -0
- package/templates/dashboard/src/lib/billing/providers/stripe.ts +77 -0
- package/templates/dashboard/src/lib/billing/types.ts +25 -0
- package/templates/dashboard/src/lib/email/index.ts +19 -0
- package/templates/dashboard/src/lib/email/templates.test.ts +15 -0
- package/templates/dashboard/src/lib/email/templates.ts +40 -0
- package/templates/dashboard/src/lib/env.ts +88 -0
- package/templates/dashboard/tsconfig.json +34 -0
- package/templates/dashboard/vitest.config.ts +19 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { usePathname } from "next/navigation";
|
|
5
|
+
import { useState, useEffect } from "react";
|
|
6
|
+
|
|
7
|
+
import { SignOutButton } from "@/components/auth/sign-out-button";
|
|
8
|
+
|
|
9
|
+
type DashboardShellProps = {
|
|
10
|
+
user: { name: string; email: string };
|
|
11
|
+
children: React.ReactNode;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const navItems = [
|
|
15
|
+
{
|
|
16
|
+
href: "/dashboard",
|
|
17
|
+
label: "Overview",
|
|
18
|
+
icon: (
|
|
19
|
+
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" width="18" height="18">
|
|
20
|
+
<rect x="2" y="2" width="7" height="7" rx="1.5" />
|
|
21
|
+
<rect x="11" y="2" width="7" height="7" rx="1.5" />
|
|
22
|
+
<rect x="2" y="11" width="7" height="7" rx="1.5" />
|
|
23
|
+
<rect x="11" y="11" width="7" height="7" rx="1.5" />
|
|
24
|
+
</svg>
|
|
25
|
+
),
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
href: "/billing",
|
|
29
|
+
label: "Billing",
|
|
30
|
+
icon: (
|
|
31
|
+
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" width="18" height="18">
|
|
32
|
+
<rect x="2" y="5" width="16" height="11" rx="2" />
|
|
33
|
+
<path d="M2 9h16" />
|
|
34
|
+
<path d="M6 13h2" strokeLinecap="round" />
|
|
35
|
+
</svg>
|
|
36
|
+
),
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
href: "/email",
|
|
40
|
+
label: "Email",
|
|
41
|
+
icon: (
|
|
42
|
+
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" width="18" height="18">
|
|
43
|
+
<rect x="2" y="4" width="16" height="12" rx="2" />
|
|
44
|
+
<path d="M2 7l8 5 8-5" />
|
|
45
|
+
</svg>
|
|
46
|
+
),
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
href: "/settings",
|
|
50
|
+
label: "Settings",
|
|
51
|
+
icon: (
|
|
52
|
+
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" width="18" height="18">
|
|
53
|
+
<circle cx="10" cy="10" r="2.5" />
|
|
54
|
+
<path d="M10 2v2M10 16v2M2 10h2M16 10h2M4.22 4.22l1.42 1.42M14.36 14.36l1.42 1.42M4.22 15.78l1.42-1.42M14.36 5.64l1.42-1.42" strokeLinecap="round" />
|
|
55
|
+
</svg>
|
|
56
|
+
),
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
const SIDEBAR_COLLAPSED_KEY = "dashboard_sidebar_collapsed";
|
|
61
|
+
|
|
62
|
+
export function DashboardShell({ user, children }: DashboardShellProps) {
|
|
63
|
+
const pathname = usePathname();
|
|
64
|
+
const [isCollapsed, setIsCollapsed] = useState(false);
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
const stored = localStorage.getItem(SIDEBAR_COLLAPSED_KEY);
|
|
68
|
+
if (stored !== null) {
|
|
69
|
+
setIsCollapsed(stored === "true");
|
|
70
|
+
}
|
|
71
|
+
}, []);
|
|
72
|
+
|
|
73
|
+
function toggleSidebar() {
|
|
74
|
+
const next = !isCollapsed;
|
|
75
|
+
setIsCollapsed(next);
|
|
76
|
+
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(next));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const initials = (user.name || user.email || "U")
|
|
80
|
+
.split(" ")
|
|
81
|
+
.map((w) => w[0])
|
|
82
|
+
.join("")
|
|
83
|
+
.slice(0, 2)
|
|
84
|
+
.toUpperCase();
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<div className="dashboard-layout">
|
|
88
|
+
<aside className={`sidebar${isCollapsed ? " collapsed" : ""}`}>
|
|
89
|
+
<div className="sidebar-header">
|
|
90
|
+
<div className="sidebar-logo">
|
|
91
|
+
<span className="sidebar-logo-icon">⬡</span>
|
|
92
|
+
<span className="sidebar-logo-text">
|
|
93
|
+
launch<span>frame</span>
|
|
94
|
+
</span>
|
|
95
|
+
</div>
|
|
96
|
+
<button
|
|
97
|
+
type="button"
|
|
98
|
+
className="sidebar-toggle"
|
|
99
|
+
onClick={toggleSidebar}
|
|
100
|
+
aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
|
101
|
+
title={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
|
102
|
+
>
|
|
103
|
+
<svg
|
|
104
|
+
viewBox="0 0 20 20"
|
|
105
|
+
fill="none"
|
|
106
|
+
stroke="currentColor"
|
|
107
|
+
strokeWidth="1.5"
|
|
108
|
+
width="16"
|
|
109
|
+
height="16"
|
|
110
|
+
style={{
|
|
111
|
+
transform: isCollapsed ? "rotate(180deg)" : "none",
|
|
112
|
+
transition: "transform 0.25s ease",
|
|
113
|
+
}}
|
|
114
|
+
>
|
|
115
|
+
<path d="M13 15l-5-5 5-5" strokeLinecap="round" strokeLinejoin="round" />
|
|
116
|
+
</svg>
|
|
117
|
+
</button>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
<div className="sidebar-nav">
|
|
121
|
+
<div className="sidebar-section">
|
|
122
|
+
<span className="sidebar-section-label">Navigation</span>
|
|
123
|
+
</div>
|
|
124
|
+
{navItems.map((item) => (
|
|
125
|
+
<Link
|
|
126
|
+
key={item.href}
|
|
127
|
+
href={item.href}
|
|
128
|
+
className={`sidebar-link${pathname === item.href ? " active" : ""}`}
|
|
129
|
+
title={isCollapsed ? item.label : undefined}
|
|
130
|
+
>
|
|
131
|
+
<span className="sidebar-link-icon">{item.icon}</span>
|
|
132
|
+
<span className="sidebar-link-label">{item.label}</span>
|
|
133
|
+
</Link>
|
|
134
|
+
))}
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
<div className="sidebar-spacer" />
|
|
138
|
+
|
|
139
|
+
<div className="sidebar-footer">
|
|
140
|
+
<div className="sidebar-user">
|
|
141
|
+
<div className="sidebar-avatar">{initials}</div>
|
|
142
|
+
<div className="sidebar-user-info">
|
|
143
|
+
<div className="sidebar-user-name">{user.name || "User"}</div>
|
|
144
|
+
<div className="sidebar-user-email">{user.email}</div>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
<div className="sidebar-signout">
|
|
148
|
+
<SignOutButton />
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
</aside>
|
|
152
|
+
|
|
153
|
+
<main className="dashboard-main">
|
|
154
|
+
{children}
|
|
155
|
+
</main>
|
|
156
|
+
</div>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
|
|
3
|
+
import { drizzle } from "__DRIZZLE_DRIVER_IMPORT__";
|
|
4
|
+
__DATABASE_CLIENT_IMPORT__
|
|
5
|
+
|
|
6
|
+
import { env } from "@/lib/env";
|
|
7
|
+
|
|
8
|
+
__DATABASE_GLOBAL_BLOCK__
|
|
9
|
+
|
|
10
|
+
const client = __DATABASE_CLIENT_EXPRESSION__;
|
|
11
|
+
|
|
12
|
+
if (env.NODE_ENV !== "production") {
|
|
13
|
+
__DATABASE_DEV_CACHE__
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const db = __DATABASE_DRIZZLE_EXPRESSION__;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
|
2
|
+
|
|
3
|
+
export const projects = pgTable("projects", {
|
|
4
|
+
id: text("id").primaryKey(),
|
|
5
|
+
name: text("name").notNull(),
|
|
6
|
+
slug: text("slug").notNull().unique(),
|
|
7
|
+
createdAt: timestamp("created_at", {
|
|
8
|
+
withTimezone: true
|
|
9
|
+
})
|
|
10
|
+
.defaultNow()
|
|
11
|
+
.notNull(),
|
|
12
|
+
updatedAt: timestamp("updated_at", {
|
|
13
|
+
withTimezone: true
|
|
14
|
+
})
|
|
15
|
+
.defaultNow()
|
|
16
|
+
.notNull()
|
|
17
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { db } from "@/db";
|
|
2
|
+
import { projects } from "@/db/schema";
|
|
3
|
+
|
|
4
|
+
const shouldSeedDemoData = ["yes"].includes("__SEED_DEMO_DATA__");
|
|
5
|
+
|
|
6
|
+
async function main() {
|
|
7
|
+
if (!shouldSeedDemoData) {
|
|
8
|
+
console.log("Demo seed data disabled.");
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
await db
|
|
13
|
+
.insert(projects)
|
|
14
|
+
.values({
|
|
15
|
+
id: "project-demo",
|
|
16
|
+
name: "Demo Project",
|
|
17
|
+
slug: "demo-project"
|
|
18
|
+
})
|
|
19
|
+
.onConflictDoNothing();
|
|
20
|
+
|
|
21
|
+
console.log("Database seed complete.");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
main().catch((error) => {
|
|
25
|
+
console.error("Database seed failed.");
|
|
26
|
+
console.error(error);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
|
|
3
|
+
import { betterAuth } from "better-auth";
|
|
4
|
+
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
|
5
|
+
|
|
6
|
+
import { db } from "../db";
|
|
7
|
+
import { env } from "./env";
|
|
8
|
+
import * as schema from "../db/schema";
|
|
9
|
+
|
|
10
|
+
export const auth = betterAuth({
|
|
11
|
+
appName: "__PROJECT_NAME__",
|
|
12
|
+
baseURL: env.BETTER_AUTH_URL,
|
|
13
|
+
secret: env.BETTER_AUTH_SECRET,
|
|
14
|
+
trustedOrigins: [env.NEXT_PUBLIC_APP_URL],
|
|
15
|
+
database: drizzleAdapter(db, {
|
|
16
|
+
provider: "pg",
|
|
17
|
+
schema
|
|
18
|
+
}),
|
|
19
|
+
emailAndPassword: {
|
|
20
|
+
enabled: true,
|
|
21
|
+
autoSignIn: true
|
|
22
|
+
},
|
|
23
|
+
socialProviders: {
|
|
24
|
+
...(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET
|
|
25
|
+
? {
|
|
26
|
+
github: {
|
|
27
|
+
clientId: env.GITHUB_CLIENT_ID,
|
|
28
|
+
clientSecret: env.GITHUB_CLIENT_SECRET
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
: {}),
|
|
32
|
+
...(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET
|
|
33
|
+
? {
|
|
34
|
+
google: {
|
|
35
|
+
clientId: env.GOOGLE_CLIENT_ID,
|
|
36
|
+
clientSecret: env.GOOGLE_CLIENT_SECRET
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
: {})
|
|
40
|
+
}
|
|
41
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
|
|
3
|
+
import { env } from "@/lib/env";
|
|
4
|
+
|
|
5
|
+
__BILLING_PROVIDER_IMPORTS__
|
|
6
|
+
|
|
7
|
+
import type { BillingProvider, BillingProviderName } from "./types";
|
|
8
|
+
|
|
9
|
+
export function getBillingProviderName(input?: string): BillingProviderName {
|
|
10
|
+
if (input === "stripe" || input === "polar") {
|
|
11
|
+
return input;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (env.BILLING_PROVIDER === "stripe" || env.BILLING_PROVIDER === "polar") {
|
|
15
|
+
return env.BILLING_PROVIDER;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (env.BILLING_PROVIDER === "both") {
|
|
19
|
+
return "stripe";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
throw new Error("Billing is disabled for this project.");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getBillingProvider(input?: string): BillingProvider {
|
|
26
|
+
const provider = getBillingProviderName(input);
|
|
27
|
+
const billingProviderFactories: Partial<Record<BillingProviderName, () => BillingProvider>> = {
|
|
28
|
+
__BILLING_PROVIDER_FACTORIES__
|
|
29
|
+
};
|
|
30
|
+
const factory = billingProviderFactories[provider];
|
|
31
|
+
|
|
32
|
+
if (!factory) {
|
|
33
|
+
throw new Error(`Billing provider is not installed: ${provider}.`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return factory();
|
|
37
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
|
|
3
|
+
import { env } from "@/lib/env";
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
BillingProvider,
|
|
7
|
+
CheckoutSessionInput,
|
|
8
|
+
PortalSessionInput,
|
|
9
|
+
WebhookInput
|
|
10
|
+
} from "../types";
|
|
11
|
+
|
|
12
|
+
function getPolarApiBaseUrl() {
|
|
13
|
+
return env.POLAR_SERVER === "production"
|
|
14
|
+
? "https://api.polar.sh/v1"
|
|
15
|
+
: "https://sandbox-api.polar.sh/v1";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function polarFetch<T>(pathname: string, body: Record<string, unknown>) {
|
|
19
|
+
if (!env.POLAR_ACCESS_TOKEN) {
|
|
20
|
+
throw new Error("POLAR_ACCESS_TOKEN is required for Polar billing.");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const response = await fetch(`${getPolarApiBaseUrl()}${pathname}`, {
|
|
24
|
+
method: "POST",
|
|
25
|
+
headers: {
|
|
26
|
+
Authorization: `Bearer ${env.POLAR_ACCESS_TOKEN}`,
|
|
27
|
+
"Content-Type": "application/json"
|
|
28
|
+
},
|
|
29
|
+
body: JSON.stringify(body)
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
if (!response.ok) {
|
|
33
|
+
const message = await response.text();
|
|
34
|
+
throw new Error(`Polar request failed: ${message}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return (await response.json()) as T;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class PolarBillingProvider implements BillingProvider {
|
|
41
|
+
async createCheckoutSession(input: CheckoutSessionInput) {
|
|
42
|
+
if (!env.POLAR_PRODUCT_ID) {
|
|
43
|
+
throw new Error("POLAR_PRODUCT_ID is required for Polar checkout.");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const checkout = await polarFetch<{ url?: string }>("/checkouts", {
|
|
47
|
+
products: [env.POLAR_PRODUCT_ID],
|
|
48
|
+
external_customer_id: input.userId,
|
|
49
|
+
customer_email: input.customerEmail,
|
|
50
|
+
success_url: input.successUrl,
|
|
51
|
+
metadata: {
|
|
52
|
+
userId: input.userId
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
if (!checkout.url) {
|
|
57
|
+
throw new Error("Polar did not return a checkout URL.");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { url: checkout.url };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async createCustomerPortalSession(input: PortalSessionInput) {
|
|
64
|
+
const session = await polarFetch<{ customer_portal_url?: string }>("/customer-sessions/", {
|
|
65
|
+
external_customer_id: input.userId
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
if (!session.customer_portal_url) {
|
|
69
|
+
throw new Error("Polar did not return a customer portal URL.");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { url: session.customer_portal_url };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async handleWebhook(_input: WebhookInput) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
"Polar webhook verification is intentionally left for follow-up implementation after confirming your webhook signing strategy."
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
|
|
3
|
+
import Stripe from "stripe";
|
|
4
|
+
|
|
5
|
+
import { env } from "@/lib/env";
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
BillingProvider,
|
|
9
|
+
CheckoutSessionInput,
|
|
10
|
+
PortalSessionInput,
|
|
11
|
+
WebhookInput
|
|
12
|
+
} from "../types";
|
|
13
|
+
|
|
14
|
+
let stripeClient: Stripe | null = null;
|
|
15
|
+
|
|
16
|
+
function getStripe() {
|
|
17
|
+
if (!env.STRIPE_SECRET_KEY) {
|
|
18
|
+
throw new Error("STRIPE_SECRET_KEY is required for Stripe billing.");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
stripeClient ??= new Stripe(env.STRIPE_SECRET_KEY);
|
|
22
|
+
return stripeClient;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class StripeBillingProvider implements BillingProvider {
|
|
26
|
+
async createCheckoutSession(input: CheckoutSessionInput) {
|
|
27
|
+
if (!env.STRIPE_PRICE_ID) {
|
|
28
|
+
throw new Error("STRIPE_PRICE_ID is required for Stripe checkout.");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const stripe = getStripe();
|
|
32
|
+
const session = await stripe.checkout.sessions.create({
|
|
33
|
+
mode: "subscription",
|
|
34
|
+
line_items: [
|
|
35
|
+
{
|
|
36
|
+
price: env.STRIPE_PRICE_ID,
|
|
37
|
+
quantity: 1
|
|
38
|
+
}
|
|
39
|
+
],
|
|
40
|
+
customer_email: input.customerEmail,
|
|
41
|
+
success_url: input.successUrl,
|
|
42
|
+
cancel_url: input.cancelUrl,
|
|
43
|
+
allow_promotion_codes: true,
|
|
44
|
+
metadata: {
|
|
45
|
+
userId: input.userId
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
if (!session.url) {
|
|
50
|
+
throw new Error("Stripe did not return a checkout URL.");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { url: session.url };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async createCustomerPortalSession(
|
|
57
|
+
_input: PortalSessionInput
|
|
58
|
+
): Promise<{ url: string }> {
|
|
59
|
+
throw new Error(
|
|
60
|
+
"Stripe portal requires a persisted Stripe customer ID. Add customer mapping before enabling portal access."
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async handleWebhook(input: WebhookInput) {
|
|
65
|
+
if (!env.STRIPE_WEBHOOK_SECRET) {
|
|
66
|
+
throw new Error("STRIPE_WEBHOOK_SECRET is required for Stripe webhooks.");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const signature = input.headers.get("stripe-signature");
|
|
70
|
+
if (!signature) {
|
|
71
|
+
throw new Error("Missing Stripe signature header.");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const stripe = getStripe();
|
|
75
|
+
stripe.webhooks.constructEvent(input.rawBody, signature, env.STRIPE_WEBHOOK_SECRET);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type BillingProviderName = "stripe" | "polar";
|
|
2
|
+
|
|
3
|
+
export type CheckoutSessionInput = {
|
|
4
|
+
customerEmail: string;
|
|
5
|
+
successUrl: string;
|
|
6
|
+
cancelUrl: string;
|
|
7
|
+
userId: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type PortalSessionInput = {
|
|
11
|
+
customerEmail: string;
|
|
12
|
+
returnUrl: string;
|
|
13
|
+
userId: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type WebhookInput = {
|
|
17
|
+
headers: Headers;
|
|
18
|
+
rawBody: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export interface BillingProvider {
|
|
22
|
+
createCheckoutSession(input: CheckoutSessionInput): Promise<{ url: string }>;
|
|
23
|
+
createCustomerPortalSession(input: PortalSessionInput): Promise<{ url: string }>;
|
|
24
|
+
handleWebhook(input: WebhookInput): Promise<void>;
|
|
25
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
|
|
3
|
+
__EMAIL_PROVIDER_IMPORTS__
|
|
4
|
+
|
|
5
|
+
__EMAIL_ENV_IMPORT__
|
|
6
|
+
|
|
7
|
+
import type { EmailTemplateResult } from "./templates";
|
|
8
|
+
|
|
9
|
+
__EMAIL_PROVIDER_SETUP__
|
|
10
|
+
|
|
11
|
+
__EMAIL_FROM_ADDRESS_HELPER__
|
|
12
|
+
|
|
13
|
+
export async function sendEmail(__EMAIL_SEND_SIGNATURE__: {
|
|
14
|
+
to: string | string[];
|
|
15
|
+
template: EmailTemplateResult;
|
|
16
|
+
}) {
|
|
17
|
+
void __EMAIL_SEND_SIGNATURE__;
|
|
18
|
+
__EMAIL_SEND_IMPLEMENTATION__
|
|
19
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { createBillingUpdatePlaceholderTemplate } from "@/lib/email/templates";
|
|
4
|
+
|
|
5
|
+
describe("createBillingUpdatePlaceholderTemplate", () => {
|
|
6
|
+
it("mentions the billing provider", () => {
|
|
7
|
+
const template = createBillingUpdatePlaceholderTemplate({
|
|
8
|
+
appName: "Skit",
|
|
9
|
+
provider: "stripe"
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
expect(template.subject).toContain("billing");
|
|
13
|
+
expect(template.text).toContain("stripe");
|
|
14
|
+
});
|
|
15
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export type EmailTemplateResult = {
|
|
2
|
+
subject: string;
|
|
3
|
+
text: string;
|
|
4
|
+
html: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export function createWelcomeEmailTemplate(input: {
|
|
8
|
+
appName: string;
|
|
9
|
+
recipientName?: string | null;
|
|
10
|
+
}): EmailTemplateResult {
|
|
11
|
+
const greeting = input.recipientName ? `Hi ${input.recipientName},` : "Hi there,";
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
subject: `Welcome to ${input.appName}`,
|
|
15
|
+
text: `${greeting}\n\nYour starter app is ready. Auth, billing, and the core project structure are already in place.\n\nNext step: sign in and continue building.\n`,
|
|
16
|
+
html: `<p>${greeting}</p><p>Your starter app is ready. Auth, billing, and the core project structure are already in place.</p><p>Next step: sign in and continue building.</p>`
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function createPasswordResetPlaceholderTemplate(input: {
|
|
21
|
+
appName: string;
|
|
22
|
+
resetUrl: string;
|
|
23
|
+
}): EmailTemplateResult {
|
|
24
|
+
return {
|
|
25
|
+
subject: `Reset your ${input.appName} password`,
|
|
26
|
+
text: `Use this link to reset your password: ${input.resetUrl}`,
|
|
27
|
+
html: `<p>Use this link to reset your password:</p><p><a href="${input.resetUrl}">${input.resetUrl}</a></p>`
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createBillingUpdatePlaceholderTemplate(input: {
|
|
32
|
+
appName: string;
|
|
33
|
+
provider: string;
|
|
34
|
+
}): EmailTemplateResult {
|
|
35
|
+
return {
|
|
36
|
+
subject: `${input.appName} billing update`,
|
|
37
|
+
text: `A billing event was received from ${input.provider}. Replace this placeholder with your real billing notification workflow.`,
|
|
38
|
+
html: `<p>A billing event was received from <strong>${input.provider}</strong>.</p><p>Replace this placeholder with your real billing notification workflow.</p>`
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
const envSchema = z.object({
|
|
6
|
+
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
|
7
|
+
NEXT_PUBLIC_APP_URL: z.string().url(),
|
|
8
|
+
BETTER_AUTH_URL: z.string().url(),
|
|
9
|
+
DATABASE_URL: z.string().min(1),
|
|
10
|
+
BETTER_AUTH_SECRET: z.string().min(1),
|
|
11
|
+
GITHUB_CLIENT_ID: z.string().min(1).optional(),
|
|
12
|
+
GITHUB_CLIENT_SECRET: z.string().min(1).optional(),
|
|
13
|
+
GOOGLE_CLIENT_ID: z.string().min(1).optional(),
|
|
14
|
+
GOOGLE_CLIENT_SECRET: z.string().min(1).optional(),
|
|
15
|
+
BILLING_PROVIDER: z.enum(["stripe", "polar", "both", "none"]).default("stripe"),
|
|
16
|
+
EMAIL_PROVIDER: z.enum(["resend", "none"]).default("resend"),
|
|
17
|
+
RESEND_API_KEY: z.string().min(1).optional(),
|
|
18
|
+
EMAIL_FROM: z.string().min(1).optional(),
|
|
19
|
+
STRIPE_PRICE_ID: z.string().min(1).optional(),
|
|
20
|
+
STRIPE_SECRET_KEY: z.string().min(1).optional(),
|
|
21
|
+
STRIPE_WEBHOOK_SECRET: z.string().min(1).optional(),
|
|
22
|
+
POLAR_ACCESS_TOKEN: z.string().min(1).optional(),
|
|
23
|
+
POLAR_ORGANIZATION_ID: z.string().min(1).optional(),
|
|
24
|
+
POLAR_PRODUCT_ID: z.string().min(1).optional(),
|
|
25
|
+
POLAR_SERVER: z.enum(["production", "sandbox"]).default("sandbox"),
|
|
26
|
+
POLAR_WEBHOOK_SECRET: z.string().min(1).optional()
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
type Env = z.infer<typeof envSchema>;
|
|
30
|
+
|
|
31
|
+
let cachedEnv: Env | null = null;
|
|
32
|
+
const isBuildRuntime =
|
|
33
|
+
process.env.npm_lifecycle_event === "build" ||
|
|
34
|
+
process.env.NEXT_PHASE === "phase-production-build";
|
|
35
|
+
|
|
36
|
+
function readRequiredValue(name: string, fallback: string) {
|
|
37
|
+
const value = process.env[name];
|
|
38
|
+
|
|
39
|
+
if (value) {
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (isBuildRuntime) {
|
|
44
|
+
return fallback;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseEnv(): Env {
|
|
51
|
+
return envSchema.parse({
|
|
52
|
+
NODE_ENV: process.env.NODE_ENV,
|
|
53
|
+
NEXT_PUBLIC_APP_URL: readRequiredValue("NEXT_PUBLIC_APP_URL", "http://localhost:3000"),
|
|
54
|
+
BETTER_AUTH_URL: readRequiredValue("BETTER_AUTH_URL", "http://localhost:3000"),
|
|
55
|
+
DATABASE_URL: readRequiredValue(
|
|
56
|
+
"DATABASE_URL",
|
|
57
|
+
"postgresql://postgres:postgres@localhost:5432/skit"
|
|
58
|
+
),
|
|
59
|
+
BETTER_AUTH_SECRET: readRequiredValue("BETTER_AUTH_SECRET", "build-placeholder-secret"),
|
|
60
|
+
GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID,
|
|
61
|
+
GITHUB_CLIENT_SECRET: process.env.GITHUB_CLIENT_SECRET,
|
|
62
|
+
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
|
|
63
|
+
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
|
|
64
|
+
BILLING_PROVIDER: process.env.BILLING_PROVIDER,
|
|
65
|
+
EMAIL_PROVIDER: process.env.EMAIL_PROVIDER,
|
|
66
|
+
RESEND_API_KEY: process.env.RESEND_API_KEY,
|
|
67
|
+
EMAIL_FROM: process.env.EMAIL_FROM,
|
|
68
|
+
STRIPE_PRICE_ID: process.env.STRIPE_PRICE_ID,
|
|
69
|
+
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
|
|
70
|
+
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
|
|
71
|
+
POLAR_ACCESS_TOKEN: process.env.POLAR_ACCESS_TOKEN,
|
|
72
|
+
POLAR_ORGANIZATION_ID: process.env.POLAR_ORGANIZATION_ID,
|
|
73
|
+
POLAR_PRODUCT_ID: process.env.POLAR_PRODUCT_ID,
|
|
74
|
+
POLAR_SERVER: process.env.POLAR_SERVER,
|
|
75
|
+
POLAR_WEBHOOK_SECRET: process.env.POLAR_WEBHOOK_SECRET
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function getEnv(): Env {
|
|
80
|
+
cachedEnv ??= parseEnv();
|
|
81
|
+
return cachedEnv;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const env = new Proxy({} as Env, {
|
|
85
|
+
get(_target, property) {
|
|
86
|
+
return getEnv()[property as keyof Env];
|
|
87
|
+
}
|
|
88
|
+
});
|