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,109 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { useRouter } from "next/navigation";
5
+ import { useState } from "react";
6
+
7
+ import { authClient } from "@/lib/auth-client";
8
+
9
+ type EmailAuthFormProps = {
10
+ mode: "sign-in" | "sign-up";
11
+ };
12
+
13
+ export function EmailAuthForm({ mode }: EmailAuthFormProps) {
14
+ const isSignUp = mode === "sign-up";
15
+ const router = useRouter();
16
+ const [error, setError] = useState<string | null>(null);
17
+ const [isPending, setIsPending] = useState(false);
18
+
19
+ async function handleSubmit(formData: FormData) {
20
+ const email = String(formData.get("email") ?? "");
21
+ const password = String(formData.get("password") ?? "");
22
+ const name = String(formData.get("name") ?? "");
23
+
24
+ setIsPending(true);
25
+ setError(null);
26
+
27
+ try {
28
+ const result = isSignUp
29
+ ? await authClient.signUp.email({
30
+ email,
31
+ password,
32
+ name
33
+ })
34
+ : await authClient.signIn.email({
35
+ email,
36
+ password
37
+ });
38
+
39
+ if (result.error) {
40
+ setError(result.error.message ?? "Authentication failed.");
41
+ return;
42
+ }
43
+
44
+ router.push("/dashboard");
45
+ router.refresh();
46
+ } catch {
47
+ setError("Authentication failed.");
48
+ } finally {
49
+ setIsPending(false);
50
+ }
51
+ }
52
+
53
+ return (
54
+ <form
55
+ action={(formData) => {
56
+ void handleSubmit(formData);
57
+ }}
58
+ className="auth-card"
59
+ >
60
+ <div className="auth-copy">
61
+ <p className="eyebrow">{isSignUp ? "Create account" : "Welcome back"}</p>
62
+ <h1>{isSignUp ? "Create your account" : "Sign in to your workspace"}</h1>
63
+ <p className="lede">
64
+ {isSignUp
65
+ ? "Start from a minimal SaaS baseline with auth, billing, and strong project structure."
66
+ : "Use your email and password to continue into the app."}
67
+ </p>
68
+ </div>
69
+
70
+ <div className="auth-fields">
71
+ {isSignUp ? (
72
+ <label className="field">
73
+ <span>Name</span>
74
+ <input name="name" type="text" placeholder="Ada Lovelace" required />
75
+ </label>
76
+ ) : null}
77
+
78
+ <label className="field">
79
+ <span>Email</span>
80
+ <input name="email" type="email" placeholder="you@example.com" required />
81
+ </label>
82
+
83
+ <label className="field">
84
+ <span>Password</span>
85
+ <input
86
+ name="password"
87
+ type="password"
88
+ placeholder="At least 8 characters"
89
+ minLength={8}
90
+ required
91
+ />
92
+ </label>
93
+ </div>
94
+
95
+ {error ? <p className="form-error">{error}</p> : null}
96
+
97
+ <button type="submit" className="auth-button" disabled={isPending}>
98
+ {isPending ? "Working..." : isSignUp ? "Create account" : "Sign in"}
99
+ </button>
100
+
101
+ <p className="auth-switch">
102
+ {isSignUp ? "Already have an account?" : "Need an account?"}{" "}
103
+ <Link href={isSignUp ? "/sign-in" : "/sign-up"}>
104
+ {isSignUp ? "Sign in" : "Create one"}
105
+ </Link>
106
+ </p>
107
+ </form>
108
+ );
109
+ }
@@ -0,0 +1,29 @@
1
+ "use client";
2
+
3
+ import { useRouter } from "next/navigation";
4
+ import { useState } from "react";
5
+
6
+ import { authClient } from "@/lib/auth-client";
7
+
8
+ export function SignOutButton() {
9
+ const router = useRouter();
10
+ const [isPending, setIsPending] = useState(false);
11
+
12
+ async function handleSignOut() {
13
+ setIsPending(true);
14
+
15
+ try {
16
+ await authClient.signOut();
17
+ router.push("/sign-in");
18
+ router.refresh();
19
+ } finally {
20
+ setIsPending(false);
21
+ }
22
+ }
23
+
24
+ return (
25
+ <button type="button" className="btn-ghost" onClick={() => void handleSignOut()}>
26
+ {isPending ? "Signing out..." : "Sign out"}
27
+ </button>
28
+ );
29
+ }
@@ -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,23 @@
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
+ });
@@ -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 { createWelcomeEmailTemplate } from "@/lib/email/templates";
4
+
5
+ describe("createWelcomeEmailTemplate", () => {
6
+ it("includes the app name in the subject", () => {
7
+ const template = createWelcomeEmailTemplate({
8
+ appName: "Skit",
9
+ recipientName: "Ada"
10
+ });
11
+
12
+ expect(template.subject).toContain("Skit");
13
+ expect(template.text).toContain("Ada");
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,80 @@
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
+ BILLING_PROVIDER: z.enum(["stripe", "polar", "both", "none"]).default("stripe"),
12
+ EMAIL_PROVIDER: z.enum(["resend", "none"]).default("resend"),
13
+ RESEND_API_KEY: z.string().min(1).optional(),
14
+ EMAIL_FROM: z.string().min(1).optional(),
15
+ STRIPE_PRICE_ID: z.string().min(1).optional(),
16
+ STRIPE_SECRET_KEY: z.string().min(1).optional(),
17
+ STRIPE_WEBHOOK_SECRET: z.string().min(1).optional(),
18
+ POLAR_ACCESS_TOKEN: z.string().min(1).optional(),
19
+ POLAR_ORGANIZATION_ID: z.string().min(1).optional(),
20
+ POLAR_PRODUCT_ID: z.string().min(1).optional(),
21
+ POLAR_SERVER: z.enum(["production", "sandbox"]).default("sandbox"),
22
+ POLAR_WEBHOOK_SECRET: z.string().min(1).optional()
23
+ });
24
+
25
+ type Env = z.infer<typeof envSchema>;
26
+
27
+ let cachedEnv: Env | null = null;
28
+ const isBuildRuntime =
29
+ process.env.npm_lifecycle_event === "build" ||
30
+ process.env.NEXT_PHASE === "phase-production-build";
31
+
32
+ function readRequiredValue(name: string, fallback: string) {
33
+ const value = process.env[name];
34
+
35
+ if (value) {
36
+ return value;
37
+ }
38
+
39
+ if (isBuildRuntime) {
40
+ return fallback;
41
+ }
42
+
43
+ return value;
44
+ }
45
+
46
+ function parseEnv(): Env {
47
+ return envSchema.parse({
48
+ NODE_ENV: process.env.NODE_ENV,
49
+ NEXT_PUBLIC_APP_URL: readRequiredValue("NEXT_PUBLIC_APP_URL", "http://localhost:3000"),
50
+ BETTER_AUTH_URL: readRequiredValue("BETTER_AUTH_URL", "http://localhost:3000"),
51
+ DATABASE_URL: readRequiredValue(
52
+ "DATABASE_URL",
53
+ "postgresql://postgres:postgres@localhost:5432/skit"
54
+ ),
55
+ BETTER_AUTH_SECRET: readRequiredValue("BETTER_AUTH_SECRET", "build-placeholder-secret"),
56
+ BILLING_PROVIDER: process.env.BILLING_PROVIDER,
57
+ EMAIL_PROVIDER: process.env.EMAIL_PROVIDER,
58
+ RESEND_API_KEY: process.env.RESEND_API_KEY,
59
+ EMAIL_FROM: process.env.EMAIL_FROM,
60
+ STRIPE_PRICE_ID: process.env.STRIPE_PRICE_ID,
61
+ STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
62
+ STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
63
+ POLAR_ACCESS_TOKEN: process.env.POLAR_ACCESS_TOKEN,
64
+ POLAR_ORGANIZATION_ID: process.env.POLAR_ORGANIZATION_ID,
65
+ POLAR_PRODUCT_ID: process.env.POLAR_PRODUCT_ID,
66
+ POLAR_SERVER: process.env.POLAR_SERVER,
67
+ POLAR_WEBHOOK_SECRET: process.env.POLAR_WEBHOOK_SECRET
68
+ });
69
+ }
70
+
71
+ export function getEnv(): Env {
72
+ cachedEnv ??= parseEnv();
73
+ return cachedEnv;
74
+ }
75
+
76
+ export const env = new Proxy({} as Env, {
77
+ get(_target, property) {
78
+ return getEnv()[property as keyof Env];
79
+ }
80
+ });
@@ -0,0 +1,34 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["dom", "dom.iterable", "es2022"],
5
+ "allowJs": false,
6
+ "skipLibCheck": true,
7
+ "strict": true,
8
+ "noUncheckedIndexedAccess": true,
9
+ "exactOptionalPropertyTypes": true,
10
+ "noEmit": true,
11
+ "esModuleInterop": true,
12
+ "module": "esnext",
13
+ "moduleResolution": "bundler",
14
+ "baseUrl": ".",
15
+ "resolveJsonModule": true,
16
+ "isolatedModules": true,
17
+ "jsx": "react-jsx",
18
+ "incremental": true,
19
+ "verbatimModuleSyntax": true,
20
+ "moduleDetection": "force",
21
+ "plugins": [{ "name": "next" }],
22
+ "paths": {
23
+ "@/*": ["./src/*"]
24
+ }
25
+ },
26
+ "include": [
27
+ "next-env.d.ts",
28
+ "**/*.ts",
29
+ "**/*.tsx",
30
+ ".next/types/**/*.ts",
31
+ ".next/dev/types/**/*.ts"
32
+ ],
33
+ "exclude": ["node_modules"]
34
+ }