feat(cli): add polar as better-auth plugin (#578)

This commit is contained in:
Aman Varshney
2025-09-16 17:53:44 +05:30
committed by GitHub
parent 3f22373cc3
commit ba3d62b6b9
77 changed files with 1221 additions and 308 deletions

View File

@@ -13,6 +13,7 @@ export const DEFAULT_CONFIG_BASE = {
database: "sqlite", database: "sqlite",
orm: "drizzle", orm: "drizzle",
auth: "better-auth", auth: "better-auth",
payments: "none",
addons: ["turborepo"], addons: ["turborepo"],
examples: [], examples: [],
git: true, git: true,
@@ -39,8 +40,8 @@ export function getDefaultConfig() {
export const DEFAULT_CONFIG = getDefaultConfig(); export const DEFAULT_CONFIG = getDefaultConfig();
export const dependencyVersionMap = { export const dependencyVersionMap = {
"better-auth": "^1.3.9", "better-auth": "^1.3.10",
"@better-auth/expo": "^1.3.9", "@better-auth/expo": "^1.3.10",
"@clerk/nextjs": "^6.31.5", "@clerk/nextjs": "^6.31.5",
"@clerk/clerk-react": "^5.45.0", "@clerk/clerk-react": "^5.45.0",
@@ -139,9 +140,9 @@ export const dependencyVersionMap = {
"@tanstack/react-query-devtools": "^5.85.5", "@tanstack/react-query-devtools": "^5.85.5",
"@tanstack/react-query": "^5.85.5", "@tanstack/react-query": "^5.85.5",
"@tanstack/solid-query": "^5.75.0", "@tanstack/solid-query": "^5.87.4",
"@tanstack/solid-query-devtools": "^5.75.0", "@tanstack/solid-query-devtools": "^5.87.4",
"@tanstack/solid-router-devtools": "^1.131.25", "@tanstack/solid-router-devtools": "^1.131.44",
wrangler: "^4.23.0", wrangler: "^4.23.0",
"@cloudflare/vite-plugin": "^1.9.0", "@cloudflare/vite-plugin": "^1.9.0",
@@ -155,6 +156,9 @@ export const dependencyVersionMap = {
nitropack: "^2.12.4", nitropack: "^2.12.4",
dotenv: "^17.2.1", dotenv: "^17.2.1",
"@polar-sh/better-auth": "^1.1.3",
"@polar-sh/sdk": "^0.34.16",
} as const; } as const;
export type AvailableDependencies = keyof typeof dependencyVersionMap; export type AvailableDependencies = keyof typeof dependencyVersionMap;

View File

@@ -2,8 +2,8 @@ import path from "node:path";
import { log } from "@clack/prompts"; import { log } from "@clack/prompts";
import pc from "picocolors"; import pc from "picocolors";
import type { AddInput, Addons, ProjectConfig } from "../../types"; import type { AddInput, Addons, ProjectConfig } from "../../types";
import { validateAddonCompatibility } from "../../utils/addon-compatibility";
import { updateBtsConfig } from "../../utils/bts-config"; import { updateBtsConfig } from "../../utils/bts-config";
import { validateAddonCompatibility } from "../../utils/compatibility-rules";
import { exitWithError } from "../../utils/errors"; import { exitWithError } from "../../utils/errors";
import { setupAddons } from "../addons/addons-setup"; import { setupAddons } from "../addons/addons-setup";
import { import {
@@ -45,6 +45,7 @@ export async function addAddonsToProject(
addons: input.addons, addons: input.addons,
examples: detectedConfig.examples || [], examples: detectedConfig.examples || [],
auth: detectedConfig.auth || "none", auth: detectedConfig.auth || "none",
payments: detectedConfig.payments || "none",
git: false, git: false,
packageManager: packageManager:
input.packageManager || detectedConfig.packageManager || "npm", input.packageManager || detectedConfig.packageManager || "npm",

View File

@@ -69,6 +69,7 @@ export async function addDeploymentToProject(
addons: detectedConfig.addons || [], addons: detectedConfig.addons || [],
examples: detectedConfig.examples || [], examples: detectedConfig.examples || [],
auth: detectedConfig.auth || "none", auth: detectedConfig.auth || "none",
payments: detectedConfig.payments || "none",
git: false, git: false,
packageManager: packageManager:
input.packageManager || detectedConfig.packageManager || "npm", input.packageManager || detectedConfig.packageManager || "npm",

View File

@@ -103,6 +103,7 @@ export async function createProjectHandler(
addons: [], addons: [],
examples: [], examples: [],
auth: "none", auth: "none",
payments: "none",
git: false, git: false,
packageManager: "npm", packageManager: "npm",
install: false, install: false,
@@ -272,6 +273,7 @@ export async function addAddonsHandler(input: AddInput) {
const addonsPrompt = await getAddonsToAdd( const addonsPrompt = await getAddonsToAdd(
detectedConfig.frontend || [], detectedConfig.frontend || [],
detectedConfig.addons || [], detectedConfig.addons || [],
detectedConfig.auth,
); );
if (addonsPrompt.length > 0) { if (addonsPrompt.length > 0) {

View File

@@ -17,6 +17,7 @@ import { createReadme } from "./create-readme";
import { setupEnvironmentVariables } from "./env-setup"; import { setupEnvironmentVariables } from "./env-setup";
import { initializeGit } from "./git"; import { initializeGit } from "./git";
import { installDependencies } from "./install-dependencies"; import { installDependencies } from "./install-dependencies";
import { setupPayments } from "./payments-setup";
import { displayPostInstallInstructions } from "./post-installation"; import { displayPostInstallInstructions } from "./post-installation";
import { updatePackageConfigurations } from "./project-config"; import { updatePackageConfigurations } from "./project-config";
import { import {
@@ -30,6 +31,7 @@ import {
setupDockerComposeTemplates, setupDockerComposeTemplates,
setupExamplesTemplate, setupExamplesTemplate,
setupFrontendTemplates, setupFrontendTemplates,
setupPaymentsTemplate,
} from "./template-manager"; } from "./template-manager";
export async function createProject( export async function createProject(
@@ -50,6 +52,9 @@ export async function createProject(
await setupDockerComposeTemplates(projectDir, options); await setupDockerComposeTemplates(projectDir, options);
} }
await setupAuthTemplate(projectDir, options); await setupAuthTemplate(projectDir, options);
if (options.payments && options.payments !== "none") {
await setupPaymentsTemplate(projectDir, options);
}
if (options.examples.length > 0 && options.examples[0] !== "none") { if (options.examples.length > 0 && options.examples[0] !== "none") {
await setupExamplesTemplate(projectDir, options); await setupExamplesTemplate(projectDir, options);
} }
@@ -76,6 +81,10 @@ export async function createProject(
await setupAuth(options); await setupAuth(options);
} }
if (options.payments && options.payments !== "none") {
await setupPayments(options);
}
await handleExtras(projectDir, options); await handleExtras(projectDir, options);
await setupEnvironmentVariables(options); await setupEnvironmentVariables(options);

View File

@@ -17,6 +17,7 @@ export async function detectProjectConfig(projectDir: string) {
addons: btsConfig.addons, addons: btsConfig.addons,
examples: btsConfig.examples, examples: btsConfig.examples,
auth: btsConfig.auth, auth: btsConfig.auth,
payments: btsConfig.payments,
packageManager: btsConfig.packageManager, packageManager: btsConfig.packageManager,
dbSetup: btsConfig.dbSetup, dbSetup: btsConfig.dbSetup,
api: btsConfig.api, api: btsConfig.api,

View File

@@ -280,6 +280,16 @@ export async function setupEnvironmentVariables(config: ProjectConfig) {
value: "", value: "",
condition: examples?.includes("ai") || false, condition: examples?.includes("ai") || false,
}, },
{
key: "POLAR_ACCESS_TOKEN",
value: "",
condition: config.payments === "polar",
},
{
key: "POLAR_SUCCESS_URL",
value: `${corsOrigin}/success?checkout_id={CHECKOUT_ID}`,
condition: config.payments === "polar",
},
]; ];
await addEnvVariablesToFile(envPath, serverVars); await addEnvVariablesToFile(envPath, serverVars);

View File

@@ -0,0 +1,50 @@
import path from "node:path";
import fs from "fs-extra";
import type { ProjectConfig } from "../../types";
import { addPackageDependency } from "../../utils/add-package-deps";
export async function setupPayments(config: ProjectConfig) {
const { payments, projectDir, frontend } = config;
if (!payments || payments === "none") {
return;
}
const serverDir = path.join(projectDir, "apps/server");
const clientDir = path.join(projectDir, "apps/web");
const serverDirExists = await fs.pathExists(serverDir);
const clientDirExists = await fs.pathExists(clientDir);
if (!serverDirExists) {
return;
}
if (payments === "polar") {
await addPackageDependency({
dependencies: ["@polar-sh/better-auth", "@polar-sh/sdk"],
projectDir: serverDir,
});
if (clientDirExists) {
const hasWebFrontend = frontend.some((f) =>
[
"react-router",
"tanstack-router",
"tanstack-start",
"next",
"nuxt",
"svelte",
"solid",
].includes(f),
);
if (hasWebFrontend) {
await addPackageDependency({
dependencies: ["@polar-sh/better-auth"],
projectDir: clientDir,
});
}
}
}
}

View File

@@ -72,6 +72,10 @@ export async function displayPostInstallInstructions(
: ""; : "";
const clerkInstructions = const clerkInstructions =
isConvex && config.auth === "clerk" ? getClerkInstructions() : ""; isConvex && config.auth === "clerk" ? getClerkInstructions() : "";
const polarInstructions =
config.payments === "polar" && config.auth === "better-auth"
? getPolarInstructions()
: "";
const wranglerDeployInstructions = getWranglerDeployInstructions( const wranglerDeployInstructions = getWranglerDeployInstructions(
runCmd, runCmd,
webDeploy, webDeploy,
@@ -188,6 +192,7 @@ export async function displayPostInstallInstructions(
output += `\n${alchemyDeployInstructions.trim()}\n`; output += `\n${alchemyDeployInstructions.trim()}\n`;
if (starlightInstructions) output += `\n${starlightInstructions.trim()}\n`; if (starlightInstructions) output += `\n${starlightInstructions.trim()}\n`;
if (clerkInstructions) output += `\n${clerkInstructions.trim()}\n`; if (clerkInstructions) output += `\n${clerkInstructions.trim()}\n`;
if (polarInstructions) output += `\n${polarInstructions.trim()}\n`;
if (noOrmWarning) output += `\n${noOrmWarning.trim()}\n`; if (noOrmWarning) output += `\n${noOrmWarning.trim()}\n`;
if (bunWebNativeWarning) output += `\n${bunWebNativeWarning.trim()}\n`; if (bunWebNativeWarning) output += `\n${bunWebNativeWarning.trim()}\n`;
@@ -447,6 +452,10 @@ function getClerkInstructions() {
return `${pc.bold("Clerk Authentication Setup:")}\n${pc.cyan("•")} Follow the guide: ${pc.underline("https://docs.convex.dev/auth/clerk")}\n${pc.cyan("•")} Set CLERK_JWT_ISSUER_DOMAIN in Convex Dashboard\n${pc.cyan("•")} Set CLERK_PUBLISHABLE_KEY in apps/*/.env`; return `${pc.bold("Clerk Authentication Setup:")}\n${pc.cyan("•")} Follow the guide: ${pc.underline("https://docs.convex.dev/auth/clerk")}\n${pc.cyan("•")} Set CLERK_JWT_ISSUER_DOMAIN in Convex Dashboard\n${pc.cyan("•")} Set CLERK_PUBLISHABLE_KEY in apps/*/.env`;
} }
function getPolarInstructions() {
return `${pc.bold("Polar Payments Setup:")}\n${pc.cyan("•")} Get access token & product ID from ${pc.underline("https://sandbox.polar.sh/")}\n${pc.cyan("•")} Set POLAR_ACCESS_TOKEN in apps/server/.env`;
}
function getAlchemyDeployInstructions( function getAlchemyDeployInstructions(
runCmd?: string, runCmd?: string,
webDeploy?: string, webDeploy?: string,

View File

@@ -607,6 +607,102 @@ export async function setupAuthTemplate(
} }
} }
export async function setupPaymentsTemplate(
projectDir: string,
context: ProjectConfig,
) {
if (!context.payments || context.payments === "none") return;
const serverAppDir = path.join(projectDir, "apps/server");
const webAppDir = path.join(projectDir, "apps/web");
const serverAppDirExists = await fs.pathExists(serverAppDir);
const webAppDirExists = await fs.pathExists(webAppDir);
if (serverAppDirExists && context.backend !== "convex") {
const paymentsServerSrc = path.join(
PKG_ROOT,
`templates/payments/${context.payments}/server/base`,
);
if (await fs.pathExists(paymentsServerSrc)) {
await processAndCopyFiles(
"**/*",
paymentsServerSrc,
serverAppDir,
context,
);
}
}
const hasReactWeb = context.frontend.some((f) =>
["tanstack-router", "react-router", "tanstack-start", "next"].includes(f),
);
const hasNuxtWeb = context.frontend.includes("nuxt");
const hasSvelteWeb = context.frontend.includes("svelte");
const hasSolidWeb = context.frontend.includes("solid");
if (
webAppDirExists &&
(hasReactWeb || hasNuxtWeb || hasSvelteWeb || hasSolidWeb)
) {
if (hasReactWeb) {
const reactFramework = context.frontend.find((f) =>
["tanstack-router", "react-router", "tanstack-start", "next"].includes(
f,
),
);
if (reactFramework) {
const paymentsWebSrc = path.join(
PKG_ROOT,
`templates/payments/${context.payments}/web/react/${reactFramework}`,
);
if (await fs.pathExists(paymentsWebSrc)) {
await processAndCopyFiles("**/*", paymentsWebSrc, webAppDir, context);
}
}
} else if (hasNuxtWeb) {
const paymentsWebNuxtSrc = path.join(
PKG_ROOT,
`templates/payments/${context.payments}/web/nuxt`,
);
if (await fs.pathExists(paymentsWebNuxtSrc)) {
await processAndCopyFiles(
"**/*",
paymentsWebNuxtSrc,
webAppDir,
context,
);
}
} else if (hasSvelteWeb) {
const paymentsWebSvelteSrc = path.join(
PKG_ROOT,
`templates/payments/${context.payments}/web/svelte`,
);
if (await fs.pathExists(paymentsWebSvelteSrc)) {
await processAndCopyFiles(
"**/*",
paymentsWebSvelteSrc,
webAppDir,
context,
);
}
} else if (hasSolidWeb) {
const paymentsWebSolidSrc = path.join(
PKG_ROOT,
`templates/payments/${context.payments}/web/solid`,
);
if (await fs.pathExists(paymentsWebSolidSrc)) {
await processAndCopyFiles(
"**/*",
paymentsWebSolidSrc,
webAppDir,
context,
);
}
}
}
}
export async function setupAddonsTemplate( export async function setupAddonsTemplate(
projectDir: string, projectDir: string,
context: ProjectConfig, context: ProjectConfig,

View File

@@ -32,6 +32,7 @@ import {
ORMSchema, ORMSchema,
type PackageManager, type PackageManager,
PackageManagerSchema, PackageManagerSchema,
PaymentsSchema,
type ProjectConfig, type ProjectConfig,
ProjectNameSchema, ProjectNameSchema,
type Runtime, type Runtime,
@@ -80,6 +81,7 @@ export const router = t.router({
database: DatabaseSchema.optional(), database: DatabaseSchema.optional(),
orm: ORMSchema.optional(), orm: ORMSchema.optional(),
auth: AuthSchema.optional(), auth: AuthSchema.optional(),
payments: PaymentsSchema.optional(),
frontend: z.array(FrontendSchema).optional(), frontend: z.array(FrontendSchema).optional(),
addons: z.array(AddonsSchema).optional(), addons: z.array(AddonsSchema).optional(),
examples: z.array(ExamplesSchema).optional(), examples: z.array(ExamplesSchema).optional(),

View File

@@ -1,10 +1,10 @@
import { groupMultiselect, isCancel } from "@clack/prompts"; import { groupMultiselect, isCancel } from "@clack/prompts";
import { DEFAULT_CONFIG } from "../constants"; import { DEFAULT_CONFIG } from "../constants";
import { type Addons, AddonsSchema, type Frontend } from "../types"; import { type Addons, AddonsSchema, type Auth, type Frontend } from "../types";
import { import {
getCompatibleAddons, getCompatibleAddons,
validateAddonCompatibility, validateAddonCompatibility,
} from "../utils/addon-compatibility"; } from "../utils/compatibility-rules";
import { exitCancelled } from "../utils/errors"; import { exitCancelled } from "../utils/errors";
type AddonOption = { type AddonOption = {
@@ -75,6 +75,7 @@ const ADDON_GROUPS = {
export async function getAddonsChoice( export async function getAddonsChoice(
addons?: Addons[], addons?: Addons[],
frontends?: Frontend[], frontends?: Frontend[],
auth?: Auth,
) { ) {
if (addons !== undefined) return addons; if (addons !== undefined) return addons;
@@ -88,7 +89,11 @@ export async function getAddonsChoice(
const frontendsArray = frontends || []; const frontendsArray = frontends || [];
for (const addon of allAddons) { for (const addon of allAddons) {
const { isCompatible } = validateAddonCompatibility(addon, frontendsArray); const { isCompatible } = validateAddonCompatibility(
addon,
frontendsArray,
auth,
);
if (!isCompatible) continue; if (!isCompatible) continue;
const { label, hint } = getAddonDisplay(addon); const { label, hint } = getAddonDisplay(addon);
@@ -131,6 +136,7 @@ export async function getAddonsChoice(
export async function getAddonsToAdd( export async function getAddonsToAdd(
frontend: Frontend[], frontend: Frontend[],
existingAddons: Addons[] = [], existingAddons: Addons[] = [],
auth?: Auth,
) { ) {
const groupedOptions: Record<string, AddonOption[]> = { const groupedOptions: Record<string, AddonOption[]> = {
Documentation: [], Documentation: [],
@@ -144,6 +150,7 @@ export async function getAddonsToAdd(
AddonsSchema.options.filter((addon) => addon !== "none"), AddonsSchema.options.filter((addon) => addon !== "none"),
frontendArray, frontendArray,
existingAddons, existingAddons,
auth,
); );
for (const addon of compatibleAddons) { for (const addon of compatibleAddons) {

View File

@@ -10,6 +10,7 @@ import type {
Frontend, Frontend,
ORM, ORM,
PackageManager, PackageManager,
Payments,
ProjectConfig, ProjectConfig,
Runtime, Runtime,
ServerDeploy, ServerDeploy,
@@ -28,6 +29,7 @@ import { getGitChoice } from "./git";
import { getinstallChoice } from "./install"; import { getinstallChoice } from "./install";
import { getORMChoice } from "./orm"; import { getORMChoice } from "./orm";
import { getPackageManagerChoice } from "./package-manager"; import { getPackageManagerChoice } from "./package-manager";
import { getPaymentsChoice } from "./payments";
import { getRuntimeChoice } from "./runtime"; import { getRuntimeChoice } from "./runtime";
import { getServerDeploymentChoice } from "./server-deploy"; import { getServerDeploymentChoice } from "./server-deploy";
import { getDeploymentChoice } from "./web-deploy"; import { getDeploymentChoice } from "./web-deploy";
@@ -40,6 +42,7 @@ type PromptGroupResults = {
orm: ORM; orm: ORM;
api: API; api: API;
auth: Auth; auth: Auth;
payments: Payments;
addons: Addons[]; addons: Addons[];
examples: Examples[]; examples: Examples[];
dbSetup: DatabaseSetup; dbSetup: DatabaseSetup;
@@ -87,7 +90,15 @@ export async function gatherConfig(
results.backend, results.backend,
results.frontend, results.frontend,
), ),
addons: ({ results }) => getAddonsChoice(flags.addons, results.frontend), payments: ({ results }) =>
getPaymentsChoice(
flags.payments,
results.auth,
results.backend,
results.frontend,
),
addons: ({ results }) =>
getAddonsChoice(flags.addons, results.frontend, results.auth),
examples: ({ results }) => examples: ({ results }) =>
getExamplesChoice( getExamplesChoice(
flags.examples, flags.examples,
@@ -137,6 +148,7 @@ export async function gatherConfig(
database: result.database, database: result.database,
orm: result.orm, orm: result.orm,
auth: result.auth, auth: result.auth,
payments: result.payments,
addons: result.addons, addons: result.addons,
examples: result.examples, examples: result.examples,
git: result.git, git: result.git,

View File

@@ -0,0 +1,46 @@
import { isCancel, select } from "@clack/prompts";
import { DEFAULT_CONFIG } from "../constants";
import type { Auth, Backend, Frontend, Payments } from "../types";
import { splitFrontends } from "../utils/compatibility-rules";
import { exitCancelled } from "../utils/errors";
export async function getPaymentsChoice(
payments?: Payments,
auth?: Auth,
backend?: Backend,
frontends?: Frontend[],
) {
if (payments !== undefined) return payments;
const isPolarCompatible =
auth === "better-auth" &&
backend !== "convex" &&
(frontends?.length === 0 || splitFrontends(frontends).web.length > 0);
if (!isPolarCompatible) {
return "none" as Payments;
}
const options = [
{
value: "polar" as Payments,
label: "Polar",
hint: "Turn your software into a business. 6 lines of code.",
},
{
value: "none" as Payments,
label: "None",
hint: "No payments integration",
},
];
const response = await select<Payments>({
message: "Select payments provider",
options,
initialValue: DEFAULT_CONFIG.payments,
});
if (isCancel(response)) return exitCancelled("Operation cancelled");
return response;
}

View File

@@ -86,6 +86,11 @@ export const AuthSchema = z
.describe("Authentication provider"); .describe("Authentication provider");
export type Auth = z.infer<typeof AuthSchema>; export type Auth = z.infer<typeof AuthSchema>;
export const PaymentsSchema = z
.enum(["polar", "none"])
.describe("Payments provider");
export type Payments = z.infer<typeof PaymentsSchema>;
export const ProjectNameSchema = z export const ProjectNameSchema = z
.string() .string()
.min(1, "Project name cannot be empty") .min(1, "Project name cannot be empty")
@@ -132,6 +137,7 @@ export type CreateInput = {
database?: Database; database?: Database;
orm?: ORM; orm?: ORM;
auth?: Auth; auth?: Auth;
payments?: Payments;
frontend?: Frontend[]; frontend?: Frontend[];
addons?: Addons[]; addons?: Addons[];
examples?: Examples[]; examples?: Examples[];
@@ -175,6 +181,7 @@ export interface ProjectConfig {
addons: Addons[]; addons: Addons[];
examples: Examples[]; examples: Examples[];
auth: Auth; auth: Auth;
payments: Payments;
git: boolean; git: boolean;
packageManager: PackageManager; packageManager: PackageManager;
install: boolean; install: boolean;
@@ -195,6 +202,7 @@ export interface BetterTStackConfig {
addons: Addons[]; addons: Addons[];
examples: Examples[]; examples: Examples[];
auth: Auth; auth: Auth;
payments: Payments;
packageManager: PackageManager; packageManager: PackageManager;
dbSetup: DatabaseSetup; dbSetup: DatabaseSetup;
api: API; api: API;

View File

@@ -1,42 +0,0 @@
import { ADDON_COMPATIBILITY } from "../constants";
import type { Addons, Frontend } from "../types";
export function validateAddonCompatibility(
addon: Addons,
frontend: Frontend[],
): { isCompatible: boolean; reason?: string } {
const compatibleFrontends = ADDON_COMPATIBILITY[addon];
if (compatibleFrontends.length === 0) {
return { isCompatible: true };
}
const hasCompatibleFrontend = frontend.some((f) =>
(compatibleFrontends as readonly string[]).includes(f),
);
if (!hasCompatibleFrontend) {
const frontendList = compatibleFrontends.join(", ");
return {
isCompatible: false,
reason: `${addon} addon requires one of these frontends: ${frontendList}`,
};
}
return { isCompatible: true };
}
export function getCompatibleAddons(
allAddons: Addons[],
frontend: Frontend[],
existingAddons: Addons[] = [],
) {
return allAddons.filter((addon) => {
if (existingAddons.includes(addon)) return false;
if (addon === "none") return false;
const { isCompatible } = validateAddonCompatibility(addon, frontend);
return isCompatible;
});
}

View File

@@ -18,6 +18,7 @@ export async function writeBtsConfig(projectConfig: ProjectConfig) {
addons: projectConfig.addons, addons: projectConfig.addons,
examples: projectConfig.examples, examples: projectConfig.examples,
auth: projectConfig.auth, auth: projectConfig.auth,
payments: projectConfig.payments,
packageManager: projectConfig.packageManager, packageManager: projectConfig.packageManager,
dbSetup: projectConfig.dbSetup, dbSetup: projectConfig.dbSetup,
api: projectConfig.api, api: projectConfig.api,

View File

@@ -1,14 +1,16 @@
import { ADDON_COMPATIBILITY } from "../constants";
import type { import type {
Addons, Addons,
API, API,
Auth,
Backend, Backend,
CLIInput, CLIInput,
Frontend, Frontend,
Payments,
ProjectConfig, ProjectConfig,
ServerDeploy, ServerDeploy,
WebDeploy, WebDeploy,
} from "../types"; } from "../types";
import { validateAddonCompatibility } from "./addon-compatibility";
import { WEB_FRAMEWORKS } from "./compatibility"; import { WEB_FRAMEWORKS } from "./compatibility";
import { exitWithError } from "./errors"; import { exitWithError } from "./errors";
@@ -191,15 +193,57 @@ export function validateServerDeployRequiresBackend(
} }
} }
export function validateAddonCompatibility(
addon: Addons,
frontend: Frontend[],
_auth?: Auth,
): { isCompatible: boolean; reason?: string } {
const compatibleFrontends = ADDON_COMPATIBILITY[addon];
if (compatibleFrontends.length > 0) {
const hasCompatibleFrontend = frontend.some((f) =>
(compatibleFrontends as readonly string[]).includes(f),
);
if (!hasCompatibleFrontend) {
const frontendList = compatibleFrontends.join(", ");
return {
isCompatible: false,
reason: `${addon} addon requires one of these frontends: ${frontendList}`,
};
}
}
return { isCompatible: true };
}
export function getCompatibleAddons(
allAddons: Addons[],
frontend: Frontend[],
existingAddons: Addons[] = [],
auth?: Auth,
) {
return allAddons.filter((addon) => {
if (existingAddons.includes(addon)) return false;
if (addon === "none") return false;
const { isCompatible } = validateAddonCompatibility(addon, frontend, auth);
return isCompatible;
});
}
export function validateAddonsAgainstFrontends( export function validateAddonsAgainstFrontends(
addons: Addons[] = [], addons: Addons[] = [],
frontends: Frontend[] = [], frontends: Frontend[] = [],
auth?: Auth,
) { ) {
for (const addon of addons) { for (const addon of addons) {
if (addon === "none") continue; if (addon === "none") continue;
const { isCompatible, reason } = validateAddonCompatibility( const { isCompatible, reason } = validateAddonCompatibility(
addon, addon,
frontends, frontends,
auth,
); );
if (!isCompatible) { if (!isCompatible) {
exitWithError(`Incompatible addon/frontend combination: ${reason}`); exitWithError(`Incompatible addon/frontend combination: ${reason}`);
@@ -207,6 +251,36 @@ export function validateAddonsAgainstFrontends(
} }
} }
export function validatePaymentsCompatibility(
payments: Payments | undefined,
auth: Auth | undefined,
backend: Backend | undefined,
frontends: Frontend[] = [],
) {
if (!payments || payments === "none") return;
if (payments === "polar") {
if (!auth || auth === "none" || auth !== "better-auth") {
exitWithError(
"Polar payments requires Better Auth. Please use '--auth better-auth' or choose a different payments provider.",
);
}
if (backend === "convex") {
exitWithError(
"Polar payments is not compatible with Convex backend. Please use a different backend or choose a different payments provider.",
);
}
const { web } = splitFrontends(frontends);
if (web.length === 0 && frontends.length > 0) {
exitWithError(
"Polar payments requires a web frontend or no frontend. Please select a web frontend or choose a different payments provider.",
);
}
}
}
export function validateExamplesCompatibility( export function validateExamplesCompatibility(
examples: string[] | undefined, examples: string[] | undefined,
backend: ProjectConfig["backend"] | undefined, backend: ProjectConfig["backend"] | undefined,

View File

@@ -8,6 +8,7 @@ import type {
DatabaseSetup, DatabaseSetup,
ORM, ORM,
PackageManager, PackageManager,
Payments,
ProjectConfig, ProjectConfig,
Runtime, Runtime,
ServerDeploy, ServerDeploy,
@@ -56,6 +57,10 @@ export function processFlags(options: CLIInput, projectName?: string) {
config.auth = options.auth as Auth; config.auth = options.auth as Auth;
} }
if (options.payments !== undefined) {
config.payments = options.payments as Payments;
}
if (options.git !== undefined) { if (options.git !== undefined) {
config.git = options.git; config.git = options.git;
} }

View File

@@ -11,6 +11,7 @@ import {
validateAddonsAgainstFrontends, validateAddonsAgainstFrontends,
validateApiFrontendCompatibility, validateApiFrontendCompatibility,
validateExamplesCompatibility, validateExamplesCompatibility,
validatePaymentsCompatibility,
validateServerDeployRequiresBackend, validateServerDeployRequiresBackend,
validateWebDeployRequiresWebFrontend, validateWebDeployRequiresWebFrontend,
validateWorkersCompatibility, validateWorkersCompatibility,
@@ -417,7 +418,7 @@ export function validateFullConfig(
} }
if (config.addons && config.addons.length > 0) { if (config.addons && config.addons.length > 0) {
validateAddonsAgainstFrontends(config.addons, config.frontend); validateAddonsAgainstFrontends(config.addons, config.frontend, config.auth);
config.addons = [...new Set(config.addons)]; config.addons = [...new Set(config.addons)];
} }
@@ -441,8 +442,19 @@ export function validateConfigForProgrammaticUse(
validateApiFrontendCompatibility(config.api, config.frontend); validateApiFrontendCompatibility(config.api, config.frontend);
validatePaymentsCompatibility(
config.payments,
config.auth,
config.backend,
config.frontend,
);
if (config.addons && config.addons.length > 0) { if (config.addons && config.addons.length > 0) {
validateAddonsAgainstFrontends(config.addons, config.frontend); validateAddonsAgainstFrontends(
config.addons,
config.frontend,
config.auth,
);
} }
validateExamplesCompatibility( validateExamplesCompatibility(

View File

@@ -43,6 +43,10 @@ export function displayConfig(config: Partial<ProjectConfig>) {
configDisplay.push(`${pc.blue("Auth:")} ${String(config.auth)}`); configDisplay.push(`${pc.blue("Auth:")} ${String(config.auth)}`);
} }
if (config.payments !== undefined) {
configDisplay.push(`${pc.blue("Payments:")} ${String(config.payments)}`);
}
if (config.addons !== undefined) { if (config.addons !== undefined) {
const addons = Array.isArray(config.addons) const addons = Array.isArray(config.addons)
? config.addons ? config.addons

View File

@@ -15,6 +15,7 @@ export function generateReproducibleCommand(config: ProjectConfig) {
flags.push(`--orm ${config.orm}`); flags.push(`--orm ${config.orm}`);
flags.push(`--api ${config.api}`); flags.push(`--api ${config.api}`);
flags.push(`--auth ${config.auth}`); flags.push(`--auth ${config.auth}`);
flags.push(`--payments ${config.payments}`);
if (config.addons && config.addons.length > 0) { if (config.addons && config.addons.length > 0) {
flags.push(`--addons ${config.addons.join(" ")}`); flags.push(`--addons ${config.addons.join(" ")}`);

View File

@@ -4,6 +4,10 @@ import { prismaAdapter } from "better-auth/adapters/prisma";
{{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}} {{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}}
import { expo } from "@better-auth/expo"; import { expo } from "@better-auth/expo";
{{/if}} {{/if}}
{{#if (eq payments "polar")}}
import { polar, checkout, portal } from "@polar-sh/better-auth";
import { polarClient } from "./payments";
{{/if}}
import prisma from "../db"; import prisma from "../db";
export const auth = betterAuth<BetterAuthOptions>({ export const auth = betterAuth<BetterAuthOptions>({
@@ -28,9 +32,35 @@ export const auth = betterAuth<BetterAuthOptions>({
secure: true, secure: true,
httpOnly: true, httpOnly: true,
}, },
} },
{{#if (eq payments "polar")}}
plugins: [
polar({
client: polarClient,
createCustomerOnSignUp: true,
enableCustomerPortal: true,
use: [
checkout({
products: [
{
productId: "your-product-id",
slug: "pro",
},
],
successUrl: process.env.POLAR_SUCCESS_URL,
authenticatedUsersOnly: true,
}),
portal(),
],
}),
{{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}} {{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}}
, plugins: [expo()] expo(),
{{/if}}
],
{{else}}
{{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}}
plugins: [expo()],
{{/if}}
{{/if}} {{/if}}
}); });
{{/if}} {{/if}}
@@ -42,6 +72,10 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle";
{{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}} {{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}}
import { expo } from "@better-auth/expo"; import { expo } from "@better-auth/expo";
{{/if}} {{/if}}
{{#if (eq payments "polar")}}
import { polar, checkout, portal } from "@polar-sh/better-auth";
import { polarClient } from "./payments";
{{/if}}
import { db } from "../db"; import { db } from "../db";
import * as schema from "../db/schema/auth"; import * as schema from "../db/schema/auth";
@@ -68,9 +102,35 @@ export const auth = betterAuth<BetterAuthOptions>({
httpOnly: true, httpOnly: true,
}, },
}, },
{{#if (eq payments "polar")}}
plugins: [
polar({
client: polarClient,
createCustomerOnSignUp: true,
enableCustomerPortal: true,
use: [
checkout({
products: [
{
productId: "your-product-id",
slug: "pro",
},
],
successUrl: process.env.POLAR_SUCCESS_URL,
authenticatedUsersOnly: true,
}),
portal(),
],
}),
{{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}}
expo(),
{{/if}}
],
{{else}}
{{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}} {{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}}
plugins: [expo()], plugins: [expo()],
{{/if}} {{/if}}
{{/if}}
}); });
{{/if}} {{/if}}
@@ -80,6 +140,10 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle";
{{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}} {{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}}
import { expo } from "@better-auth/expo"; import { expo } from "@better-auth/expo";
{{/if}} {{/if}}
{{#if (eq payments "polar")}}
import { polar, checkout, portal } from "@polar-sh/better-auth";
import { polarClient } from "./payments";
{{/if}}
import { db } from "../db"; import { db } from "../db";
import * as schema from "../db/schema/auth"; import * as schema from "../db/schema/auth";
import { env } from "cloudflare:workers"; import { env } from "cloudflare:workers";
@@ -109,9 +173,32 @@ export const auth = betterAuth<BetterAuthOptions>({
httpOnly: true, httpOnly: true,
}, },
}, },
{{#if (eq payments "polar")}}
plugins: [
polar({
client: polarClient,
createCustomerOnSignUp: true,
enableCustomerPortal: true,
use: [
checkout({
products: [
{
productId: "your-product-id",
slug: "pro",
},
],
successUrl: env.POLAR_SUCCESS_URL,
authenticatedUsersOnly: true,
}),
portal(),
],
}),
],
{{else}}
{{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}} {{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}}
plugins: [expo()], plugins: [expo()],
{{/if}} {{/if}}
{{/if}}
}); });
{{/if}} {{/if}}
{{/if}} {{/if}}
@@ -122,6 +209,10 @@ import { mongodbAdapter } from "better-auth/adapters/mongodb";
{{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}} {{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}}
import { expo } from "@better-auth/expo"; import { expo } from "@better-auth/expo";
{{/if}} {{/if}}
{{#if (eq payments "polar")}}
import { polar, checkout, portal } from "@polar-sh/better-auth";
import { polarClient } from "./payments";
{{/if}}
import { client } from "../db"; import { client } from "../db";
export const auth = betterAuth<BetterAuthOptions>({ export const auth = betterAuth<BetterAuthOptions>({
@@ -141,9 +232,35 @@ export const auth = betterAuth<BetterAuthOptions>({
secure: true, secure: true,
httpOnly: true, httpOnly: true,
}, },
} },
{{#if (eq payments "polar")}}
plugins: [
polar({
client: polarClient,
createCustomerOnSignUp: true,
enableCustomerPortal: true,
use: [
checkout({
products: [
{
productId: "your-product-id",
slug: "pro",
},
],
successUrl: process.env.POLAR_SUCCESS_URL,
authenticatedUsersOnly: true,
}),
portal(),
],
}),
{{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}} {{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}}
, plugins: [expo()] expo(),
{{/if}}
],
{{else}}
{{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}}
plugins: [expo()],
{{/if}}
{{/if}} {{/if}}
}); });
{{/if}} {{/if}}
@@ -153,6 +270,10 @@ import { betterAuth, type BetterAuthOptions } from "better-auth";
{{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}} {{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}}
import { expo } from "@better-auth/expo"; import { expo } from "@better-auth/expo";
{{/if}} {{/if}}
{{#if (eq payments "polar")}}
import { polar, checkout, portal } from "@polar-sh/better-auth";
import { polarClient } from "./payments";
{{/if}}
export const auth = betterAuth<BetterAuthOptions>({ export const auth = betterAuth<BetterAuthOptions>({
database: "", // Invalid configuration database: "", // Invalid configuration
@@ -171,9 +292,35 @@ export const auth = betterAuth<BetterAuthOptions>({
secure: true, secure: true,
httpOnly: true, httpOnly: true,
}, },
} },
{{#if (eq payments "polar")}}
plugins: [
polar({
client: polarClient,
createCustomerOnSignUp: true,
enableCustomerPortal: true,
use: [
checkout({
products: [
{
productId: "your-product-id",
slug: "pro",
},
],
successUrl: process.env.POLAR_SUCCESS_URL,
authenticatedUsersOnly: true,
}),
portal(),
],
}),
{{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}} {{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}}
, plugins: [expo()] expo(),
{{/if}}
],
{{else}}
{{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}}
plugins: [expo()],
{{/if}}
{{/if}} {{/if}}
}); });
{{/if}} {{/if}}

View File

@@ -11,9 +11,28 @@ definePageMeta({
const { $orpc } = useNuxtApp() const { $orpc } = useNuxtApp()
const session = $authClient.useSession() const session = $authClient.useSession()
{{#if (eq payments "polar")}}
const customerState = ref<any>(null)
{{/if}}
{{#if (eq api "orpc")}} {{#if (eq api "orpc")}}
const privateData = useQuery($orpc.privateData.queryOptions()) const privateData = useQuery({
...$orpc.privateData.queryOptions(),
enabled: computed(() => !!session.value?.data?.user)
})
{{/if}}
{{#if (eq payments "polar")}}
onMounted(async () => {
if (session.value?.data) {
const { data } = await $authClient.customer.state()
customerState.value = data
}
})
const hasProSubscription = computed(() =>
customerState.value?.activeSubscriptions?.length! > 0
)
{{/if}} {{/if}}
</script> </script>
@@ -27,7 +46,22 @@ const privateData = useQuery($orpc.privateData.queryOptions())
{{#if (eq api "orpc")}} {{#if (eq api "orpc")}}
<div v-if="privateData.status.value === 'pending'">Loading private data...</div> <div v-if="privateData.status.value === 'pending'">Loading private data...</div>
<div v-else-if="privateData.status.value === 'error'">Error loading private data: \{{ privateData.error.value?.message }}</div> <div v-else-if="privateData.status.value === 'error'">Error loading private data: \{{ privateData.error.value?.message }}</div>
<p v-else-if="privateData.data.value">Private Data: \{{ privateData.data.value.message }}</p> <p v-else-if="privateData.data.value">API: \{{ privateData.data.value.message }}</p>
{{/if}}
{{#if (eq payments "polar")}}
<p class="mb-2">Plan: \{{ hasProSubscription ? "Pro" : "Free" }}</p>
<UButton
v-if="hasProSubscription"
@click="() => { $authClient.customer.portal() }"
>
Manage Subscription
</UButton>
<UButton
v-else
@click="() => { $authClient.checkout({ slug: 'pro' }) }"
>
Upgrade to Pro
</UButton>
{{/if}} {{/if}}
</div> </div>
</template> </template>

View File

@@ -1,16 +0,0 @@
import { createAuthClient } from "better-auth/vue";
export default defineNuxtPlugin(nuxtApp => {
const config = useRuntimeConfig()
const serverUrl = config.public.serverURL
const authClient = createAuthClient({
baseURL: serverUrl
})
return {
provide: {
authClient: authClient
}
}
})

View File

@@ -0,0 +1,22 @@
import { createAuthClient } from "better-auth/vue";
{{#if (eq payments "polar")}}
import { polarClient } from "@polar-sh/better-auth";
{{/if}}
export default defineNuxtPlugin((nuxtApp) => {
const config = useRuntimeConfig();
const serverUrl = config.public.serverURL;
const authClient = createAuthClient({
baseURL: serverUrl,
{{#if (eq payments "polar")}}
plugins: [polarClient()],
{{/if}}
});
return {
provide: {
authClient: authClient,
},
};
});

View File

@@ -1,4 +1,7 @@
import { createAuthClient } from "better-auth/react"; import { createAuthClient } from "better-auth/react";
{{#if (eq payments "polar")}}
import { polarClient } from "@polar-sh/better-auth";
{{/if}}
export const authClient = createAuthClient({ export const authClient = createAuthClient({
baseURL: baseURL:
@@ -7,4 +10,7 @@ export const authClient = createAuthClient({
{{else}} {{else}}
import.meta.env.VITE_SERVER_URL, import.meta.env.VITE_SERVER_URL,
{{/if}} {{/if}}
{{#if (eq payments "polar")}}
plugins: [polarClient()]
{{/if}}
}); });

View File

@@ -0,0 +1,58 @@
"use client";
import { Button } from "@/components/ui/button";
import { authClient } from "@/lib/auth-client";
{{#if (eq api "orpc")}}
import { useQuery } from "@tanstack/react-query";
import { orpc } from "@/utils/orpc";
{{/if}}
{{#if (eq api "trpc")}}
import { useQuery } from "@tanstack/react-query";
import { trpc } from "@/utils/trpc";
{{/if}}
export default function Dashboard({
{{#if (eq payments "polar")}}
customerState,
{{/if}}
session
}: {
{{#if (eq payments "polar")}}
customerState: ReturnType<typeof authClient.customer.state>;
{{/if}}
session: typeof authClient.$Infer.Session;
}) {
{{#if (eq api "orpc")}}
const privateData = useQuery(orpc.privateData.queryOptions());
{{/if}}
{{#if (eq api "trpc")}}
const privateData = useQuery(trpc.privateData.queryOptions());
{{/if}}
{{#if (eq payments "polar")}}
const hasProSubscription = customerState?.activeSubscriptions?.length! > 0;
console.log("Active subscriptions:", customerState?.activeSubscriptions);
{{/if}}
return (
<>
{{#if (eq api "orpc")}}
<p>API: {privateData.data?.message}</p>
{{/if}}
{{#if (eq api "trpc")}}
<p>API: {privateData.data?.message}</p>
{{/if}}
{{#if (eq payments "polar")}}
<p>Plan: {hasProSubscription ? "Pro" : "Free"}</p>
{hasProSubscription ? (
<Button onClick={async () => await authClient.customer.portal()}>
Manage Subscription
</Button>
) : (
<Button onClick={async () => await authClient.checkout({ slug: "pro" })}>
Upgrade to Pro
</Button>
)}
{{/if}}
</>
);
}

View File

@@ -1,47 +1,37 @@
"use client"
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
{{#if (eq api "orpc")}} import { redirect } from "next/navigation";
import { useQuery } from "@tanstack/react-query"; import Dashboard from "./dashboard";
import { orpc } from "@/utils/orpc"; import { headers } from "next/headers";
{{/if}}
{{#if (eq api "trpc")}}
import { useQuery } from "@tanstack/react-query";
import { trpc } from "@/utils/trpc";
{{/if}}
import { useRouter } from "next/navigation";
import { useEffect } from "react";
export default function Dashboard() { export default async function DashboardPage() {
const router = useRouter(); const session = await authClient.getSession({
const { data: session, isPending } = authClient.useSession(); fetchOptions: {
headers: await headers()
{{#if (eq api "orpc")}}
const privateData = useQuery(orpc.privateData.queryOptions());
{{/if}}
{{#if (eq api "trpc")}}
const privateData = useQuery(trpc.privateData.queryOptions());
{{/if}}
useEffect(() => {
if (!session && !isPending) {
router.push("/login");
} }
}, [session, isPending]); });
if (isPending) { if (!session.data) {
return <div>Loading...</div>; redirect("/login");
} }
{{#if (eq payments "polar")}}
const { data: customerState, error } = await authClient.customer.state({
fetchOptions: {
headers: await headers()
}
});
{{/if}}
return ( return (
<div> <div>
<h1>Dashboard</h1> <h1>Dashboard</h1>
<p>Welcome {session?.user.name}</p> <p>Welcome {session.data.user.name}</p>
{{#if (eq api "orpc")}} <Dashboard
<p>privateData: {privateData.data?.message}</p> session={session.data}
{{/if}} {{#if (eq payments "polar")}}
{{#if (eq api "trpc")}} customerState={customerState}
<p>privateData: {privateData.data?.message}</p>
{{/if}} {{/if}}
/>
</div> </div>
); );
} }

View File

@@ -1,3 +1,4 @@
import { Button } from "@/components/ui/button";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
{{#if (eq api "orpc")}} {{#if (eq api "orpc")}}
import { orpc } from "@/utils/orpc"; import { orpc } from "@/utils/orpc";
@@ -8,12 +9,15 @@ import { trpc } from "@/utils/trpc";
{{#if ( or (eq api "orpc") (eq api "trpc"))}} {{#if ( or (eq api "orpc") (eq api "trpc"))}}
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
{{/if}} {{/if}}
import { useEffect } from "react"; import { useEffect, useState } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
export default function Dashboard() { export default function Dashboard() {
const { data: session, isPending } = authClient.useSession(); const { data: session, isPending } = authClient.useSession();
const navigate = useNavigate(); const navigate = useNavigate();
{{#if (eq payments "polar")}}
const [customerState, setCustomerState] = useState<any>(null);
{{/if}}
{{#if (eq api "orpc")}} {{#if (eq api "orpc")}}
const privateData = useQuery(orpc.privateData.queryOptions()); const privateData = useQuery(orpc.privateData.queryOptions());
@@ -26,18 +30,48 @@ export default function Dashboard() {
if (!session && !isPending) { if (!session && !isPending) {
navigate("/login"); navigate("/login");
} }
}, [session, isPending]); }, [session, isPending, navigate]);
{{#if (eq payments "polar")}}
useEffect(() => {
async function fetchCustomerState() {
if (session) {
const { data } = await authClient.customer.state();
setCustomerState(data);
}
}
fetchCustomerState();
}, [session]);
{{/if}}
if (isPending) { if (isPending) {
return <div>Loading...</div>; return <div>Loading...</div>;
} }
{{#if (eq payments "polar")}}
const hasProSubscription = customerState?.activeSubscriptions?.length! > 0;
console.log("Active subscriptions:", customerState?.activeSubscriptions);
{{/if}}
return ( return (
<div> <div>
<h1>Dashboard</h1> <h1>Dashboard</h1>
<p>Welcome {session?.user.name}</p> <p>Welcome {session?.user.name}</p>
{{#if ( or (eq api "orpc") (eq api "trpc"))}} {{#if ( or (eq api "orpc") (eq api "trpc"))}}
<p>privateData: {privateData.data?.message}</p> <p>API: {privateData.data?.message}</p>
{{/if}}
{{#if (eq payments "polar")}}
<p>Plan: {hasProSubscription ? "Pro" : "Free"}</p>
{hasProSubscription ? (
<Button onClick={async () => await authClient.customer.portal()}>
Manage Subscription
</Button>
) : (
<Button onClick={async () => await authClient.checkout({ slug: "pro" })}>
Upgrade to Pro
</Button>
)}
{{/if}} {{/if}}
</div> </div>
); );

View File

@@ -1,3 +1,4 @@
import { Button } from "@/components/ui/button";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
{{#if (eq api "orpc")}} {{#if (eq api "orpc")}}
import { orpc } from "@/utils/orpc"; import { orpc } from "@/utils/orpc";
@@ -8,17 +9,29 @@ import { trpc } from "@/utils/trpc";
{{#if ( or (eq api "orpc") (eq api "trpc"))}} {{#if ( or (eq api "orpc") (eq api "trpc"))}}
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
{{/if}} {{/if}}
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute, redirect } from "@tanstack/react-router";
import { useEffect } from "react";
export const Route = createFileRoute("/dashboard")({ export const Route = createFileRoute("/dashboard")({
component: RouteComponent, component: RouteComponent,
beforeLoad: async () => {
const session = await authClient.getSession();
if (!session.data) {
redirect({
to: "/login",
throw: true
});
}
{{#if (eq payments "polar")}}
const {data: customerState} = await authClient.customer.state()
return { session, customerState };
{{else}}
return { session };
{{/if}}
}
}); });
function RouteComponent() { function RouteComponent() {
const { data: session, isPending } = authClient.useSession(); const { session{{#if (eq payments "polar")}}, customerState{{/if}} } = Route.useRouteContext();
const navigate = Route.useNavigate();
{{#if (eq api "orpc")}} {{#if (eq api "orpc")}}
const privateData = useQuery(orpc.privateData.queryOptions()); const privateData = useQuery(orpc.privateData.queryOptions());
@@ -27,24 +40,29 @@ function RouteComponent() {
const privateData = useQuery(trpc.privateData.queryOptions()); const privateData = useQuery(trpc.privateData.queryOptions());
{{/if}} {{/if}}
useEffect(() => { {{#if (eq payments "polar")}}
if (!session && !isPending) { const hasProSubscription = customerState?.activeSubscriptions?.length! > 0
navigate({ console.log("Active subscriptions:", customerState?.activeSubscriptions)
to: "/login", {{/if}}
});
}
}, [session, isPending]);
if (isPending) {
return <div>Loading...</div>;
}
return ( return (
<div> <div>
<h1>Dashboard</h1> <h1>Dashboard</h1>
<p>Welcome {session?.user.name}</p> <p>Welcome {session.data?.user.name}</p>
{{#if ( or (eq api "orpc") (eq api "trpc"))}} {{#if ( or (eq api "orpc") (eq api "trpc"))}}
<p>privateData: {privateData.data?.message}</p> <p>API: {privateData.data?.message}</p>
{{/if}}
{{#if (eq payments "polar")}}
<p>Plan: {hasProSubscription ? "Pro" : "Free"}</p>
{hasProSubscription ? (
<Button onClick={async () => await authClient.customer.portal()}>
Manage Subscription
</Button>
) : (
<Button onClick={async () => await authClient.checkout({ slug: "pro" })}>
Upgrade to Pro
</Button>
)}
{{/if}} {{/if}}
</div> </div>
); );

View File

@@ -1,3 +1,4 @@
import { Button } from "@/components/ui/button";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
{{#if (eq api "trpc")}} {{#if (eq api "trpc")}}
import { useTRPC } from "@/utils/trpc"; import { useTRPC } from "@/utils/trpc";
@@ -7,47 +8,61 @@ import { useQuery } from "@tanstack/react-query";
import { orpc } from "@/utils/orpc"; import { orpc } from "@/utils/orpc";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
{{/if}} {{/if}}
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute, redirect } from "@tanstack/react-router";
import { useEffect } from "react";
export const Route = createFileRoute("/dashboard")({ export const Route = createFileRoute("/dashboard")({
component: RouteComponent, component: RouteComponent,
beforeLoad: async () => {
const session = await authClient.getSession();
if (!session.data) {
redirect({
to: "/login",
throw: true
});
}
{{#if (eq payments "polar")}}
const {data: customerState} = await authClient.customer.state()
return { session, customerState };
{{else}}
return { session };
{{/if}}
}
}); });
function RouteComponent() { function RouteComponent() {
const navigate = Route.useNavigate(); const { session{{#if (eq payments "polar")}}, customerState{{/if}} } = Route.useRouteContext();
{{#if (eq api "trpc")}}
const trpc = useTRPC();
{{/if}}
{{#if (eq api "orpc")}}
{{/if}}
const { data: session, isPending } = authClient.useSession();
{{#if (eq api "trpc")}} {{#if (eq api "trpc")}}
const trpc = useTRPC();
const privateData = useQuery(trpc.privateData.queryOptions()); const privateData = useQuery(trpc.privateData.queryOptions());
{{/if}} {{/if}}
{{#if (eq api "orpc")}} {{#if (eq api "orpc")}}
const privateData = useQuery(orpc.privateData.queryOptions()); const privateData = useQuery(orpc.privateData.queryOptions());
{{/if}} {{/if}}
useEffect(() => { {{#if (eq payments "polar")}}
if (!session && !isPending) { const hasProSubscription = customerState?.activeSubscriptions?.length! > 0
navigate({ console.log("Active subscriptions:", customerState?.activeSubscriptions)
to: "/login", {{/if}}
});
}
}, [session, isPending]);
if (isPending) {
return <div>Loading...</div>;
}
return ( return (
<div> <div>
<h1>Dashboard</h1> <h1>Dashboard</h1>
<p>Welcome {session?.user.name}</p> <p>Welcome {session.data?.user.name}</p>
{{#if ( or (eq api "orpc") (eq api "trpc"))}} {{#if ( or (eq api "orpc") (eq api "trpc"))}}
<p>privateData: {privateData.data?.message}</p> <p>API: {privateData.data?.message}</p>
{{/if}}
{{#if (eq payments "polar")}}
<p>Plan: {hasProSubscription ? "Pro" : "Free"}</p>
{hasProSubscription ? (
<Button onClick={async () => await authClient.customer.portal()}>
Manage Subscription
</Button>
) : (
<Button onClick={async () => await authClient.checkout({ slug: "pro" })}>
Upgrade to Pro
</Button>
)}
{{/if}} {{/if}}
</div> </div>
); );

View File

@@ -1,5 +0,0 @@
import { createAuthClient } from "better-auth/solid";
export const authClient = createAuthClient({
baseURL: import.meta.env.VITE_SERVER_URL,
});

View File

@@ -0,0 +1,11 @@
import { createAuthClient } from "better-auth/solid";
{{#if (eq payments "polar")}}
import { polarClient } from "@polar-sh/better-auth";
{{/if}}
export const authClient = createAuthClient({
baseURL: import.meta.env.VITE_SERVER_URL,
{{#if (eq payments "polar")}}
plugins: [polarClient()]
{{/if}}
});

View File

@@ -3,42 +3,65 @@ import { authClient } from "@/lib/auth-client";
import { orpc } from "@/utils/orpc"; import { orpc } from "@/utils/orpc";
import { useQuery } from "@tanstack/solid-query"; import { useQuery } from "@tanstack/solid-query";
{{/if}} {{/if}}
import { createFileRoute } from "@tanstack/solid-router"; import { createFileRoute, redirect } from "@tanstack/solid-router";
import { createEffect, Show } from "solid-js";
export const Route = createFileRoute("/dashboard")({ export const Route = createFileRoute("/dashboard")({
component: RouteComponent, component: RouteComponent,
beforeLoad: async () => {
const session = await authClient.getSession();
if (!session.data) {
redirect({
to: "/login",
throw: true,
});
}
{{#if (eq payments "polar")}}
const { data: customerState } = await authClient.customer.state();
return { session, customerState };
{{else}}
return { session };
{{/if}}
},
}); });
function RouteComponent() { function RouteComponent() {
const session = authClient.useSession(); const context = Route.useRouteContext();
const navigate = Route.useNavigate();
{{#if (eq api "orpc")}} const session = context().session;
{{#if (eq payments "polar")}}
const customerState = context().customerState;
{{/if}}
{{#if (eq api "orpc")}}
const privateData = useQuery(() => orpc.privateData.queryOptions()); const privateData = useQuery(() => orpc.privateData.queryOptions());
{{/if}} {{/if}}
createEffect(() => { {{#if (eq payments "polar")}}
if (!session().data && !session().isPending) { const hasProSubscription = () =>
navigate({ customerState?.activeSubscriptions?.length! > 0;
to: "/login", {{/if}}
});
}
});
return ( return (
<div> <div>
<Show when={session().isPending}>
<div>Loading...</div>
</Show>
<Show when={!session().isPending && session().data}>
<h1>Dashboard</h1> <h1>Dashboard</h1>
<p>Welcome {session().data?.user.name}</p> <p>Welcome {session.data?.user.name}</p>
{{#if (eq api "orpc")}} {{#if (eq api "orpc")}}
<p>privateData: {privateData.data?.message}</p> <p>API: {privateData.data?.message}</p>
{{/if}}
{{#if (eq payments "polar")}}
<p>Plan: {hasProSubscription() ? "Pro" : "Free"}</p>
{hasProSubscription() ? (
<button onClick={async () => await authClient.customer.portal()}>
Manage Subscription
</button>
) : (
<button
onClick={async () => await authClient.checkout({ slug: "pro" })}
>
Upgrade to Pro
</button>
)}
{{/if}} {{/if}}
</Show>
</div> </div>
); );
} }

View File

@@ -1,6 +1,12 @@
import { PUBLIC_SERVER_URL } from "$env/static/public"; import { PUBLIC_SERVER_URL } from "$env/static/public";
import { createAuthClient } from "better-auth/svelte"; import { createAuthClient } from "better-auth/svelte";
{{#if (eq payments "polar")}}
import { polarClient } from "@polar-sh/better-auth";
{{/if}}
export const authClient = createAuthClient({ export const authClient = createAuthClient({
baseURL: PUBLIC_SERVER_URL, baseURL: PUBLIC_SERVER_URL,
{{#if (eq payments "polar")}}
plugins: [polarClient()]
{{/if}}
}); });

View File

@@ -6,7 +6,9 @@
import { orpc } from '$lib/orpc'; import { orpc } from '$lib/orpc';
import { createQuery } from '@tanstack/svelte-query'; import { createQuery } from '@tanstack/svelte-query';
{{/if}} {{/if}}
import { get } from 'svelte/store'; {{#if (eq payments "polar")}}
let customerState: any = null;
{{/if}}
const sessionQuery = authClient.useSession(); const sessionQuery = authClient.useSession();
@@ -15,10 +17,17 @@
{{/if}} {{/if}}
onMount(() => { onMount(() => {
const { data: session, isPending } = get(sessionQuery); const { data: session, isPending } = $sessionQuery;
if (!session && !isPending) { if (!session && !isPending) {
goto('/login'); goto('/login');
} }
{{#if (eq payments "polar")}}
if (session) {
authClient.customer.state().then(({ data }) => {
customerState = data;
});
}
{{/if}}
}); });
</script> </script>
@@ -30,7 +39,19 @@
<h1>Dashboard</h1> <h1>Dashboard</h1>
<p>Welcome {$sessionQuery.data.user.name}</p> <p>Welcome {$sessionQuery.data.user.name}</p>
{{#if (eq api "orpc")}} {{#if (eq api "orpc")}}
<p>privateData: {$privateDataQuery.data?.message}</p> <p>API: {$privateDataQuery.data?.message}</p>
{{/if}}
{{#if (eq payments "polar")}}
<p>Plan: {customerState?.activeSubscriptions?.length > 0 ? "Pro" : "Free"}</p>
{#if customerState?.activeSubscriptions?.length > 0}
<button onclick={async () => await authClient.customer.portal()}>
Manage Subscription
</button>
{:else}
<button onclick={async () => await authClient.checkout({ slug: "pro" })}>
Upgrade to Pro
</button>
{/if}
{{/if}} {{/if}}
</div> </div>
{/if} {/if}

View File

@@ -17,7 +17,7 @@
], ],
{{/if}} {{/if}}
"devDependencies": { "devDependencies": {
"tsdown": "^0.14.1", "tsdown": "^0.15.1",
"typescript": "^5.8.2" "typescript": "^5.8.2"
} }
} }

View File

@@ -1,2 +1,6 @@
[install] [install]
{{#if (includes frontend "nuxt")}}
# linker = "isolated" # Commented out for Nuxt compatibility
{{else}}
linker = "isolated" linker = "isolated"
{{/if}}

View File

@@ -17,6 +17,10 @@ import appCss from "../index.css?url";
import type { QueryClient } from "@tanstack/react-query"; import type { QueryClient } from "@tanstack/react-query";
import type { ConvexQueryClient } from "@convex-dev/react-query"; import type { ConvexQueryClient } from "@convex-dev/react-query";
import type { ConvexReactClient } from "convex/react"; import type { ConvexReactClient } from "convex/react";
{{else}}
{{#if (or (eq api "trpc") (eq api "orpc"))}}
import type { QueryClient } from "@tanstack/react-query";
{{/if}}
{{/if}} {{/if}}
import Loader from "@/components/loader"; import Loader from "@/components/loader";

View File

@@ -9,18 +9,18 @@
"test": "vitest run" "test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.0.6", "@tailwindcss/vite": "^4.1.13",
"@tanstack/router-plugin": "^1.109.2", "@tanstack/router-plugin": "^1.131.44",
"@tanstack/solid-form": "^1.9.0", "@tanstack/solid-form": "^1.20.0",
"@tanstack/solid-router": "^1.110.0", "@tanstack/solid-router": "^1.131.44",
"lucide-solid": "^0.507.0", "lucide-solid": "^0.544.0",
"solid-js": "^1.9.4", "solid-js": "^1.9.9",
"tailwindcss": "^4.0.6", "tailwindcss": "^4.1.13",
"zod": "^4.0.2" "zod": "^4.0.2"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.7.2", "typescript": "^5.9.2",
"vite": "^7.0.2", "vite": "^7.1.5",
"vite-plugin-solid": "^2.11.2" "vite-plugin-solid": "^2.11.8"
} }
} }

View File

@@ -0,0 +1,6 @@
import { Polar } from "@polar-sh/sdk";
export const polarClient = new Polar({
accessToken: process.env.POLAR_ACCESS_TOKEN,
server: "sandbox",
});

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
const route = useRoute()
const checkout_id = route.query.checkout_id as string
</script>
<template>
<div class="container mx-auto px-4 py-8">
<h1 class="text-2xl font-bold mb-4">Payment Successful!</h1>
<p v-if="checkout_id">Checkout ID: \{{ checkout_id }}</p>
</div>
</template>

View File

@@ -0,0 +1,15 @@
export default async function SuccessPage({
searchParams,
}: {
searchParams: Promise<{ checkout_id: string }>
}) {
const params = await searchParams;
const checkout_id = params.checkout_id;
return (
<div className="px-4 py-8">
<h1>Payment Successful!</h1>
{checkout_id && <p>Checkout ID: {checkout_id}</p>}
</div>
);
}

View File

@@ -0,0 +1,13 @@
import { useSearchParams } from "react-router";
export default function SuccessPage() {
const [searchParams] = useSearchParams();
const checkout_id = searchParams.get("checkout_id");
return (
<div className="container mx-auto px-4 py-8">
<h1>Payment Successful!</h1>
{checkout_id && <p>Checkout ID: {checkout_id}</p>}
</div>
);
}

View File

@@ -0,0 +1,19 @@
import { createFileRoute, useSearch } from "@tanstack/react-router";
export const Route = createFileRoute("/success")({
component: SuccessPage,
validateSearch: (search) => ({
checkout_id: search.checkout_id as string,
}),
});
function SuccessPage() {
const { checkout_id } = useSearch({ from: "/success" });
return (
<div className="container mx-auto px-4 py-8">
<h1>Payment Successful!</h1>
{checkout_id && <p>Checkout ID: {checkout_id}</p>}
</div>
);
}

View File

@@ -0,0 +1,19 @@
import { createFileRoute, useSearch } from "@tanstack/react-router";
export const Route = createFileRoute("/success")({
component: SuccessPage,
validateSearch: (search) => ({
checkout_id: search.checkout_id as string,
}),
});
function SuccessPage() {
const { checkout_id } = useSearch({ from: "/success" });
return (
<div className="container mx-auto px-4 py-8">
<h1>Payment Successful!</h1>
{checkout_id && <p>Checkout ID: {checkout_id}</p>}
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { createFileRoute } from "@tanstack/solid-router";
import { Show } from "solid-js";
export const Route = createFileRoute("/success")({
component: SuccessPage,
validateSearch: (search) => ({
checkout_id: search.checkout_id as string,
}),
});
function SuccessPage() {
const searchParams = Route.useSearch();
const checkout_id = searchParams().checkout_id;
return (
<div class="container mx-auto px-4 py-8">
<h1>Payment Successful!</h1>
<Show when={checkout_id}>
<p>Checkout ID: {checkout_id}</p>
</Show>
</div>
);
}

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import { page } from '$app/stores';
const checkout_id = $page.url.searchParams.get('checkout_id');
</script>
<div class="container mx-auto px-4 py-8">
<h1>Payment Successful!</h1>
{#if checkout_id}
<p>Checkout ID: {checkout_id}</p>
{/if}
</div>

View File

@@ -30,7 +30,8 @@ export function TechIcon({
icon.includes("prisma") || icon.includes("prisma") ||
icon.includes("express") || icon.includes("express") ||
icon.includes("clerk") || icon.includes("clerk") ||
icon.includes("planetscale")) icon.includes("planetscale") ||
icon.includes("polar"))
) { ) {
iconSrc = icon.replace(".svg", "-light.svg"); iconSrc = icon.replace(".svg", "-light.svg");
} }

View File

@@ -818,6 +818,64 @@ export const analyzeStackCompatibility = (
}); });
} }
if (nextStack.payments === "polar") {
if (nextStack.auth !== "better-auth") {
notes.payments.notes.push(
"Polar payments requires Better Auth. Payments will be set to 'None'.",
);
notes.auth.notes.push(
"Polar payments requires Better Auth. Payments will be disabled.",
);
notes.payments.hasIssue = true;
notes.auth.hasIssue = true;
nextStack.payments = "none";
changed = true;
changes.push({
category: "payments",
message: "Payments set to 'None' (Polar requires Better Auth)",
});
}
if (nextStack.backend === "convex") {
notes.payments.notes.push(
"Polar payments is not compatible with Convex backend. Payments will be set to 'None'.",
);
notes.backend.notes.push(
"Polar payments is not compatible with Convex backend. Payments will be disabled.",
);
notes.payments.hasIssue = true;
notes.backend.hasIssue = true;
nextStack.payments = "none";
changed = true;
changes.push({
category: "payments",
message:
"Payments set to 'None' (Polar not compatible with Convex backend)",
});
}
const hasWebFrontend = nextStack.webFrontend.some((f) => f !== "none");
if (
!hasWebFrontend &&
nextStack.nativeFrontend.some((f) => f !== "none")
) {
notes.payments.notes.push(
"Polar payments requires a web frontend. Payments will be set to 'None'.",
);
notes.webFrontend.notes.push(
"Polar payments requires a web frontend. Payments will be disabled.",
);
notes.payments.hasIssue = true;
notes.webFrontend.hasIssue = true;
nextStack.payments = "none";
changed = true;
changes.push({
category: "payments",
message: "Payments set to 'None' (Polar requires web frontend)",
});
}
}
const incompatibleAddons: string[] = []; const incompatibleAddons: string[] = [];
const isPWACompat = hasPWACompatibleFrontend(nextStack.webFrontend); const isPWACompat = hasPWACompatibleFrontend(nextStack.webFrontend);
const isTauriCompat = hasTauriCompatibleFrontend(nextStack.webFrontend); const isTauriCompat = hasTauriCompatibleFrontend(nextStack.webFrontend);
@@ -1536,6 +1594,22 @@ export const getDisabledReason = (
} }
} }
if (category === "payments" && optionId === "polar") {
if (finalStack.auth !== "better-auth") {
return "Polar payments requires Better Auth. Select Better Auth first.";
}
if (finalStack.backend === "convex") {
return "Polar payments is not compatible with Convex backend. Try Hono, Express, Fastify, or Elysia.";
}
const hasWebFrontend = finalStack.webFrontend.some((f) => f !== "none");
if (
!hasWebFrontend &&
finalStack.nativeFrontend.some((f) => f !== "none")
) {
return "Polar payments requires a web frontend. Select a web frontend first.";
}
}
if (category === "dbSetup" && optionId === "planetscale") { if (category === "dbSetup" && optionId === "planetscale") {
if (finalStack.database !== "postgres" && finalStack.database !== "mysql") { if (finalStack.database !== "postgres" && finalStack.database !== "mysql") {
return "PlanetScale requires PostgreSQL or MySQL database. Select PostgreSQL or MySQL first."; return "PlanetScale requires PostgreSQL or MySQL database. Select PostgreSQL or MySQL first.";

View File

@@ -427,6 +427,24 @@ export const TECH_OPTIONS: Record<
color: "from-red-400 to-red-600", color: "from-red-400 to-red-600",
}, },
], ],
payments: [
{
id: "polar",
name: "Polar",
description: "Turn your software into a business. 6 lines of code.",
icon: `${ICON_BASE_URL}/polar.svg`,
color: "from-purple-400 to-purple-600",
default: false,
},
{
id: "none",
name: "No Payments",
description: "Skip payments integration",
icon: "",
color: "from-gray-400 to-gray-600",
default: true,
},
],
packageManager: [ packageManager: [
{ {
id: "npm", id: "npm",
@@ -604,6 +622,7 @@ export const PRESET_TEMPLATES = [
orm: "drizzle", orm: "drizzle",
dbSetup: "none", dbSetup: "none",
auth: "better-auth", auth: "better-auth",
payments: "none",
packageManager: "bun", packageManager: "bun",
addons: ["turborepo"], addons: ["turborepo"],
examples: [], examples: [],
@@ -629,6 +648,7 @@ export const PRESET_TEMPLATES = [
orm: "none", orm: "none",
dbSetup: "none", dbSetup: "none",
auth: "none", auth: "none",
payments: "none",
packageManager: "bun", packageManager: "bun",
addons: ["turborepo"], addons: ["turborepo"],
examples: ["todo"], examples: ["todo"],
@@ -654,6 +674,7 @@ export const PRESET_TEMPLATES = [
orm: "drizzle", orm: "drizzle",
dbSetup: "none", dbSetup: "none",
auth: "better-auth", auth: "better-auth",
payments: "none",
packageManager: "bun", packageManager: "bun",
addons: ["turborepo"], addons: ["turborepo"],
examples: [], examples: [],
@@ -679,6 +700,7 @@ export const PRESET_TEMPLATES = [
orm: "drizzle", orm: "drizzle",
dbSetup: "none", dbSetup: "none",
auth: "better-auth", auth: "better-auth",
payments: "none",
packageManager: "bun", packageManager: "bun",
addons: ["turborepo"], addons: ["turborepo"],
examples: [], examples: [],
@@ -704,6 +726,7 @@ export const PRESET_TEMPLATES = [
orm: "drizzle", orm: "drizzle",
dbSetup: "turso", dbSetup: "turso",
auth: "better-auth", auth: "better-auth",
payments: "polar",
packageManager: "bun", packageManager: "bun",
addons: ["pwa", "biome", "husky", "tauri", "starlight", "turborepo"], addons: ["pwa", "biome", "husky", "tauri", "starlight", "turborepo"],
examples: ["todo", "ai"], examples: ["todo", "ai"],
@@ -727,6 +750,7 @@ export type StackState = {
orm: string; orm: string;
dbSetup: string; dbSetup: string;
auth: string; auth: string;
payments: string;
packageManager: string; packageManager: string;
addons: string[]; addons: string[];
examples: string[]; examples: string[];
@@ -748,6 +772,7 @@ export const DEFAULT_STACK: StackState = {
orm: "drizzle", orm: "drizzle",
dbSetup: "none", dbSetup: "none",
auth: "better-auth", auth: "better-auth",
payments: "none",
packageManager: "bun", packageManager: "bun",
addons: ["turborepo"], addons: ["turborepo"],
examples: [], examples: [],

View File

@@ -12,6 +12,7 @@ export const stackUrlKeys: UrlKeys<Record<keyof StackState, unknown>> = {
orm: "orm", orm: "orm",
dbSetup: "dbs", dbSetup: "dbs",
auth: "au", auth: "au",
payments: "pay",
packageManager: "pm", packageManager: "pm",
addons: "add", addons: "add",
examples: "ex", examples: "ex",

View File

@@ -43,6 +43,9 @@ export const stackParsers = {
auth: parseAsStringEnum<StackState["auth"]>(getValidIds("auth")).withDefault( auth: parseAsStringEnum<StackState["auth"]>(getValidIds("auth")).withDefault(
DEFAULT_STACK.auth, DEFAULT_STACK.auth,
), ),
payments: parseAsStringEnum<StackState["payments"]>(
getValidIds("payments"),
).withDefault(DEFAULT_STACK.payments),
packageManager: parseAsStringEnum<StackState["packageManager"]>( packageManager: parseAsStringEnum<StackState["packageManager"]>(
getValidIds("packageManager"), getValidIds("packageManager"),
).withDefault(DEFAULT_STACK.packageManager), ).withDefault(DEFAULT_STACK.packageManager),

View File

@@ -44,6 +44,9 @@ const serverStackParsers = {
auth: parseAsStringEnumServer<StackState["auth"]>( auth: parseAsStringEnumServer<StackState["auth"]>(
getValidIds("auth"), getValidIds("auth"),
).withDefault(DEFAULT_STACK.auth), ).withDefault(DEFAULT_STACK.auth),
payments: parseAsStringEnumServer<StackState["payments"]>(
getValidIds("payments"),
).withDefault(DEFAULT_STACK.payments),
packageManager: parseAsStringEnumServer<StackState["packageManager"]>( packageManager: parseAsStringEnumServer<StackState["packageManager"]>(
getValidIds("packageManager"), getValidIds("packageManager"),
).withDefault(DEFAULT_STACK.packageManager), ).withDefault(DEFAULT_STACK.packageManager),

View File

@@ -18,6 +18,7 @@ const CATEGORY_ORDER: Array<keyof typeof TECH_OPTIONS> = [
"webDeploy", "webDeploy",
"serverDeploy", "serverDeploy",
"auth", "auth",
"payments",
"packageManager", "packageManager",
"addons", "addons",
"examples", "examples",
@@ -88,6 +89,7 @@ export function generateStackCommand(stack: StackState) {
`--runtime ${stack.runtime}`, `--runtime ${stack.runtime}`,
`--api ${stack.api}`, `--api ${stack.api}`,
`--auth ${stack.auth}`, `--auth ${stack.auth}`,
`--payments ${stack.payments}`,
`--database ${stack.database}`, `--database ${stack.database}`,
`--orm ${stack.orm}`, `--orm ${stack.orm}`,
`--db-setup ${stack.dbSetup}`, `--db-setup ${stack.dbSetup}`,

View File

@@ -10,6 +10,7 @@ export type TechCategory =
| "webDeploy" | "webDeploy"
| "serverDeploy" | "serverDeploy"
| "auth" | "auth"
| "payments"
| "packageManager" | "packageManager"
| "addons" | "addons"
| "examples" | "examples"