From 7931166c8d4db7db98d8850272dcd280272bd79f Mon Sep 17 00:00:00 2001 From: Alex Yang Date: Wed, 27 Aug 2025 10:49:34 -0700 Subject: [PATCH] feat(cli): add `info` script (#4143) --- docs/content/docs/concepts/cli.mdx | 47 ++- packages/cli/src/commands/info.ts | 539 +++++++++++++++++++++++++++++ packages/cli/src/index.ts | 2 + packages/cli/test/info.test.ts | 354 +++++++++++++++++++ 4 files changed, 937 insertions(+), 5 deletions(-) create mode 100644 packages/cli/src/commands/info.ts create mode 100644 packages/cli/test/info.test.ts diff --git a/docs/content/docs/concepts/cli.mdx b/docs/content/docs/concepts/cli.mdx index 6cecaa2e17..4bef4662d2 100644 --- a/docs/content/docs/concepts/cli.mdx +++ b/docs/content/docs/concepts/cli.mdx @@ -3,7 +3,7 @@ title: CLI description: Built-in CLI for managing your project. --- -Better Auth comes with a built-in CLI to help you manage the database schemas, initialize your project, and generate a secret key for your application. +Better Auth comes with a built-in CLI to help you manage the database schemas, initialize your project, generate a secret key for your application, and gather diagnostic information about your setup. ## Generate @@ -15,14 +15,14 @@ npx @better-auth/cli@latest generate ### Options -- `--output` - Where to save the generated schema. For Prisma, it will be saved in prisma/schema.prisma. For Drizzle, it goes to schema.ts in your project root. For Kysely, it’s an SQL file saved as schema.sql in your project root. +- `--output` - Where to save the generated schema. For Prisma, it will be saved in prisma/schema.prisma. For Drizzle, it goes to schema.ts in your project root. For Kysely, it's an SQL file saved as schema.sql in your project root. - `--config` - The path to your Better Auth config file. By default, the CLI will search for a auth.ts file in **./**, **./utils**, **./lib**, or any of these directories under `src` directory. - `--yes` - Skip the confirmation prompt and generate the schema directly. ## Migrate -The migrate command applies the Better Auth schema directly to your database. This is available if you’re using the built-in Kysely adapter. For other adapters, you'll need to apply the schema using your ORM's migration tool. +The migrate command applies the Better Auth schema directly to your database. This is available if you're using the built-in Kysely adapter. For other adapters, you'll need to apply the schema using your ORM's migration tool. ```bash title="Terminal" npx @better-auth/cli@latest migrate @@ -49,6 +49,43 @@ npx @better-auth/cli@latest init - `--database` - The database you want to use. Currently, the only supported database is `sqlite`. - `--package-manager` - The package manager you want to use. Currently, the only supported package managers are `npm`, `pnpm`, `yarn`, `bun`. (Defaults to the manager you used to initialize the CLI.) +## Info + +The `info` command provides diagnostic information about your Better Auth setup and environment. Useful for debugging and sharing when seeking support. + +```bash title="Terminal" +npx @better-auth/cli@latest info +``` + +### Output + +The command displays: +- **System**: OS, CPU, memory, Node.js version +- **Package Manager**: Detected manager and version +- **Better Auth**: Version and configuration (sensitive data auto-redacted) +- **Frameworks**: Detected frameworks (Next.js, React, Vue, etc.) +- **Databases**: Database clients and ORMs (Prisma, Drizzle, etc.) + +### Options + +- `--config` - Path to your Better Auth config file +- `--json` - Output as JSON for sharing or programmatic use + +### Examples + +```bash +# Basic usage +npx @better-auth/cli@latest info + +# Custom config path +npx @better-auth/cli@latest info --config ./config/auth.ts + +# JSON output +npx @better-auth/cli@latest info --json > auth-info.json +``` + +Sensitive data like secrets, API keys, and database URLs are automatically replaced with `[REDACTED]` for safe sharing. + ## Secret The CLI also provides a way to generate a secret key for your Better Auth instance. @@ -61,6 +98,6 @@ npx @better-auth/cli@latest secret **Error: Cannot find module X** -If you see this error, it means the CLI can’t resolve imported modules in your Better Auth config file. We're working on a fix for many of these issues, but in the meantime, you can try the following: +If you see this error, it means the CLI can't resolve imported modules in your Better Auth config file. We're working on a fix for many of these issues, but in the meantime, you can try the following: -- Remove any import aliases in your config file and use relative paths instead. After running the CLI, you can revert to using aliases. +- Remove any import aliases in your config file and use relative paths instead. After running the CLI, you can revert to using aliases. \ No newline at end of file diff --git a/packages/cli/src/commands/info.ts b/packages/cli/src/commands/info.ts new file mode 100644 index 0000000000..1d41c876de --- /dev/null +++ b/packages/cli/src/commands/info.ts @@ -0,0 +1,539 @@ +import { Command } from "commander"; +import os from "os"; +import { execSync } from "child_process"; +import { existsSync, readFileSync } from "fs"; +import path from "path"; +import chalk from "chalk"; +import { getConfig } from "../utils/get-config"; +import { getPackageInfo } from "../utils/get-package-info"; + +function getSystemInfo() { + const platform = os.platform(); + const arch = os.arch(); + const version = os.version(); + const release = os.release(); + const cpus = os.cpus(); + const memory = os.totalmem(); + const freeMemory = os.freemem(); + + return { + platform, + arch, + version, + release, + cpuCount: cpus.length, + cpuModel: cpus[0]?.model || "Unknown", + totalMemory: `${(memory / 1024 / 1024 / 1024).toFixed(2)} GB`, + freeMemory: `${(freeMemory / 1024 / 1024 / 1024).toFixed(2)} GB`, + }; +} + +function getNodeInfo() { + return { + version: process.version, + env: process.env.NODE_ENV || "development", + }; +} + +function getPackageManager() { + const userAgent = process.env.npm_config_user_agent || ""; + + if (userAgent.includes("yarn")) { + return { name: "yarn", version: getVersion("yarn") }; + } + if (userAgent.includes("pnpm")) { + return { name: "pnpm", version: getVersion("pnpm") }; + } + if (userAgent.includes("bun")) { + return { name: "bun", version: getVersion("bun") }; + } + return { name: "npm", version: getVersion("npm") }; +} + +function getVersion(command: string): string { + try { + const output = execSync(`${command} --version`, { encoding: "utf8" }); + return output.trim(); + } catch { + return "Not installed"; + } +} + +function getFrameworkInfo(projectRoot: string) { + const packageJsonPath = path.join(projectRoot, "package.json"); + + if (!existsSync(packageJsonPath)) { + return null; + } + + try { + const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")); + const deps = { + ...packageJson.dependencies, + ...packageJson.devDependencies, + }; + + const frameworks: Record = { + next: deps["next"], + react: deps["react"], + vue: deps["vue"], + nuxt: deps["nuxt"], + svelte: deps["svelte"], + "@sveltejs/kit": deps["@sveltejs/kit"], + express: deps["express"], + fastify: deps["fastify"], + hono: deps["hono"], + remix: deps["@remix-run/react"], + astro: deps["astro"], + solid: deps["solid-js"], + qwik: deps["@builder.io/qwik"], + }; + + const installedFrameworks = Object.entries(frameworks) + .filter(([_, version]) => version) + .map(([name, version]) => ({ name, version })); + + return installedFrameworks.length > 0 ? installedFrameworks : null; + } catch { + return null; + } +} + +function getDatabaseInfo(projectRoot: string) { + const packageJsonPath = path.join(projectRoot, "package.json"); + + if (!existsSync(packageJsonPath)) { + return null; + } + + try { + const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")); + const deps = { + ...packageJson.dependencies, + ...packageJson.devDependencies, + }; + + const databases: Record = { + "better-sqlite3": deps["better-sqlite3"], + "@libsql/client": deps["@libsql/client"], + "@libsql/kysely-libsql": deps["@libsql/kysely-libsql"], + mysql2: deps["mysql2"], + pg: deps["pg"], + postgres: deps["postgres"], + "@prisma/client": deps["@prisma/client"], + drizzle: deps["drizzle-orm"], + kysely: deps["kysely"], + mongodb: deps["mongodb"], + "@neondatabase/serverless": deps["@neondatabase/serverless"], + "@vercel/postgres": deps["@vercel/postgres"], + "@planetscale/database": deps["@planetscale/database"], + }; + + const installedDatabases = Object.entries(databases) + .filter(([_, version]) => version) + .map(([name, version]) => ({ name, version })); + + return installedDatabases.length > 0 ? installedDatabases : null; + } catch { + return null; + } +} + +function sanitizeBetterAuthConfig(config: any): any { + if (!config) return null; + + const sanitized = JSON.parse(JSON.stringify(config)); + + // List of sensitive keys to redact + const sensitiveKeys = [ + "secret", + "clientSecret", + "clientId", + "authToken", + "apiKey", + "apiSecret", + "privateKey", + "publicKey", + "password", + "token", + "webhook", + "connectionString", + "databaseUrl", + "databaseURL", + "TURSO_AUTH_TOKEN", + "TURSO_DATABASE_URL", + "MYSQL_DATABASE_URL", + "DATABASE_URL", + "POSTGRES_URL", + "MONGODB_URI", + "stripeKey", + "stripeWebhookSecret", + ]; + + // Keys that should NOT be redacted even if they contain sensitive keywords + const allowedKeys = [ + "baseURL", + "callbackURL", + "redirectURL", + "trustedOrigins", + "appName", + ]; + + function redactSensitive(obj: any, parentKey?: string): any { + if (typeof obj !== "object" || obj === null) { + // Check if the parent key is sensitive + if (parentKey && typeof obj === "string" && obj.length > 0) { + // First check if it's in the allowed list + if ( + allowedKeys.some( + (allowed) => parentKey.toLowerCase() === allowed.toLowerCase(), + ) + ) { + return obj; + } + + const lowerKey = parentKey.toLowerCase(); + if ( + sensitiveKeys.some((key) => { + const lowerSensitiveKey = key.toLowerCase(); + // Exact match or the key ends with the sensitive key + return ( + lowerKey === lowerSensitiveKey || + lowerKey.endsWith(lowerSensitiveKey) + ); + }) + ) { + return "[REDACTED]"; + } + } + return obj; + } + + if (Array.isArray(obj)) { + return obj.map((item) => redactSensitive(item, parentKey)); + } + + const result: any = {}; + for (const [key, value] of Object.entries(obj)) { + // First check if this key is in the allowed list + if ( + allowedKeys.some( + (allowed) => key.toLowerCase() === allowed.toLowerCase(), + ) + ) { + result[key] = value; + continue; + } + + const lowerKey = key.toLowerCase(); + + // Check if this key should be redacted + if ( + sensitiveKeys.some((sensitiveKey) => { + const lowerSensitiveKey = sensitiveKey.toLowerCase(); + // Exact match or the key ends with the sensitive key + return ( + lowerKey === lowerSensitiveKey || + lowerKey.endsWith(lowerSensitiveKey) + ); + }) + ) { + if (typeof value === "string" && value.length > 0) { + result[key] = "[REDACTED]"; + } else if (typeof value === "object" && value !== null) { + // Still recurse into objects but mark them as potentially sensitive + result[key] = redactSensitive(value, key); + } else { + result[key] = value; + } + } else { + result[key] = redactSensitive(value, key); + } + } + return result; + } + + // Special handling for specific config sections + if (sanitized.database) { + // Redact database connection details + if (typeof sanitized.database === "string") { + sanitized.database = "[REDACTED]"; + } else if (sanitized.database.url) { + sanitized.database.url = "[REDACTED]"; + } + if (sanitized.database.authToken) { + sanitized.database.authToken = "[REDACTED]"; + } + } + + if (sanitized.socialProviders) { + // Redact all social provider secrets + for (const provider in sanitized.socialProviders) { + if (sanitized.socialProviders[provider]) { + sanitized.socialProviders[provider] = redactSensitive( + sanitized.socialProviders[provider], + provider, + ); + } + } + } + + if (sanitized.emailAndPassword?.sendResetPassword) { + sanitized.emailAndPassword.sendResetPassword = "[Function]"; + } + + if (sanitized.emailVerification?.sendVerificationEmail) { + sanitized.emailVerification.sendVerificationEmail = "[Function]"; + } + + // Redact plugin configurations + if (sanitized.plugins && Array.isArray(sanitized.plugins)) { + sanitized.plugins = sanitized.plugins.map((plugin: any) => { + if (typeof plugin === "function") { + return "[Plugin Function]"; + } + if (plugin && typeof plugin === "object") { + // Get plugin name if available + const pluginName = plugin.id || plugin.name || "unknown"; + return { + name: pluginName, + config: redactSensitive(plugin.config || plugin), + }; + } + return plugin; + }); + } + + return redactSensitive(sanitized); +} + +async function getBetterAuthInfo( + projectRoot: string, + configPath?: string, + suppressLogs = false, +) { + try { + // Temporarily suppress console output if needed + const originalLog = console.log; + const originalWarn = console.warn; + const originalError = console.error; + + if (suppressLogs) { + console.log = () => {}; + console.warn = () => {}; + console.error = () => {}; + } + + try { + const config = await getConfig({ + cwd: projectRoot, + configPath, + shouldThrowOnError: false, + }); + const packageInfo = await getPackageInfo(); + + return { + version: packageInfo.version || "Unknown", + config: sanitizeBetterAuthConfig(config), + }; + } finally { + // Restore console methods + if (suppressLogs) { + console.log = originalLog; + console.warn = originalWarn; + console.error = originalError; + } + } + } catch (error) { + return { + version: "Unknown", + config: null, + error: + error instanceof Error + ? error.message + : "Failed to load Better Auth config", + }; + } +} + +function formatOutput(data: any, indent = 0): string { + const spaces = " ".repeat(indent); + + if (data === null || data === undefined) { + return `${spaces}${chalk.gray("N/A")}`; + } + + if ( + typeof data === "string" || + typeof data === "number" || + typeof data === "boolean" + ) { + return `${spaces}${data}`; + } + + if (Array.isArray(data)) { + if (data.length === 0) { + return `${spaces}${chalk.gray("[]")}`; + } + return data.map((item) => formatOutput(item, indent)).join("\n"); + } + + if (typeof data === "object") { + const entries = Object.entries(data); + if (entries.length === 0) { + return `${spaces}${chalk.gray("{}")}`; + } + + return entries + .map(([key, value]) => { + if ( + typeof value === "object" && + value !== null && + !Array.isArray(value) + ) { + return `${spaces}${chalk.cyan(key)}:\n${formatOutput(value, indent + 2)}`; + } + return `${spaces}${chalk.cyan(key)}: ${formatOutput(value, 0)}`; + }) + .join("\n"); + } + + return `${spaces}${JSON.stringify(data)}`; +} + +export const info = new Command("info") + .description("Display system and Better Auth configuration information") + .option("--cwd ", "The working directory", process.cwd()) + .option("--config ", "Path to the Better Auth configuration file") + .option("-j, --json", "Output as JSON") + .option("-c, --copy", "Copy output to clipboard (requires pbcopy/xclip)") + .action(async (options) => { + const projectRoot = path.resolve(options.cwd || process.cwd()); + + // Collect all information + const systemInfo = getSystemInfo(); + const nodeInfo = getNodeInfo(); + const packageManager = getPackageManager(); + const frameworks = getFrameworkInfo(projectRoot); + const databases = getDatabaseInfo(projectRoot); + const betterAuthInfo = await getBetterAuthInfo( + projectRoot, + options.config, + options.json, + ); + + const fullInfo = { + system: systemInfo, + node: nodeInfo, + packageManager, + frameworks, + databases, + betterAuth: betterAuthInfo, + }; + + if (options.json) { + const jsonOutput = JSON.stringify(fullInfo, null, 2); + console.log(jsonOutput); + + if (options.copy) { + try { + const platform = os.platform(); + if (platform === "darwin") { + execSync("pbcopy", { input: jsonOutput }); + console.log(chalk.green("\n✓ Copied to clipboard")); + } else if (platform === "linux") { + execSync("xclip -selection clipboard", { input: jsonOutput }); + console.log(chalk.green("\n✓ Copied to clipboard")); + } else if (platform === "win32") { + execSync("clip", { input: jsonOutput }); + console.log(chalk.green("\n✓ Copied to clipboard")); + } + } catch { + console.log(chalk.yellow("\n⚠ Could not copy to clipboard")); + } + } + return; + } + + // Format and display output + console.log(chalk.bold("\n📊 Better Auth System Information\n")); + console.log(chalk.gray("=".repeat(50))); + + console.log(chalk.bold.white("\n🖥️ System Information:")); + console.log(formatOutput(systemInfo, 2)); + + console.log(chalk.bold.white("\n📦 Node.js:")); + console.log(formatOutput(nodeInfo, 2)); + + console.log(chalk.bold.white("\n📦 Package Manager:")); + console.log(formatOutput(packageManager, 2)); + + if (frameworks) { + console.log(chalk.bold.white("\n🚀 Frameworks:")); + console.log(formatOutput(frameworks, 2)); + } + + if (databases) { + console.log(chalk.bold.white("\n💾 Database Clients:")); + console.log(formatOutput(databases, 2)); + } + + console.log(chalk.bold.white("\n🔐 Better Auth:")); + if (betterAuthInfo.error) { + console.log(` ${chalk.red("Error:")} ${betterAuthInfo.error}`); + } else { + console.log(` ${chalk.cyan("Version")}: ${betterAuthInfo.version}`); + if (betterAuthInfo.config) { + console.log(` ${chalk.cyan("Configuration")}:`); + console.log(formatOutput(betterAuthInfo.config, 4)); + } + } + + console.log(chalk.gray("\n" + "=".repeat(50))); + console.log(chalk.gray("\n💡 Tip: Use --json flag for JSON output")); + console.log(chalk.gray("💡 Use --copy flag to copy output to clipboard")); + console.log( + chalk.gray("💡 When reporting issues, include this information\n"), + ); + + if (options.copy) { + const textOutput = ` +Better Auth System Information +============================== + +System Information: +${JSON.stringify(systemInfo, null, 2)} + +Node.js: +${JSON.stringify(nodeInfo, null, 2)} + +Package Manager: +${JSON.stringify(packageManager, null, 2)} + +Frameworks: +${JSON.stringify(frameworks, null, 2)} + +Database Clients: +${JSON.stringify(databases, null, 2)} + +Better Auth: +${JSON.stringify(betterAuthInfo, null, 2)} +`; + + try { + const platform = os.platform(); + if (platform === "darwin") { + execSync("pbcopy", { input: textOutput }); + console.log(chalk.green("✓ Copied to clipboard")); + } else if (platform === "linux") { + execSync("xclip -selection clipboard", { input: textOutput }); + console.log(chalk.green("✓ Copied to clipboard")); + } else if (platform === "win32") { + execSync("clip", { input: textOutput }); + console.log(chalk.green("✓ Copied to clipboard")); + } + } catch { + console.log(chalk.yellow("⚠ Could not copy to clipboard")); + } + } + }); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 5030b9f0ca..07a6c37cba 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -7,6 +7,7 @@ import { migrate } from "./commands/migrate"; import { generate } from "./commands/generate"; import { generateSecret } from "./commands/secret"; import { login } from "./commands/login"; +import { info } from "./commands/info"; import { getPackageInfo } from "./utils/get-package-info"; import "dotenv/config"; @@ -29,6 +30,7 @@ async function main() { .addCommand(migrate) .addCommand(generate) .addCommand(generateSecret) + .addCommand(info) .addCommand(login) .version(packageInfo.version || "1.1.2") .description("Better Auth CLI") diff --git a/packages/cli/test/info.test.ts b/packages/cli/test/info.test.ts new file mode 100644 index 0000000000..f702528824 --- /dev/null +++ b/packages/cli/test/info.test.ts @@ -0,0 +1,354 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { exec } from "node:child_process"; +import { promisify } from "node:util"; + +const execAsync = promisify(exec); + +let tmpDir = "."; + +describe("info command", () => { + beforeEach(async () => { + const tmp = path.join( + process.cwd(), + "node_modules", + ".cache", + "info_test-", + ); + tmpDir = await fs.mkdtemp(tmp); + + // Mock console methods to avoid noise in test output + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true }); + vi.restoreAllMocks(); + }); + + it("should display system information without auth config", async () => { + // Create a minimal package.json + await fs.writeFile( + path.join(tmpDir, "package.json"), + JSON.stringify({ + name: "test-project", + version: "1.0.0", + dependencies: { + "better-auth": "^1.0.0", + }, + }), + ); + + const cliPath = path.join(process.cwd(), "dist", "index.mjs"); + const { stdout } = await execAsync(`node ${cliPath} info --json`, { + cwd: tmpDir, + }); + + const output = JSON.parse(stdout); + + // Check system information + expect(output.system).toHaveProperty("platform"); + expect(output.system).toHaveProperty("arch"); + expect(output.system).toHaveProperty("cpuCount"); + expect(output.system).toHaveProperty("totalMemory"); + + // Check node information + expect(output.node).toHaveProperty("version"); + expect(output.node).toHaveProperty("env"); + + // Check package manager + expect(output.packageManager).toHaveProperty("name"); + expect(output.packageManager).toHaveProperty("version"); + + // Better Auth config should have an error since no auth file exists + expect(output.betterAuth).toHaveProperty("version"); + expect(output.betterAuth.config).toBeNull(); + }); + + it("should load and sanitize auth configuration", async () => { + // Create package.json with dependencies + await fs.writeFile( + path.join(tmpDir, "package.json"), + JSON.stringify({ + name: "test-project", + version: "1.0.0", + dependencies: { + "better-auth": "^1.0.0", + next: "^14.0.0", + react: "^18.0.0", + }, + }), + ); + + // Create auth.ts with sensitive data - using in-memory database to avoid adapter errors + await fs.writeFile( + path.join(tmpDir, "auth.ts"), + `import { betterAuth } from "better-auth"; + + export const auth = betterAuth({ + secret: "super-secret-key-123", + baseURL: "https://example.com", + emailAndPassword: { + enabled: true, + }, + socialProviders: { + github: { + clientId: "github-client-id", + clientSecret: "github-client-secret" + }, + google: { + clientId: "google-client-id", + clientSecret: "google-client-secret" + } + } + })`, + ); + + const cliPath = path.join(process.cwd(), "dist", "index.mjs"); + const { stdout } = await execAsync(`node ${cliPath} info --json`, { + cwd: tmpDir, + }); + + const output = JSON.parse(stdout); + + // Check that sensitive data is redacted + expect(output.betterAuth.config).toBeDefined(); + expect(output.betterAuth.config.secret).toBe("[REDACTED]"); + + // Check social providers are sanitized + expect(output.betterAuth.config.socialProviders).toBeDefined(); + expect(output.betterAuth.config.socialProviders.github.clientId).toBe( + "[REDACTED]", + ); + expect(output.betterAuth.config.socialProviders.github.clientSecret).toBe( + "[REDACTED]", + ); + expect(output.betterAuth.config.socialProviders.google.clientId).toBe( + "[REDACTED]", + ); + expect(output.betterAuth.config.socialProviders.google.clientSecret).toBe( + "[REDACTED]", + ); + + // Check non-sensitive data is preserved + expect(output.betterAuth.config.emailAndPassword).toEqual({ + enabled: true, + }); + expect(output.betterAuth.config.baseURL).toBe("https://example.com"); + }); + + it("should detect installed frameworks", async () => { + // Create package.json with various frameworks + await fs.writeFile( + path.join(tmpDir, "package.json"), + JSON.stringify({ + name: "test-project", + version: "1.0.0", + dependencies: { + "better-auth": "^1.0.0", + next: "^14.0.0", + react: "^18.0.0", + }, + devDependencies: { + "@sveltejs/kit": "^2.0.0", + svelte: "^4.0.0", + }, + }), + ); + + const cliPath = path.join(process.cwd(), "dist", "index.mjs"); + const { stdout } = await execAsync(`node ${cliPath} info --json`, { + cwd: tmpDir, + }); + + const output = JSON.parse(stdout); + + // Check frameworks are detected + expect(output.frameworks).toContainEqual({ + name: "next", + version: "^14.0.0", + }); + expect(output.frameworks).toContainEqual({ + name: "react", + version: "^18.0.0", + }); + expect(output.frameworks).toContainEqual({ + name: "@sveltejs/kit", + version: "^2.0.0", + }); + expect(output.frameworks).toContainEqual({ + name: "svelte", + version: "^4.0.0", + }); + }); + + it("should detect database clients", async () => { + // Create package.json with database clients + await fs.writeFile( + path.join(tmpDir, "package.json"), + JSON.stringify({ + name: "test-project", + version: "1.0.0", + dependencies: { + "better-auth": "^1.0.0", + "@prisma/client": "^5.0.0", + kysely: "^0.26.0", + }, + devDependencies: { + "drizzle-orm": "^0.29.0", + "better-sqlite3": "^9.0.0", + }, + }), + ); + + const cliPath = path.join(process.cwd(), "dist", "index.mjs"); + const { stdout } = await execAsync(`node ${cliPath} info --json`, { + cwd: tmpDir, + }); + + const output = JSON.parse(stdout); + + // Check database clients are detected + expect(output.databases).toContainEqual({ + name: "@prisma/client", + version: "^5.0.0", + }); + expect(output.databases).toContainEqual({ + name: "kysely", + version: "^0.26.0", + }); + expect(output.databases).toContainEqual({ + name: "drizzle", + version: "^0.29.0", + }); + expect(output.databases).toContainEqual({ + name: "better-sqlite3", + version: "^9.0.0", + }); + }); + + it("should support custom config path", async () => { + // Create package.json + await fs.writeFile( + path.join(tmpDir, "package.json"), + JSON.stringify({ + name: "test-project", + version: "1.0.0", + dependencies: { + "better-auth": "^1.0.0", + }, + }), + ); + + // Create custom directory for auth config + const customPath = path.join(tmpDir, "config"); + await fs.mkdir(customPath, { recursive: true }); + + // Create auth config in custom location + await fs.writeFile( + path.join(customPath, "auth.config.ts"), + `import { betterAuth } from "better-auth"; + + export const auth = betterAuth({ + secret: "my-secret", + appName: "Custom Config App", + emailAndPassword: { + enabled: true, + } + })`, + ); + + const cliPath = path.join(process.cwd(), "dist", "index.mjs"); + const { stdout } = await execAsync( + `node ${cliPath} info --config config/auth.config.ts --json`, + { cwd: tmpDir }, + ); + + const output = JSON.parse(stdout); + + // Check that custom config was loaded + expect(output.betterAuth.config).toBeDefined(); + expect(output.betterAuth.config.appName).toBe("Custom Config App"); + expect(output.betterAuth.config.secret).toBe("[REDACTED]"); + expect(output.betterAuth.config.emailAndPassword).toEqual({ + enabled: true, + }); + }); + + it("should sanitize plugin configurations", async () => { + // Create package.json + await fs.writeFile( + path.join(tmpDir, "package.json"), + JSON.stringify({ + name: "test-project", + version: "1.0.0", + dependencies: { + "better-auth": "^1.0.0", + }, + }), + ); + + // Create auth.ts with plugins + await fs.writeFile( + path.join(tmpDir, "auth.ts"), + `import { betterAuth } from "better-auth"; + import { twoFactor, organization } from "better-auth/plugins"; + + export const auth = betterAuth({ + plugins: [ + twoFactor({ + otpOptions: { + secret: "otp-secret-key" + } + }), + organization({ + apiKey: "org-api-key", + webhookSecret: "webhook-secret" + }) + ] + })`, + ); + + const cliPath = path.join(process.cwd(), "dist", "index.mjs"); + const { stdout } = await execAsync(`node ${cliPath} info --json`, { + cwd: tmpDir, + }); + + const output = JSON.parse(stdout); + + // Check that plugin configs are sanitized + expect(output.betterAuth.config.plugins).toBeDefined(); + expect(Array.isArray(output.betterAuth.config.plugins)).toBe(true); + + // Plugin sensitive data should be redacted + const plugins = output.betterAuth.config.plugins; + plugins.forEach((plugin: any) => { + if (plugin.config) { + // Check that sensitive keys are redacted + const configStr = JSON.stringify(plugin.config); + expect(configStr).toContain("[REDACTED]"); + } + }); + }); + + it("should handle missing package.json gracefully", async () => { + // Don't create package.json + const cliPath = path.join(process.cwd(), "dist", "index.mjs"); + const { stdout } = await execAsync(`node ${cliPath} info --json`, { + cwd: tmpDir, + }); + + const output = JSON.parse(stdout); + + // Should still return system info + expect(output.system).toBeDefined(); + expect(output.node).toBeDefined(); + expect(output.packageManager).toBeDefined(); + + // Frameworks and databases should be null + expect(output.frameworks).toBeNull(); + expect(output.databases).toBeNull(); + }); +}, 20000);