mirror of
https://github.com/jakejarvis/stanza.git
synced 2026-10-16 06:15:43 -04:00
feat: add sqlite drizzle adapter for better-auth and clerk codemods
- split better-auth drizzle adapters into separate postgres/sqlite variants with a dedicated sqlite schema template - simplify auth-client templates to use `createAuthClient()` without an explicit baseURL - export `Node` type and `SyntaxKind` enum from the codemods package - add codemod for the auth-clerk module
This commit is contained in:
11 files changed
+256
-77
No files matched your search
@@ -1,8 +1,9 @@
|
||||
export type { CodemodContext, Codemod, CodemodResult } from "./types.ts";
|
||||
|
||||
// Re-export the ts-morph Project type so consumers don't need a direct
|
||||
// ts-morph dep just to type a parameter.
|
||||
export type { Project, SourceFile } from "ts-morph";
|
||||
// Re-export common ts-morph types + the SyntaxKind enum so module codemods
|
||||
// don't need a direct ts-morph dep.
|
||||
export type { Project, SourceFile, Node } from "ts-morph";
|
||||
export { SyntaxKind } from "ts-morph";
|
||||
|
||||
export { openProject } from "./project.ts";
|
||||
|
||||
|
||||
Generated
+4
@@ -176,6 +176,10 @@ importers:
|
||||
'@stanza/registry':
|
||||
specifier: workspace:*
|
||||
version: link:../../../packages/registry
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^25.9.1
|
||||
version: 25.9.1
|
||||
|
||||
registry/modules/db-postgres:
|
||||
dependencies:
|
||||
|
||||
@@ -1,57 +1,73 @@
|
||||
import { defineModule } from "@stanza/registry";
|
||||
|
||||
// Shared env declaration — every adapter wants the same two vars.
|
||||
const env = [
|
||||
{
|
||||
name: "BETTER_AUTH_SECRET",
|
||||
example: "change-me-in-prod",
|
||||
required: true,
|
||||
description: "Better Auth signing secret.",
|
||||
},
|
||||
{
|
||||
name: "BETTER_AUTH_URL",
|
||||
example: "http://localhost:3000",
|
||||
required: true,
|
||||
description: "Public URL of the app (used server-side for callbacks and cookies).",
|
||||
},
|
||||
];
|
||||
|
||||
const deps = { "better-auth": "^1.6.11" };
|
||||
|
||||
export default defineModule({
|
||||
id: "better-auth",
|
||||
slot: "auth",
|
||||
label: "Better Auth",
|
||||
description: "Framework-agnostic, headless TypeScript auth library.",
|
||||
version: "0.1.0",
|
||||
peers: { orm: ["drizzle", "prisma"], framework: ["next", "tanstack-start"] },
|
||||
peers: {
|
||||
orm: ["drizzle", "prisma"],
|
||||
framework: ["next", "tanstack-start"],
|
||||
db: ["postgres", "sqlite"],
|
||||
},
|
||||
homepage: "https://better-auth.com",
|
||||
adapters: [
|
||||
{
|
||||
key: "next+drizzle",
|
||||
match: { framework: "next", orm: "drizzle" },
|
||||
dependencies: { "better-auth": "^1.6.11" },
|
||||
env: [
|
||||
{
|
||||
name: "BETTER_AUTH_SECRET",
|
||||
example: "change-me-in-prod",
|
||||
required: true,
|
||||
description: "Better Auth signing secret.",
|
||||
},
|
||||
{
|
||||
name: "BETTER_AUTH_URL",
|
||||
example: "http://localhost:3000",
|
||||
required: true,
|
||||
description: "Public URL of the app.",
|
||||
},
|
||||
],
|
||||
key: "next+drizzle+postgres",
|
||||
match: { framework: "next", orm: "drizzle", db: "postgres" },
|
||||
dependencies: deps,
|
||||
env,
|
||||
templates: [
|
||||
{ src: "next/auth.drizzle.ts", dest: "src/lib/auth.ts", scope: "app" },
|
||||
{ src: "next/auth-client.ts", dest: "src/lib/auth-client.ts", scope: "app" },
|
||||
{ src: "next/route.ts", dest: "app/api/auth/[...all]/route.ts", scope: "app" },
|
||||
{ src: "shared/auth-schema.drizzle.ts", dest: "src/db/auth-schema.ts", scope: "app" },
|
||||
{
|
||||
src: "shared/auth-schema.drizzle-postgres.ts",
|
||||
dest: "src/db/auth-schema.ts",
|
||||
scope: "app",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "next+drizzle+sqlite",
|
||||
match: { framework: "next", orm: "drizzle", db: "sqlite" },
|
||||
dependencies: deps,
|
||||
env,
|
||||
templates: [
|
||||
{ src: "next/auth.drizzle.ts", dest: "src/lib/auth.ts", scope: "app" },
|
||||
{ src: "next/auth-client.ts", dest: "src/lib/auth-client.ts", scope: "app" },
|
||||
{ src: "next/route.ts", dest: "app/api/auth/[...all]/route.ts", scope: "app" },
|
||||
{
|
||||
src: "shared/auth-schema.drizzle-sqlite.ts",
|
||||
dest: "src/db/auth-schema.ts",
|
||||
scope: "app",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "next+prisma",
|
||||
match: { framework: "next", orm: "prisma" },
|
||||
dependencies: { "better-auth": "^1.6.11" },
|
||||
env: [
|
||||
{
|
||||
name: "BETTER_AUTH_SECRET",
|
||||
example: "change-me-in-prod",
|
||||
required: true,
|
||||
description: "Better Auth signing secret.",
|
||||
},
|
||||
{
|
||||
name: "BETTER_AUTH_URL",
|
||||
example: "http://localhost:3000",
|
||||
required: true,
|
||||
description: "Public URL of the app.",
|
||||
},
|
||||
],
|
||||
dependencies: deps,
|
||||
env,
|
||||
templates: [
|
||||
{ src: "next/auth.prisma.ts", dest: "src/lib/auth.ts", scope: "app" },
|
||||
{ src: "next/auth-client.ts", dest: "src/lib/auth-client.ts", scope: "app" },
|
||||
@@ -59,48 +75,42 @@ export default defineModule({
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "tanstack-start+drizzle",
|
||||
match: { framework: "tanstack-start", orm: "drizzle" },
|
||||
dependencies: { "better-auth": "^1.6.11" },
|
||||
env: [
|
||||
{
|
||||
name: "BETTER_AUTH_SECRET",
|
||||
example: "change-me-in-prod",
|
||||
required: true,
|
||||
description: "Better Auth signing secret.",
|
||||
},
|
||||
{
|
||||
name: "BETTER_AUTH_URL",
|
||||
example: "http://localhost:3000",
|
||||
required: true,
|
||||
description: "Public URL of the app.",
|
||||
},
|
||||
],
|
||||
key: "tanstack-start+drizzle+postgres",
|
||||
match: { framework: "tanstack-start", orm: "drizzle", db: "postgres" },
|
||||
dependencies: deps,
|
||||
env,
|
||||
templates: [
|
||||
{ src: "tanstack/auth.drizzle.ts", dest: "src/lib/auth.ts", scope: "app" },
|
||||
{ src: "tanstack/auth-client.ts", dest: "src/lib/auth-client.ts", scope: "app" },
|
||||
{ src: "tanstack/api.ts", dest: "src/routes/api/auth/$.ts", scope: "app" },
|
||||
{ src: "shared/auth-schema.drizzle.ts", dest: "src/db/auth-schema.ts", scope: "app" },
|
||||
{
|
||||
src: "shared/auth-schema.drizzle-postgres.ts",
|
||||
dest: "src/db/auth-schema.ts",
|
||||
scope: "app",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "tanstack-start+drizzle+sqlite",
|
||||
match: { framework: "tanstack-start", orm: "drizzle", db: "sqlite" },
|
||||
dependencies: deps,
|
||||
env,
|
||||
templates: [
|
||||
{ src: "tanstack/auth.drizzle.ts", dest: "src/lib/auth.ts", scope: "app" },
|
||||
{ src: "tanstack/auth-client.ts", dest: "src/lib/auth-client.ts", scope: "app" },
|
||||
{ src: "tanstack/api.ts", dest: "src/routes/api/auth/$.ts", scope: "app" },
|
||||
{
|
||||
src: "shared/auth-schema.drizzle-sqlite.ts",
|
||||
dest: "src/db/auth-schema.ts",
|
||||
scope: "app",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "tanstack-start+prisma",
|
||||
match: { framework: "tanstack-start", orm: "prisma" },
|
||||
dependencies: { "better-auth": "^1.6.11" },
|
||||
env: [
|
||||
{
|
||||
name: "BETTER_AUTH_SECRET",
|
||||
example: "change-me-in-prod",
|
||||
required: true,
|
||||
description: "Better Auth signing secret.",
|
||||
},
|
||||
{
|
||||
name: "BETTER_AUTH_URL",
|
||||
example: "http://localhost:3000",
|
||||
required: true,
|
||||
description: "Public URL of the app.",
|
||||
},
|
||||
],
|
||||
dependencies: deps,
|
||||
env,
|
||||
templates: [
|
||||
{ src: "tanstack/auth.prisma.ts", dest: "src/lib/auth.ts", scope: "app" },
|
||||
{ src: "tanstack/auth-client.ts", dest: "src/lib/auth-client.ts", scope: "app" },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: process.env.BETTER_AUTH_URL,
|
||||
});
|
||||
// Defaults to the current origin. For cross-origin auth (e.g. a separate
|
||||
// API host), pass `baseURL: "https://your-domain.com"` here.
|
||||
// https://better-auth.com/docs/concepts/client
|
||||
export const authClient = createAuthClient();
|
||||
File renamed without changes.
@@ -0,0 +1,73 @@
|
||||
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
|
||||
|
||||
// SQLite has no native boolean or timestamp types. We use:
|
||||
// - integer(...) with mode "boolean" for booleans (0/1)
|
||||
// - integer(...) with mode "timestamp" for timestamps (epoch seconds)
|
||||
// Defaults are applied in JS via .$defaultFn() since SQLite's CURRENT_TIMESTAMP
|
||||
// would store as ISO text, which doesn't round-trip cleanly with the mode.
|
||||
|
||||
export const user = sqliteTable("user", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
email: text("email").notNull().unique(),
|
||||
emailVerified: integer("email_verified", { mode: "boolean" }).notNull().default(false),
|
||||
image: text("image"),
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
});
|
||||
|
||||
export const session = sqliteTable("session", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
|
||||
token: text("token").notNull(),
|
||||
ipAddress: text("ip_address"),
|
||||
userAgent: text("user_agent"),
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
});
|
||||
|
||||
export const account = sqliteTable("account", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
accountId: text("account_id").notNull(),
|
||||
providerId: text("provider_id").notNull(),
|
||||
accessToken: text("access_token"),
|
||||
refreshToken: text("refresh_token"),
|
||||
idToken: text("id_token"),
|
||||
accessTokenExpiresAt: integer("access_token_expires_at", { mode: "timestamp" }),
|
||||
refreshTokenExpiresAt: integer("refresh_token_expires_at", { mode: "timestamp" }),
|
||||
scope: text("scope"),
|
||||
password: text("password"),
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
});
|
||||
|
||||
export const verification = sqliteTable("verification", {
|
||||
id: text("id").primaryKey(),
|
||||
identifier: text("identifier").notNull(),
|
||||
value: text("value").notNull(),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: import.meta.env.VITE_BETTER_AUTH_URL,
|
||||
});
|
||||
// Defaults to the current origin. For cross-origin auth (e.g. a separate
|
||||
// API host), pass `baseURL: "https://your-domain.com"` here.
|
||||
// https://better-auth.com/docs/concepts/client
|
||||
export const authClient = createAuthClient();
|
||||
@@ -0,0 +1,82 @@
|
||||
import path from "node:path";
|
||||
import { type Codemod, SyntaxKind, addNamedImport } from "@stanza/codemods";
|
||||
|
||||
const LAYOUT_REL_FROM_APP = "app/layout.tsx";
|
||||
const PROVIDER_NAME = "ClerkRootProvider";
|
||||
const PROVIDER_IMPORT = "./_clerk-provider";
|
||||
|
||||
const wrapRootLayout: Codemod = {
|
||||
id: "wrap-root-layout",
|
||||
description: "Wrap {children} in app/layout.tsx with <ClerkRootProvider>.",
|
||||
|
||||
apply(ctx) {
|
||||
const layoutAbs = path.join(ctx.appRoot, LAYOUT_REL_FROM_APP);
|
||||
const layoutRel = path.relative(ctx.projectRoot, layoutAbs);
|
||||
const sf = ctx.project().addSourceFileAtPath(layoutAbs);
|
||||
|
||||
// Idempotency guard: if the provider is already wired up, do nothing.
|
||||
if (sf.getText().includes(PROVIDER_NAME)) {
|
||||
return { touchedFiles: [] };
|
||||
}
|
||||
|
||||
addNamedImport(sf, PROVIDER_IMPORT, PROVIDER_NAME);
|
||||
|
||||
// Find the bare `{children}` JsxExpression and wrap it. We match exactly
|
||||
// — anything that's already been edited (e.g. wrapped by another module)
|
||||
// won't match this literal, which is the desired safety property.
|
||||
const wrapped = `<${PROVIDER_NAME}>{children}</${PROVIDER_NAME}>`;
|
||||
let wrappedAny = false;
|
||||
for (const node of sf.getDescendantsOfKind(SyntaxKind.JsxExpression)) {
|
||||
if (node.getText().replace(/\s+/g, "") === "{children}") {
|
||||
node.replaceWithText(wrapped);
|
||||
wrappedAny = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!wrappedAny) {
|
||||
// The layout has been customized; we can't safely auto-wrap. Surface
|
||||
// the situation rather than silently shipping a broken auth setup.
|
||||
throw new Error(
|
||||
`Could not find \`{children}\` in ${layoutRel} to wrap with <${PROVIDER_NAME}>. ` +
|
||||
`If you've customized the layout, wrap children with <${PROVIDER_NAME}> manually.`,
|
||||
);
|
||||
}
|
||||
|
||||
ctx.claimRegion(layoutRel, "imports.clerk-provider");
|
||||
ctx.claimRegion(layoutRel, "providers.clerk");
|
||||
|
||||
return { touchedFiles: [layoutRel] };
|
||||
},
|
||||
|
||||
revert(ctx) {
|
||||
const layoutAbs = path.join(ctx.appRoot, LAYOUT_REL_FROM_APP);
|
||||
const layoutRel = path.relative(ctx.projectRoot, layoutAbs);
|
||||
const sf = ctx.project().addSourceFileAtPath(layoutAbs);
|
||||
|
||||
// Remove the import.
|
||||
const importDecl = sf.getImportDeclaration(
|
||||
(d) => d.getModuleSpecifierValue() === PROVIDER_IMPORT,
|
||||
);
|
||||
importDecl?.remove();
|
||||
|
||||
// Unwrap the JSX back to bare `{children}`. We match the exact text we
|
||||
// would have written, so user edits to the wrapped block won't be touched.
|
||||
const wrapped = `<${PROVIDER_NAME}>{children}</${PROVIDER_NAME}>`;
|
||||
for (const node of sf.getDescendantsOfKind(SyntaxKind.JsxElement)) {
|
||||
if (node.getText().replace(/\s+/g, "") === wrapped) {
|
||||
node.replaceWithText("{children}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.releaseRegion(layoutRel, "imports.clerk-provider");
|
||||
ctx.releaseRegion(layoutRel, "providers.clerk");
|
||||
|
||||
return { touchedFiles: [layoutRel] };
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
"wrap-root-layout": wrapRootLayout,
|
||||
};
|
||||
@@ -37,6 +37,7 @@ export default defineModule({
|
||||
{ src: "middleware.ts", dest: "middleware.ts", scope: "app" },
|
||||
{ src: "layout-wrapper.tsx", dest: "app/_clerk-provider.tsx", scope: "app" },
|
||||
],
|
||||
codemods: ["wrap-root-layout"],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -8,5 +8,8 @@
|
||||
"dependencies": {
|
||||
"@stanza/codemods": "workspace:*",
|
||||
"@stanza/registry": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.1"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"include": ["module.ts"],
|
||||
"compilerOptions": {
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["module.ts", "codemods/**/*.ts"],
|
||||
"exclude": ["templates"]
|
||||
}
|
||||
Reference in new issue
Block a user