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.
Files changed (195) hide show
  1. package/README.md +36 -0
  2. package/bin/create-skit.mjs +1064 -0
  3. package/lib/module-application.mjs +281 -0
  4. package/lib/module-resolver.mjs +179 -0
  5. package/modules/README.md +22 -0
  6. package/modules/ai-dx/files/AGENTS.md +116 -0
  7. package/modules/ai-dx/files/ARCHITECTURE.md +103 -0
  8. package/modules/ai-dx/module.json +14 -0
  9. package/modules/ai-dx-claude/files/CLAUDE.md +8 -0
  10. package/modules/ai-dx-claude/module.json +13 -0
  11. package/modules/ai-dx-cursor/files/.cursor/rules/auth.mdc +53 -0
  12. package/modules/ai-dx-cursor/files/.cursor/rules/database.mdc +48 -0
  13. package/modules/ai-dx-cursor/files/.cursor/rules/env.mdc +43 -0
  14. package/modules/ai-dx-cursor/files/.cursor/rules/nextjs.mdc +58 -0
  15. package/modules/ai-dx-cursor/files/.cursor/rules/project.mdc +33 -0
  16. package/modules/ai-dx-cursor/files/.cursor/rules/testing.mdc +55 -0
  17. package/modules/ai-dx-cursor/module.json +18 -0
  18. package/modules/ai-dx-gemini/files/.gemini/GEMINI.md +5 -0
  19. package/modules/ai-dx-gemini/module.json +13 -0
  20. package/modules/auth-core/module.json +8 -0
  21. package/modules/auth-github/module.json +20 -0
  22. package/modules/billing-polar/module.json +20 -0
  23. package/modules/billing-stripe/module.json +23 -0
  24. package/modules/dashboard-shell/files/src/app/globals.css +756 -0
  25. package/modules/dashboard-shell/files/src/app/settings/page.tsx +67 -0
  26. package/modules/dashboard-shell/module.json +11 -0
  27. package/modules/db-pg/module.json +21 -0
  28. package/modules/db-postgresjs/module.json +21 -0
  29. package/modules/deploy-docker/files/.dockerignore +19 -0
  30. package/modules/deploy-docker/files/Dockerfile +25 -0
  31. package/modules/deploy-docker/module.json +11 -0
  32. package/modules/email-resend/module.json +21 -0
  33. package/modules/quality-baseline/module.json +8 -0
  34. package/modules/testing-baseline/module.json +8 -0
  35. package/package.json +40 -0
  36. package/presets/README.md +12 -0
  37. package/presets/blank.json +67 -0
  38. package/presets/dashboard.json +67 -0
  39. package/templates/base-web/.env.example +17 -0
  40. package/templates/base-web/.github/workflows/ci.yml +34 -0
  41. package/templates/base-web/.husky/pre-commit +3 -0
  42. package/templates/base-web/.husky/pre-push +3 -0
  43. package/templates/base-web/.prettierignore +3 -0
  44. package/templates/base-web/README.md +42 -0
  45. package/templates/base-web/drizzle.config.ts +16 -0
  46. package/templates/base-web/eslint.config.mjs +127 -0
  47. package/templates/base-web/manifest.json +5 -0
  48. package/templates/base-web/next-env.d.ts +4 -0
  49. package/templates/base-web/next.config.ts +5 -0
  50. package/templates/base-web/package.json +75 -0
  51. package/templates/base-web/playwright.config.ts +21 -0
  52. package/templates/base-web/prettier.config.mjs +9 -0
  53. package/templates/base-web/proxy.ts +23 -0
  54. package/templates/base-web/src/app/api/auth/[...all]/route.ts +5 -0
  55. package/templates/base-web/src/app/api/billing/checkout/route.ts +26 -0
  56. package/templates/base-web/src/app/api/billing/portal/route.ts +25 -0
  57. package/templates/base-web/src/app/api/email/test/route.ts +28 -0
  58. package/templates/base-web/src/app/api/webhooks/polar/route.ts +5 -0
  59. package/templates/base-web/src/app/api/webhooks/stripe/route.ts +5 -0
  60. package/templates/base-web/src/app/billing/page.tsx +55 -0
  61. package/templates/base-web/src/app/dashboard/page.tsx +15 -0
  62. package/templates/base-web/src/app/email/page.tsx +46 -0
  63. package/templates/base-web/src/app/error.tsx +27 -0
  64. package/templates/base-web/src/app/globals.css +534 -0
  65. package/templates/base-web/src/app/layout.tsx +19 -0
  66. package/templates/base-web/src/app/llms-full.txt/route.ts +158 -0
  67. package/templates/base-web/src/app/llms.txt/route.ts +59 -0
  68. package/templates/base-web/src/app/loading.tsx +24 -0
  69. package/templates/base-web/src/app/not-found.tsx +16 -0
  70. package/templates/base-web/src/app/page.tsx +5 -0
  71. package/templates/base-web/src/app/sign-in/page.tsx +14 -0
  72. package/templates/base-web/src/app/sign-up/page.tsx +14 -0
  73. package/templates/base-web/src/components/auth/email-auth-form.test.tsx +40 -0
  74. package/templates/base-web/src/components/auth/email-auth-form.tsx +128 -0
  75. package/templates/base-web/src/components/auth/sign-out-button.tsx +29 -0
  76. package/templates/base-web/src/db/index.ts +16 -0
  77. package/templates/base-web/src/db/schema/auth.ts +4 -0
  78. package/templates/base-web/src/db/schema/index.ts +2 -0
  79. package/templates/base-web/src/db/schema/projects.ts +17 -0
  80. package/templates/base-web/src/db/seeds/index.ts +32 -0
  81. package/templates/base-web/src/lib/auth-client.ts +5 -0
  82. package/templates/base-web/src/lib/auth-session.ts +21 -0
  83. package/templates/base-web/src/lib/auth.ts +23 -0
  84. package/templates/base-web/src/lib/billing/index.ts +37 -0
  85. package/templates/base-web/src/lib/billing/providers/polar.ts +80 -0
  86. package/templates/base-web/src/lib/billing/providers/stripe.ts +77 -0
  87. package/templates/base-web/src/lib/billing/types.ts +25 -0
  88. package/templates/base-web/src/lib/email/index.ts +19 -0
  89. package/templates/base-web/src/lib/email/templates.test.ts +12 -0
  90. package/templates/base-web/src/lib/email/templates.ts +40 -0
  91. package/templates/base-web/src/lib/env.ts +83 -0
  92. package/templates/base-web/tests/e2e/home.spec.ts +8 -0
  93. package/templates/base-web/tsconfig.json +34 -0
  94. package/templates/base-web/vitest.config.ts +19 -0
  95. package/templates/blank/.env.example +16 -0
  96. package/templates/blank/.github/workflows/ci.yml +34 -0
  97. package/templates/blank/.husky/pre-commit +3 -0
  98. package/templates/blank/.husky/pre-push +3 -0
  99. package/templates/blank/.prettierignore +3 -0
  100. package/templates/blank/drizzle.config.ts +16 -0
  101. package/templates/blank/eslint.config.mjs +127 -0
  102. package/templates/blank/next-env.d.ts +4 -0
  103. package/templates/blank/next.config.ts +5 -0
  104. package/templates/blank/package.json +75 -0
  105. package/templates/blank/playwright.config.ts +21 -0
  106. package/templates/blank/prettier.config.mjs +9 -0
  107. package/templates/blank/proxy.ts +28 -0
  108. package/templates/blank/src/app/api/auth/[...all]/route.ts +5 -0
  109. package/templates/blank/src/app/api/billing/checkout/route.ts +26 -0
  110. package/templates/blank/src/app/api/billing/portal/route.ts +25 -0
  111. package/templates/blank/src/app/api/email/test/route.ts +28 -0
  112. package/templates/blank/src/app/api/webhooks/polar/route.ts +5 -0
  113. package/templates/blank/src/app/api/webhooks/stripe/route.ts +5 -0
  114. package/templates/blank/src/app/billing/page.tsx +70 -0
  115. package/templates/blank/src/app/email/page.tsx +46 -0
  116. package/templates/blank/src/app/globals.css +394 -0
  117. package/templates/blank/src/app/layout.tsx +19 -0
  118. package/templates/blank/src/app/page.tsx +23 -0
  119. package/templates/blank/src/app/sign-in/page.tsx +18 -0
  120. package/templates/blank/src/app/sign-up/page.tsx +18 -0
  121. package/templates/blank/src/components/auth/email-auth-form.test.tsx +39 -0
  122. package/templates/blank/src/components/auth/email-auth-form.tsx +109 -0
  123. package/templates/blank/src/components/auth/sign-out-button.tsx +29 -0
  124. package/templates/blank/src/db/index.ts +16 -0
  125. package/templates/blank/src/db/schema/auth.ts +4 -0
  126. package/templates/blank/src/db/schema/index.ts +2 -0
  127. package/templates/blank/src/db/schema/projects.ts +17 -0
  128. package/templates/blank/src/db/seeds/index.ts +28 -0
  129. package/templates/blank/src/lib/auth-client.ts +5 -0
  130. package/templates/blank/src/lib/auth-session.ts +11 -0
  131. package/templates/blank/src/lib/auth.ts +23 -0
  132. package/templates/blank/src/lib/billing/index.ts +37 -0
  133. package/templates/blank/src/lib/billing/providers/polar.ts +80 -0
  134. package/templates/blank/src/lib/billing/providers/stripe.ts +77 -0
  135. package/templates/blank/src/lib/billing/types.ts +25 -0
  136. package/templates/blank/src/lib/email/index.ts +19 -0
  137. package/templates/blank/src/lib/email/templates.test.ts +15 -0
  138. package/templates/blank/src/lib/email/templates.ts +40 -0
  139. package/templates/blank/src/lib/env.ts +80 -0
  140. package/templates/blank/tsconfig.json +34 -0
  141. package/templates/blank/vitest.config.ts +19 -0
  142. package/templates/dashboard/.env.example +16 -0
  143. package/templates/dashboard/.github/workflows/ci.yml +34 -0
  144. package/templates/dashboard/.husky/pre-commit +3 -0
  145. package/templates/dashboard/.husky/pre-push +3 -0
  146. package/templates/dashboard/.prettierignore +3 -0
  147. package/templates/dashboard/drizzle.config.ts +16 -0
  148. package/templates/dashboard/eslint.config.mjs +127 -0
  149. package/templates/dashboard/next-env.d.ts +4 -0
  150. package/templates/dashboard/next.config.ts +5 -0
  151. package/templates/dashboard/package.json +75 -0
  152. package/templates/dashboard/playwright.config.ts +21 -0
  153. package/templates/dashboard/prettier.config.mjs +9 -0
  154. package/templates/dashboard/proxy.ts +36 -0
  155. package/templates/dashboard/src/app/api/auth/[...all]/route.ts +5 -0
  156. package/templates/dashboard/src/app/api/billing/checkout/route.ts +26 -0
  157. package/templates/dashboard/src/app/api/billing/portal/route.ts +25 -0
  158. package/templates/dashboard/src/app/api/email/test/route.ts +28 -0
  159. package/templates/dashboard/src/app/api/webhooks/polar/route.ts +5 -0
  160. package/templates/dashboard/src/app/api/webhooks/stripe/route.ts +5 -0
  161. package/templates/dashboard/src/app/billing/layout.tsx +22 -0
  162. package/templates/dashboard/src/app/billing/page.tsx +73 -0
  163. package/templates/dashboard/src/app/dashboard/layout.tsx +22 -0
  164. package/templates/dashboard/src/app/dashboard/page.tsx +104 -0
  165. package/templates/dashboard/src/app/email/layout.tsx +22 -0
  166. package/templates/dashboard/src/app/email/page.tsx +54 -0
  167. package/templates/dashboard/src/app/globals.css +1357 -0
  168. package/templates/dashboard/src/app/layout.tsx +25 -0
  169. package/templates/dashboard/src/app/page.tsx +154 -0
  170. package/templates/dashboard/src/app/settings/layout.tsx +22 -0
  171. package/templates/dashboard/src/app/settings/page.tsx +85 -0
  172. package/templates/dashboard/src/app/sign-in/page.tsx +47 -0
  173. package/templates/dashboard/src/app/sign-up/page.tsx +47 -0
  174. package/templates/dashboard/src/components/auth/email-auth-form.test.tsx +39 -0
  175. package/templates/dashboard/src/components/auth/email-auth-form.tsx +160 -0
  176. package/templates/dashboard/src/components/auth/sign-out-button.tsx +29 -0
  177. package/templates/dashboard/src/components/dashboard/shell.tsx +158 -0
  178. package/templates/dashboard/src/db/index.ts +16 -0
  179. package/templates/dashboard/src/db/schema/auth.ts +4 -0
  180. package/templates/dashboard/src/db/schema/index.ts +2 -0
  181. package/templates/dashboard/src/db/schema/projects.ts +17 -0
  182. package/templates/dashboard/src/db/seeds/index.ts +28 -0
  183. package/templates/dashboard/src/lib/auth-client.ts +5 -0
  184. package/templates/dashboard/src/lib/auth-session.ts +11 -0
  185. package/templates/dashboard/src/lib/auth.ts +41 -0
  186. package/templates/dashboard/src/lib/billing/index.ts +37 -0
  187. package/templates/dashboard/src/lib/billing/providers/polar.ts +80 -0
  188. package/templates/dashboard/src/lib/billing/providers/stripe.ts +77 -0
  189. package/templates/dashboard/src/lib/billing/types.ts +25 -0
  190. package/templates/dashboard/src/lib/email/index.ts +19 -0
  191. package/templates/dashboard/src/lib/email/templates.test.ts +15 -0
  192. package/templates/dashboard/src/lib/email/templates.ts +40 -0
  193. package/templates/dashboard/src/lib/env.ts +88 -0
  194. package/templates/dashboard/tsconfig.json +34 -0
  195. 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,4 @@
1
+ // This file is intentionally minimal.
2
+ // Run `pnpm auth:generate` to replace it with Better Auth's generated Drizzle schema.
3
+
4
+ export {};
@@ -0,0 +1,2 @@
1
+ export * from "./auth";
2
+ export * from "./projects";
@@ -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,5 @@
1
+ "use client";
2
+
3
+ import { createAuthClient } from "better-auth/react";
4
+
5
+ export const authClient = createAuthClient();
@@ -0,0 +1,11 @@
1
+ import "server-only";
2
+
3
+ import { headers } from "next/headers";
4
+
5
+ import { auth } from "./auth";
6
+
7
+ export async function getSession() {
8
+ return auth.api.getSession({
9
+ headers: await headers()
10
+ });
11
+ }
@@ -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
+ });