diff --git a/packages/codemods/src/index.ts b/packages/codemods/src/index.ts index 83fdcb2..f53308d 100644 --- a/packages/codemods/src/index.ts +++ b/packages/codemods/src/index.ts @@ -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"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 234e66c..e47c88f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/registry/modules/auth-better-auth/module.ts b/registry/modules/auth-better-auth/module.ts index 39ca1e1..b4788a5 100644 --- a/registry/modules/auth-better-auth/module.ts +++ b/registry/modules/auth-better-auth/module.ts @@ -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" }, diff --git a/registry/modules/auth-better-auth/templates/next/auth-client.ts b/registry/modules/auth-better-auth/templates/next/auth-client.ts index 134f063..62bca7e 100644 --- a/registry/modules/auth-better-auth/templates/next/auth-client.ts +++ b/registry/modules/auth-better-auth/templates/next/auth-client.ts @@ -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(); diff --git a/registry/modules/auth-better-auth/templates/shared/auth-schema.drizzle.ts b/registry/modules/auth-better-auth/templates/shared/auth-schema.drizzle-postgres.ts similarity index 100% rename from registry/modules/auth-better-auth/templates/shared/auth-schema.drizzle.ts rename to registry/modules/auth-better-auth/templates/shared/auth-schema.drizzle-postgres.ts diff --git a/registry/modules/auth-better-auth/templates/shared/auth-schema.drizzle-sqlite.ts b/registry/modules/auth-better-auth/templates/shared/auth-schema.drizzle-sqlite.ts new file mode 100644 index 0000000..ffaaa3e --- /dev/null +++ b/registry/modules/auth-better-auth/templates/shared/auth-schema.drizzle-sqlite.ts @@ -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()), +}); diff --git a/registry/modules/auth-better-auth/templates/tanstack/auth-client.ts b/registry/modules/auth-better-auth/templates/tanstack/auth-client.ts index 126ccba..62bca7e 100644 --- a/registry/modules/auth-better-auth/templates/tanstack/auth-client.ts +++ b/registry/modules/auth-better-auth/templates/tanstack/auth-client.ts @@ -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(); diff --git a/registry/modules/auth-clerk/codemods/index.ts b/registry/modules/auth-clerk/codemods/index.ts new file mode 100644 index 0000000..7e07d4c --- /dev/null +++ b/registry/modules/auth-clerk/codemods/index.ts @@ -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 .", + + 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}`; + 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}`; + 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, +}; diff --git a/registry/modules/auth-clerk/module.ts b/registry/modules/auth-clerk/module.ts index c972c85..1eca5bb 100644 --- a/registry/modules/auth-clerk/module.ts +++ b/registry/modules/auth-clerk/module.ts @@ -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"], }, ], }); diff --git a/registry/modules/auth-clerk/package.json b/registry/modules/auth-clerk/package.json index e3a4533..ef983dd 100644 --- a/registry/modules/auth-clerk/package.json +++ b/registry/modules/auth-clerk/package.json @@ -8,5 +8,8 @@ "dependencies": { "@stanza/codemods": "workspace:*", "@stanza/registry": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.9.1" } } diff --git a/registry/modules/auth-clerk/tsconfig.json b/registry/modules/auth-clerk/tsconfig.json index 8988b35..71940c6 100644 --- a/registry/modules/auth-clerk/tsconfig.json +++ b/registry/modules/auth-clerk/tsconfig.json @@ -1,5 +1,8 @@ { "extends": "../../../tsconfig.base.json", - "include": ["module.ts"], + "compilerOptions": { + "types": ["node"] + }, + "include": ["module.ts", "codemods/**/*.ts"], "exclude": ["templates"] }