diff --git a/src/bin.ts b/src/bin.ts index ee7efcf..4a8b1d9 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -187,6 +187,42 @@ yargs(hideBin(process.argv)) }); }), ) + .command( + 'doctor', + 'Diagnose WorkOS integration issues', + (yargs) => + yargs.options({ + verbose: { + type: 'boolean', + default: false, + description: 'Include additional diagnostic information', + }, + 'skip-api': { + type: 'boolean', + default: false, + description: 'Skip API calls (offline mode)', + }, + 'install-dir': { + type: 'string', + default: process.cwd(), + description: 'Project directory to analyze', + }, + json: { + type: 'boolean', + default: false, + description: 'Output report as JSON', + }, + copy: { + type: 'boolean', + default: false, + description: 'Copy report to clipboard', + }, + }), + async (argv) => { + const { handleDoctor } = await import('./commands/doctor.js'); + await handleDoctor(argv); + }, + ) .command( 'install', 'Install WorkOS AuthKit into your project', diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts new file mode 100644 index 0000000..6be9649 --- /dev/null +++ b/src/commands/doctor.ts @@ -0,0 +1,39 @@ +import type { ArgumentsCamelCase } from 'yargs'; +import { runDoctor, outputReport } from '../doctor/index.js'; +import clack from '../utils/clack.js'; + +interface DoctorArgs { + verbose?: boolean; + skipApi?: boolean; + installDir?: string; + json?: boolean; + copy?: boolean; +} + +export async function handleDoctor(argv: ArgumentsCamelCase): Promise { + const options = { + installDir: argv.installDir ?? process.cwd(), + verbose: argv.verbose ?? false, + skipApi: argv.skipApi ?? false, + json: argv.json ?? false, + copy: argv.copy ?? false, + }; + + try { + const report = await runDoctor(options); + await outputReport(report, options); + + // Exit with error code if critical issues found + if (report.summary.errors > 0) { + process.exit(1); + } + process.exit(0); + } catch (error) { + if (!options.json) { + clack.log.error(`Doctor failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } else { + console.error(JSON.stringify({ error: error instanceof Error ? error.message : 'Unknown error' })); + } + process.exit(1); + } +} diff --git a/src/doctor/checks/connectivity.ts b/src/doctor/checks/connectivity.ts new file mode 100644 index 0000000..76b32d8 --- /dev/null +++ b/src/doctor/checks/connectivity.ts @@ -0,0 +1,39 @@ +import type { DoctorOptions, ConnectivityInfo } from '../types.js'; + +export async function checkConnectivity(options: DoctorOptions, baseUrl: string): Promise { + if (options.skipApi) { + return { + apiReachable: false, + latencyMs: null, + tlsValid: false, + error: 'Skipped (--skip-api)', + }; + } + const startTime = Date.now(); + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + const response = await fetch(`${baseUrl}/health`, { + signal: controller.signal, + }); + + clearTimeout(timeoutId); + const latencyMs = Date.now() - startTime; + + return { + apiReachable: response.ok, + latencyMs, + tlsValid: true, // If fetch succeeded over HTTPS, TLS is valid + error: response.ok ? undefined : `HTTP ${response.status}`, + }; + } catch (error) { + return { + apiReachable: false, + latencyMs: null, + tlsValid: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } +} diff --git a/src/doctor/checks/dashboard.ts b/src/doctor/checks/dashboard.ts new file mode 100644 index 0000000..3940d77 --- /dev/null +++ b/src/doctor/checks/dashboard.ts @@ -0,0 +1,161 @@ +import type { + CredentialValidation, + DashboardSettings, + DashboardFetchResult, + DoctorOptions, + RedirectUriComparison, + EnvironmentRaw, +} from '../types.js'; + +const WORKOS_API_URL = 'https://api.workos.com'; + +export async function checkDashboardSettings( + options: DoctorOptions, + apiKeyType: 'staging' | 'production' | null, + raw: EnvironmentRaw, +): Promise { + // Never call API with production keys + if (apiKeyType === 'production') { + return { settings: null, error: 'Skipped (production API key)' }; + } + + if (options.skipApi) { + return { settings: null, error: 'Skipped (--skip-api)' }; + } + + const apiKey = raw.apiKey; + if (!apiKey) { + return { settings: null, error: 'No API key configured' }; + } + + try { + return await fetchDashboardSettings(apiKey, raw.baseUrl); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + return { settings: null, error: message }; + } +} + +async function fetchDashboardSettings(apiKey: string, baseUrlOverride: string | null): Promise { + const baseUrl = baseUrlOverride ?? WORKOS_API_URL; + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + try { + const redirectUris: string[] = []; + + // Single /organizations?limit=1 call — validates credentials AND gets org count + const orgsResponse = await fetch(`${baseUrl}/organizations?limit=1`, { + headers: { Authorization: `Bearer ${apiKey}` }, + signal: controller.signal, + }); + + if (orgsResponse.status === 401) { + return { + settings: null, + credentialValidation: { valid: false, clientIdMatch: true, error: 'Invalid API key' }, + error: 'Invalid API key (401)', + }; + } + if (orgsResponse.status === 403) { + return { + settings: null, + credentialValidation: { valid: false, clientIdMatch: true, error: 'API key lacks permissions' }, + error: 'API key lacks permissions (403)', + }; + } + if (!orgsResponse.ok) { + return { + settings: null, + credentialValidation: { valid: false, clientIdMatch: true, error: `API error: ${orgsResponse.status}` }, + error: `API error: ${orgsResponse.status}`, + }; + } + + // Credentials valid — extract org count from the same response + const credentialValidation: CredentialValidation = { valid: true, clientIdMatch: true }; + + let organizationCount = 0; + const orgsData = (await orgsResponse.json()) as { + list_metadata?: { total_count?: number }; + data?: unknown[]; + }; + organizationCount = orgsData.list_metadata?.total_count ?? orgsData.data?.length ?? 0; + + // Fetch environment settings + const envResponse = await fetch(`${baseUrl}/environments/current`, { + headers: { Authorization: `Bearer ${apiKey}` }, + signal: controller.signal, + }); + + let authMethods: string[] = []; + let sessionTimeout: string | null = null; + let mfa: 'optional' | 'required' | 'disabled' | null = null; + + if (envResponse.ok) { + const envData = (await envResponse.json()) as { + auth_methods?: string[]; + session_timeout?: string; + mfa_policy?: 'optional' | 'required' | 'disabled'; + }; + authMethods = envData.auth_methods ?? []; + sessionTimeout = envData.session_timeout ?? null; + mfa = envData.mfa_policy ?? null; + } + + return { + settings: { redirectUris, authMethods, sessionTimeout, mfa, organizationCount }, + credentialValidation, + }; + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') { + throw new Error('Request timeout (10s)'); + } + throw err; + } finally { + clearTimeout(timeoutId); + } +} + +/** + * Normalize a URI for comparison: + * - Remove trailing slashes + * - Normalize localhost variants (127.0.0.1 → localhost) + * - Lowercase the host portion + */ +function normalizeUri(uri: string): string { + try { + const url = new URL(uri); + // Normalize localhost variants + if (url.hostname === '127.0.0.1' || url.hostname === '[::1]') { + url.hostname = 'localhost'; + } + // Lowercase hostname (but preserve path case for compatibility) + url.hostname = url.hostname.toLowerCase(); + // Remove trailing slash from pathname (unless it's just "/") + if (url.pathname.length > 1 && url.pathname.endsWith('/')) { + url.pathname = url.pathname.slice(0, -1); + } + return url.toString(); + } catch { + // If URL parsing fails, return as-is for exact match fallback + return uri; + } +} + +export function compareRedirectUris( + codeUri: string | null, + dashboardUris: string[], + source?: 'env' | 'inferred', +): RedirectUriComparison { + if (!codeUri) { + return { codeUri, dashboardUris, match: false, source }; + } + + const normalizedCode = normalizeUri(codeUri); + const normalizedDashboard = dashboardUris.map(normalizeUri); + const match = normalizedDashboard.includes(normalizedCode); + + return { codeUri, dashboardUris, match, source }; +} diff --git a/src/doctor/checks/environment.ts b/src/doctor/checks/environment.ts new file mode 100644 index 0000000..4853980 --- /dev/null +++ b/src/doctor/checks/environment.ts @@ -0,0 +1,71 @@ +import { readFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { parse as parseDotenv } from 'dotenv'; +import type { EnvironmentInfo, EnvironmentCheckResult, DoctorOptions } from '../types.js'; + +/** + * Load environment variables from project's .env files. + * Priority: .env.local > .env (matching Next.js/Vite conventions) + * Uses dotenv parser for proper handling of quotes, multiline, exports, etc. + */ +function loadProjectEnv(installDir: string): Record { + const env: Record = {}; + + // Load in order: .env first, then .env.local (later overrides earlier) + const envFiles = ['.env', '.env.local']; + + for (const file of envFiles) { + const filePath = join(installDir, file); + if (existsSync(filePath)) { + try { + const content = readFileSync(filePath, 'utf-8'); + const parsed = parseDotenv(content); + Object.assign(env, parsed); + } catch { + // Ignore read errors + } + } + } + + return env; +} + +export function checkEnvironment(options?: DoctorOptions): EnvironmentCheckResult { + // Load project env files, then fall back to process.env + const projectEnv = options?.installDir ? loadProjectEnv(options.installDir) : {}; + + const apiKey = projectEnv.WORKOS_API_KEY ?? process.env.WORKOS_API_KEY ?? null; + const clientId = projectEnv.WORKOS_CLIENT_ID ?? process.env.WORKOS_CLIENT_ID ?? null; + const redirectUri = projectEnv.WORKOS_REDIRECT_URI ?? process.env.WORKOS_REDIRECT_URI ?? null; + const cookieDomain = projectEnv.WORKOS_COOKIE_DOMAIN ?? process.env.WORKOS_COOKIE_DOMAIN ?? null; + const baseUrl = projectEnv.WORKOS_BASE_URL ?? process.env.WORKOS_BASE_URL ?? null; + + return { + info: { + apiKeyConfigured: !!apiKey, + apiKeyType: getApiKeyType(apiKey), + clientId: truncateClientId(clientId), + redirectUri: redirectUri, + cookieDomain: cookieDomain, + baseUrl: baseUrl ?? 'https://api.workos.com', + }, + raw: { + apiKey, + clientId, + baseUrl: baseUrl ?? 'https://api.workos.com', + }, + }; +} + +function getApiKeyType(apiKey: string | undefined): 'staging' | 'production' | null { + if (!apiKey) return null; + if (apiKey.startsWith('sk_test_')) return 'staging'; + if (apiKey.startsWith('sk_live_')) return 'production'; + return null; // Unknown format +} + +function truncateClientId(clientId: string | undefined): string | null { + if (!clientId) return null; + if (clientId.length <= 15) return clientId; + return `${clientId.slice(0, 10)}...${clientId.slice(-3)}`; +} diff --git a/src/doctor/checks/framework.ts b/src/doctor/checks/framework.ts new file mode 100644 index 0000000..d6f59b9 --- /dev/null +++ b/src/doctor/checks/framework.ts @@ -0,0 +1,90 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { getPackageDotJson } from '../../utils/clack-utils.js'; +import { hasPackageInstalled, getPackageVersion } from '../../utils/package-json.js'; +import { detectPort, getCallbackPath } from '../../lib/port-detection.js'; +import { KNOWN_INTEGRATIONS } from '../../lib/constants.js'; +import type { Integration } from '../../lib/constants.js'; +import type { DoctorOptions, FrameworkInfo } from '../types.js'; + +interface FrameworkConfig { + package: string; + name: string; + integration: Integration | null; // Maps to Integration type for callback path/port + detectVariant: ((options: DoctorOptions) => Promise) | null; +} + +// Order matters - more specific frameworks should come first (array guarantees order) +const FRAMEWORKS: FrameworkConfig[] = [ + { package: 'next', name: 'Next.js', integration: KNOWN_INTEGRATIONS.nextjs, detectVariant: detectNextVariant }, + { + package: '@tanstack/react-start', + name: 'TanStack Start', + integration: KNOWN_INTEGRATIONS.tanstackStart, + detectVariant: null, + }, + { + package: '@tanstack/start', + name: 'TanStack Start', + integration: KNOWN_INTEGRATIONS.tanstackStart, + detectVariant: null, + }, + { package: '@tanstack/react-router', name: 'TanStack Router', integration: null, detectVariant: null }, + { package: '@remix-run/node', name: 'Remix', integration: null, detectVariant: null }, + { + package: 'react-router-dom', + name: 'React Router', + integration: KNOWN_INTEGRATIONS.reactRouter, + detectVariant: null, + }, + { package: 'express', name: 'Express', integration: null, detectVariant: null }, +]; + +export async function checkFramework(options: DoctorOptions): Promise { + let packageJson; + try { + packageJson = await getPackageDotJson(options); + } catch { + return { name: null, version: null }; + } + + for (const config of FRAMEWORKS) { + if (hasPackageInstalled(config.package, packageJson)) { + const version = getPackageVersion(config.package, packageJson) ?? null; + const variant = config.detectVariant ? await config.detectVariant(options) : undefined; + + // Get expected callback path and port if we have an integration mapping + let expectedCallbackPath: string | undefined; + let detectedPort: number | undefined; + + if (config.integration) { + expectedCallbackPath = getCallbackPath(config.integration); + detectedPort = detectPort(config.integration, options.installDir); + } + + return { + name: config.name, + version, + variant, + expectedCallbackPath, + detectedPort, + }; + } + } + + return { name: null, version: null }; +} + +async function detectNextVariant(options: DoctorOptions): Promise { + const appDir = join(options.installDir, 'app'); + const pagesDir = join(options.installDir, 'pages'); + const srcAppDir = join(options.installDir, 'src', 'app'); + const srcPagesDir = join(options.installDir, 'src', 'pages'); + + const hasAppDir = existsSync(appDir) || existsSync(srcAppDir); + const hasPagesDir = existsSync(pagesDir) || existsSync(srcPagesDir); + + if (hasAppDir && hasPagesDir) return 'hybrid'; + if (hasAppDir) return 'app-router'; + return 'pages-router'; +} diff --git a/src/doctor/checks/runtime.ts b/src/doctor/checks/runtime.ts new file mode 100644 index 0000000..5cc5df2 --- /dev/null +++ b/src/doctor/checks/runtime.ts @@ -0,0 +1,24 @@ +import { detectAllPackageManagers } from '../../utils/package-manager.js'; +import { execFileNoThrow } from '../../utils/exec-file.js'; +import type { DoctorOptions, RuntimeInfo } from '../types.js'; + +export async function checkRuntime(options: DoctorOptions): Promise { + const nodeVersion = process.version; + + const managers = detectAllPackageManagers(options); + const primaryManager = managers[0] ?? null; + + let packageManagerVersion: string | null = null; + if (primaryManager) { + const result = await execFileNoThrow(primaryManager.name, ['--version']); + if (result.status === 0) { + packageManagerVersion = result.stdout.trim(); + } + } + + return { + nodeVersion, + packageManager: primaryManager?.label ?? null, + packageManagerVersion, + }; +} diff --git a/src/doctor/checks/sdk.ts b/src/doctor/checks/sdk.ts new file mode 100644 index 0000000..15537c7 --- /dev/null +++ b/src/doctor/checks/sdk.ts @@ -0,0 +1,122 @@ +import { getPackageDotJson } from '../../utils/clack-utils.js'; +import { hasPackageInstalled, getPackageVersion } from '../../utils/package-json.js'; +import type { DoctorOptions, SdkInfo } from '../types.js'; + +// AuthKit SDKs - check newer @workos/* scope first, then legacy @workos-inc/* +const SDK_PACKAGES = [ + // New @workos/* scope + '@workos/authkit-nextjs', + '@workos/authkit-tanstack-react-start', + '@workos/authkit-react-router', + '@workos/authkit-remix', + '@workos/authkit-sveltekit', + '@workos/authkit-react', + '@workos/authkit-js', + // Legacy @workos-inc/* scope + '@workos-inc/authkit-nextjs', + '@workos-inc/authkit-remix', + '@workos-inc/authkit-react-router', + '@workos-inc/authkit-react', + '@workos-inc/authkit-js', + '@workos-inc/node', + 'workos', // very old legacy +] as const; + +const AUTHKIT_PACKAGES = new Set([ + '@workos/authkit-nextjs', + '@workos/authkit-tanstack-react-start', + '@workos/authkit-react-router', + '@workos/authkit-remix', + '@workos/authkit-sveltekit', + '@workos/authkit-react', + '@workos/authkit-js', + '@workos-inc/authkit-nextjs', + '@workos-inc/authkit-remix', + '@workos-inc/authkit-react-router', + '@workos-inc/authkit-react', + '@workos-inc/authkit-js', +]); + +export async function checkSdk(options: DoctorOptions): Promise { + let packageJson; + try { + packageJson = await getPackageDotJson(options); + } catch { + return { + name: null, + version: null, + latest: null, + outdated: false, + isAuthKit: false, + }; + } + + // Find installed SDK (order matters—AuthKit before standalone) + const installedSdk = SDK_PACKAGES.find((pkg) => hasPackageInstalled(pkg, packageJson)); + + if (!installedSdk) { + return { + name: null, + version: null, + latest: null, + outdated: false, + isAuthKit: false, + }; + } + + const version = getPackageVersion(installedSdk, packageJson) ?? null; + const latest = await fetchLatestVersion(installedSdk); + + return { + name: installedSdk, + version, + latest, + outdated: version && latest ? isVersionOutdated(version, latest) : false, + isAuthKit: AUTHKIT_PACKAGES.has(installedSdk), + }; +} + +async function fetchLatestVersion(packageName: string): Promise { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); + + const response = await fetch(`https://registry.npmjs.org/${packageName}/latest`, { + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) return null; + const data = (await response.json()) as { version?: string }; + return data.version ?? null; + } catch { + return null; + } +} + +function isVersionOutdated(current: string, latest: string): boolean { + // Strip semver range prefixes (^, ~, etc.) and workspace protocol + const cleanCurrent = current.replace(/^[\^~>=<]+/, '').replace(/^workspace:\*?/, ''); + const cleanLatest = latest.replace(/^[\^~>=<]+/, ''); + + // Handle prerelease: "1.0.0-beta.1" → "1.0.0", "-beta.1" + const [currBase] = cleanCurrent.split('-'); + const [latBase] = cleanLatest.split('-'); + + const currParts = currBase.split('.').map(Number); + const latParts = latBase.split('.').map(Number); + + // If we got NaN values, we can't reliably compare - assume not outdated + if (currParts.some(isNaN) || latParts.some(isNaN)) { + return false; + } + + const [currMajor = 0, currMinor = 0, currPatch = 0] = currParts; + const [latMajor = 0, latMinor = 0, latPatch = 0] = latParts; + + if (latMajor > currMajor) return true; + if (latMajor === currMajor && latMinor > currMinor) return true; + if (latMajor === currMajor && latMinor === currMinor && latPatch > currPatch) return true; + return false; +} diff --git a/src/doctor/clipboard.ts b/src/doctor/clipboard.ts new file mode 100644 index 0000000..47fda03 --- /dev/null +++ b/src/doctor/clipboard.ts @@ -0,0 +1,43 @@ +import { spawn } from 'node:child_process'; +import { platform } from 'node:os'; + +/** + * Execute a command with stdin input. + * Returns true if the command succeeded (exit code 0). + */ +function execWithStdin(command: string, args: string[], input: string): Promise { + return new Promise((resolve) => { + const child = spawn(command, args, { + shell: false, + stdio: ['pipe', 'ignore', 'ignore'], + }); + + child.on('error', () => resolve(false)); + child.on('close', (code) => resolve(code === 0)); + + child.stdin?.write(input); + child.stdin?.end(); + }); +} + +export async function copyToClipboard(text: string): Promise { + const os = platform(); + + try { + if (os === 'darwin') { + // macOS: use pbcopy + return await execWithStdin('pbcopy', [], text); + } else if (os === 'linux') { + // Linux: try xclip first, then xsel + const xclipResult = await execWithStdin('xclip', ['-selection', 'clipboard'], text); + if (xclipResult) return true; + return await execWithStdin('xsel', ['--clipboard', '--input'], text); + } else if (os === 'win32') { + // Windows: use clip.exe + return await execWithStdin('clip', [], text); + } + return false; + } catch { + return false; + } +} diff --git a/src/doctor/index.ts b/src/doctor/index.ts new file mode 100644 index 0000000..3a12d02 --- /dev/null +++ b/src/doctor/index.ts @@ -0,0 +1,110 @@ +import { checkSdk } from './checks/sdk.js'; +import { checkFramework } from './checks/framework.js'; +import { checkRuntime } from './checks/runtime.js'; +import { checkEnvironment } from './checks/environment.js'; +import { checkConnectivity } from './checks/connectivity.js'; +import { checkDashboardSettings, compareRedirectUris } from './checks/dashboard.js'; +import { detectIssues } from './issues.js'; +import { formatReport } from './output.js'; +import { formatReportAsJson } from './json-output.js'; +import { copyToClipboard } from './clipboard.js'; +import Chalk from 'chalk'; +import type { DoctorOptions, DoctorReport } from './types.js'; + +const DOCTOR_VERSION = '1.0.0'; + +export async function runDoctor(options: DoctorOptions): Promise { + // Environment check first - loads project's .env/.env.local files + // Must run before connectivity so the resolved base URL is available + const { info: environment, raw: envRaw } = checkEnvironment(options); + + // Run remaining checks concurrently + const [sdk, framework, runtime, connectivity] = await Promise.all([ + checkSdk(options), + checkFramework(options), + checkRuntime(options), + checkConnectivity(options, environment.baseUrl ?? 'https://api.workos.com'), + ]); + + // Dashboard settings + credential validation (single pass, staging only) + const dashboardResult = await checkDashboardSettings(options, environment.apiKeyType, envRaw); + + // Compute expected redirect URI from framework detection if not set in env + const redirectUriSource: 'env' | 'inferred' = environment.redirectUri ? 'env' : 'inferred'; + const expectedRedirectUri = + environment.redirectUri ?? + (framework.expectedCallbackPath && framework.detectedPort + ? `http://localhost:${framework.detectedPort}${framework.expectedCallbackPath}` + : null); + + // Compare redirect URIs if we have dashboard data + const redirectUris = dashboardResult.settings + ? compareRedirectUris(expectedRedirectUri, dashboardResult.settings.redirectUris, redirectUriSource) + : undefined; + + // Build partial report + const partialReport = { + version: DOCTOR_VERSION, + timestamp: new Date().toISOString(), + project: { + path: options.installDir, + packageManager: runtime.packageManager, + }, + sdk, + runtime, + framework, + environment, + connectivity, + credentialValidation: dashboardResult.credentialValidation, + dashboardSettings: dashboardResult.settings ?? undefined, + dashboardError: dashboardResult.settings ? undefined : dashboardResult.error, + redirectUris, + }; + + // Detect issues based on collected data + const issues = detectIssues(partialReport); + + // Calculate summary + const errors = issues.filter((i) => i.severity === 'error').length; + const warnings = issues.filter((i) => i.severity === 'warning').length; + + const report: DoctorReport = { + ...partialReport, + issues, + summary: { + errors, + warnings, + healthy: errors === 0, + }, + }; + + return report; +} + +export async function outputReport(report: DoctorReport, options: DoctorOptions): Promise { + if (options.json) { + const json = formatReportAsJson(report); + console.log(json); + + if (options.copy) { + const success = await copyToClipboard(json); + if (success) { + console.error('(Copied to clipboard)'); + } + } + } else { + formatReport(report, { verbose: options.verbose }); + + if (options.copy) { + const json = formatReportAsJson(report); + const success = await copyToClipboard(json); + if (success) { + console.log(Chalk.dim('Report copied to clipboard')); + } + } + } +} + +export { formatReport } from './output.js'; +export { formatReportAsJson } from './json-output.js'; +export type { DoctorReport, DoctorOptions } from './types.js'; diff --git a/src/doctor/issues.ts b/src/doctor/issues.ts new file mode 100644 index 0000000..969f6e5 --- /dev/null +++ b/src/doctor/issues.ts @@ -0,0 +1,144 @@ +import type { Issue, DoctorReport } from './types.js'; + +export const ISSUE_DEFINITIONS = { + MISSING_API_KEY: { + severity: 'error' as const, + message: 'WORKOS_API_KEY environment variable not set', + remediation: 'Set WORKOS_API_KEY in your .env.local file', + docsUrl: 'https://dashboard.workos.com/api-keys', + }, + MISSING_CLIENT_ID: { + severity: 'error' as const, + message: 'WORKOS_CLIENT_ID environment variable not set', + remediation: 'Set WORKOS_CLIENT_ID in your .env.local file', + docsUrl: 'https://dashboard.workos.com/configuration', + }, + SDK_OUTDATED: { + severity: 'warning' as const, + message: 'SDK version is outdated', + // remediation generated dynamically + }, + COOKIE_DOMAIN_NOT_SET: { + severity: 'warning' as const, + message: 'Cookie domain not explicitly set', + remediation: 'Consider setting WORKOS_COOKIE_DOMAIN for cross-subdomain auth', + docsUrl: 'https://workos.com/docs/authkit/cookie-domain', + }, + NO_SDK_FOUND: { + severity: 'error' as const, + message: 'No WorkOS SDK found in dependencies', + remediation: 'Install a WorkOS SDK: npm install @workos-inc/authkit-nextjs', + docsUrl: 'https://workos.com/docs', + }, + API_UNREACHABLE: { + severity: 'warning' as const, + message: 'Cannot reach WorkOS API', + // remediation generated dynamically based on error + }, + REDIRECT_URI_MISMATCH: { + severity: 'warning' as const, + message: 'Redirect URI not found in dashboard configuration', + docsUrl: 'https://workos.com/docs/authkit/redirect-uri', + }, + PROD_API_CALL_BLOCKED: { + severity: 'warning' as const, + message: 'Dashboard settings not fetched (production API key)', + remediation: 'Use a staging API key to fetch dashboard settings', + }, + INVALID_API_KEY: { + severity: 'error' as const, + message: 'API key is invalid or expired', + remediation: 'Check your WORKOS_API_KEY in the WorkOS dashboard', + docsUrl: 'https://dashboard.workos.com/api-keys', + }, + CLIENT_ID_MISMATCH: { + severity: 'error' as const, + message: 'Client ID does not match the API key environment', + remediation: 'Ensure WORKOS_CLIENT_ID matches the environment for your API key', + docsUrl: 'https://dashboard.workos.com/configuration', + }, +}; + +export function detectIssues(report: Omit): Issue[] { + const issues: Issue[] = []; + + // SDK issues + if (!report.sdk.name) { + issues.push({ code: 'NO_SDK_FOUND', ...ISSUE_DEFINITIONS.NO_SDK_FOUND }); + } else if (report.sdk.outdated && report.sdk.version && report.sdk.latest) { + issues.push({ + code: 'SDK_OUTDATED', + severity: 'warning', + message: `SDK version outdated (${report.sdk.version} → ${report.sdk.latest})`, + remediation: `Run: ${getUpdateCommand(report.runtime.packageManager, report.sdk.name)}`, + details: { installed: report.sdk.version, latest: report.sdk.latest }, + }); + } + + // Environment issues + if (!report.environment.apiKeyConfigured) { + issues.push({ code: 'MISSING_API_KEY', ...ISSUE_DEFINITIONS.MISSING_API_KEY }); + } + + if (!report.environment.clientId) { + issues.push({ code: 'MISSING_CLIENT_ID', ...ISSUE_DEFINITIONS.MISSING_CLIENT_ID }); + } + + if (report.sdk.isAuthKit && !report.environment.cookieDomain) { + issues.push({ code: 'COOKIE_DOMAIN_NOT_SET', ...ISSUE_DEFINITIONS.COOKIE_DOMAIN_NOT_SET }); + } + + // Connectivity issues + if (!report.connectivity.apiReachable && !report.connectivity.error?.includes('Skipped')) { + issues.push({ + code: 'API_UNREACHABLE', + severity: 'warning', + message: `Cannot reach WorkOS API: ${report.connectivity.error}`, + remediation: 'Check your network connection and firewall settings', + }); + } + + // Note: Redirect URI mismatch detection disabled - WorkOS API doesn't expose + // a public endpoint to list configured redirect URIs for verification + + // Production key warning (no dashboard data) + if (report.environment.apiKeyType === 'production' && !report.dashboardSettings) { + issues.push({ + code: 'PROD_API_CALL_BLOCKED', + ...ISSUE_DEFINITIONS.PROD_API_CALL_BLOCKED, + }); + } + + // Credential validation issues + if (report.credentialValidation) { + if (!report.credentialValidation.valid) { + issues.push({ + code: 'INVALID_API_KEY', + ...ISSUE_DEFINITIONS.INVALID_API_KEY, + details: { error: report.credentialValidation.error }, + }); + } else if (!report.credentialValidation.clientIdMatch) { + issues.push({ + code: 'CLIENT_ID_MISMATCH', + ...ISSUE_DEFINITIONS.CLIENT_ID_MISMATCH, + details: { error: report.credentialValidation.error }, + }); + } + } + + return issues; +} + +function getUpdateCommand(packageManager: string | null, sdkName: string): string { + switch (packageManager) { + case 'pnpm': + return `pnpm update ${sdkName}`; + case 'Yarn V1': + case 'Yarn V2/3/4': + return `yarn upgrade ${sdkName}`; + case 'Bun': + return `bun update ${sdkName}`; + default: + return `npm update ${sdkName}`; + } +} diff --git a/src/doctor/json-output.ts b/src/doctor/json-output.ts new file mode 100644 index 0000000..98d6390 --- /dev/null +++ b/src/doctor/json-output.ts @@ -0,0 +1,5 @@ +import type { DoctorReport } from './types.js'; + +export function formatReportAsJson(report: DoctorReport): string { + return JSON.stringify(report, null, 2); +} diff --git a/src/doctor/output.ts b/src/doctor/output.ts new file mode 100644 index 0000000..cabeb68 --- /dev/null +++ b/src/doctor/output.ts @@ -0,0 +1,167 @@ +import Chalk from 'chalk'; +import type { DoctorReport, Issue } from './types.js'; + +export interface FormatOptions { + verbose?: boolean; +} + +export function formatReport(report: DoctorReport, options?: FormatOptions): void { + console.log(''); + console.log(Chalk.cyan('WorkOS Doctor')); + console.log(Chalk.dim('━'.repeat(70))); + + // SDK & Project + console.log(''); + console.log('SDK & Project Information'); + if (report.sdk.name) { + console.log(` SDK: ${report.sdk.name} v${report.sdk.version}`); + } else { + console.log(` SDK: ${Chalk.red('Not found')}`); + } + console.log(` Runtime: Node.js ${report.runtime.nodeVersion}`); + if (report.framework.name) { + const variant = report.framework.variant ? ` (${report.framework.variant})` : ''; + console.log(` Framework: ${report.framework.name} ${report.framework.version}${variant}`); + } + if (report.runtime.packageManager) { + console.log(` Package Manager: ${report.runtime.packageManager} ${report.runtime.packageManagerVersion ?? ''}`); + } + + // Environment + console.log(''); + console.log('Environment Configuration'); + const envType = report.environment.apiKeyType + ? report.environment.apiKeyType.charAt(0).toUpperCase() + report.environment.apiKeyType.slice(1) + : 'Unable to determine'; + console.log(` Environment: ${envType}`); + console.log(` Client ID: ${report.environment.clientId ?? Chalk.red('Not set')}`); + console.log( + ` API Key: ${report.environment.apiKeyConfigured ? Chalk.green('configured') : Chalk.red('not set')}`, + ); + console.log(` Base URL: ${report.environment.baseUrl} ${Chalk.green('✓')}`); + + // Connectivity & Credential Validation + console.log(''); + console.log('Connectivity'); + if (report.connectivity.apiReachable) { + console.log(` API: ${Chalk.green('✓')} Reachable (${report.connectivity.latencyMs}ms)`); + } else if (report.connectivity.error?.includes('Skipped')) { + console.log(` API: ${Chalk.dim('Skipped (--skip-api)')}`); + } else { + console.log(` API: ${Chalk.red('✗')} ${report.connectivity.error}`); + } + + // Credential validation + if (report.credentialValidation) { + if (report.credentialValidation.valid && report.credentialValidation.clientIdMatch) { + console.log(` Credentials: ${Chalk.green('✓')} Valid and matching`); + } else if (!report.credentialValidation.valid) { + console.log(` Credentials: ${Chalk.red('✗')} ${report.credentialValidation.error ?? 'Invalid'}`); + } else if (!report.credentialValidation.clientIdMatch) { + console.log(` Credentials: ${Chalk.red('✗')} Client ID mismatch`); + } + } + + // Dashboard Settings (if available) + if (report.dashboardSettings) { + console.log(''); + console.log('Dashboard Settings (Staging)'); + console.log( + ` Auth Methods: ${report.dashboardSettings.authMethods.length > 0 ? report.dashboardSettings.authMethods.join(', ') : 'None configured'}`, + ); + console.log(` Session Timeout: ${report.dashboardSettings.sessionTimeout ?? 'Default'}`); + console.log(` MFA: ${formatMfa(report.dashboardSettings.mfa)}`); + console.log(` Organizations: ${report.dashboardSettings.organizationCount} configured`); + } else if (report.dashboardError) { + console.log(''); + console.log('Dashboard Settings'); + console.log(` Status: ${Chalk.dim(report.dashboardError)}`); + } + + // Redirect URI (can't verify against dashboard - no list API) + if (report.redirectUris?.codeUri) { + console.log(''); + const source = report.redirectUris.source === 'inferred' ? 'Inferred' : 'Configured'; + console.log(`Redirect URI (${source})`); + console.log(` ${report.redirectUris.codeUri}`); + } + + // Verbose mode additions + if (options?.verbose) { + console.log(''); + console.log(Chalk.dim('Verbose Details')); + console.log(` ${Chalk.dim('Project path:')} ${report.project.path}`); + console.log(` ${Chalk.dim('Timestamp:')} ${report.timestamp}`); + console.log(` ${Chalk.dim('Doctor version:')} ${report.version}`); + } + + console.log(''); + console.log(Chalk.dim('━'.repeat(70))); + + // Issues + if (report.issues.length > 0) { + const errors = report.issues.filter((i) => i.severity === 'error'); + const warnings = report.issues.filter((i) => i.severity === 'warning'); + + console.log(''); + if (errors.length > 0) { + console.log(Chalk.red(`Critical Issues Found (${errors.length} errors)`)); + console.log(''); + for (const issue of errors) { + formatIssue(issue); + } + } + + if (warnings.length > 0) { + console.log(Chalk.yellow(`Warnings (${warnings.length})`)); + console.log(''); + for (const issue of warnings) { + formatIssue(issue); + } + } + } + + console.log(Chalk.dim('━'.repeat(70))); + console.log(''); + + // Summary + if (report.summary.healthy) { + console.log(Chalk.green('Your WorkOS integration looks healthy!')); + } else if (report.summary.errors > 0) { + console.log(Chalk.red(`${report.summary.errors} issue(s) must be resolved before authentication will work.`)); + } else { + console.log(Chalk.yellow(`${report.summary.warnings} warning(s) to review.`)); + } + + console.log(''); + console.log(Chalk.dim('Copy this report: workos doctor --copy')); + console.log(Chalk.dim('Troubleshooting: https://workos.com/docs/troubleshooting')); + console.log(''); +} + +function formatIssue(issue: Issue): void { + const icon = issue.severity === 'error' ? Chalk.red('✗') : Chalk.yellow('!'); + const color = issue.severity === 'error' ? Chalk.red : Chalk.yellow; + + console.log(`${icon} ${color(issue.code)}: ${issue.message}`); + if (issue.remediation) { + console.log(` ${Chalk.dim('→')} ${issue.remediation}`); + } + if (issue.docsUrl) { + console.log(` ${Chalk.dim('Docs:')} ${issue.docsUrl}`); + } + console.log(''); +} + +function formatMfa(mfa: string | null): string { + switch (mfa) { + case 'required': + return 'Required'; + case 'optional': + return 'Optional'; + case 'disabled': + return 'Disabled'; + default: + return 'Not configured'; + } +} diff --git a/src/doctor/types.ts b/src/doctor/types.ts new file mode 100644 index 0000000..25b3608 --- /dev/null +++ b/src/doctor/types.ts @@ -0,0 +1,119 @@ +export type IssueSeverity = 'error' | 'warning'; + +export interface Issue { + code: string; + severity: IssueSeverity; + message: string; + details?: Record; + remediation?: string; + docsUrl?: string; +} + +export interface SdkInfo { + name: string | null; + version: string | null; + latest: string | null; + outdated: boolean; + isAuthKit: boolean; +} + +export interface FrameworkInfo { + name: string | null; + version: string | null; + variant?: string; // e.g., 'app-router' | 'pages-router' + expectedCallbackPath?: string; // e.g., '/auth/callback' for Next.js + detectedPort?: number; +} + +export interface RuntimeInfo { + nodeVersion: string; + packageManager: string | null; + packageManagerVersion: string | null; +} + +export interface EnvironmentInfo { + apiKeyConfigured: boolean; + apiKeyType: 'staging' | 'production' | null; + clientId: string | null; // truncated for display + redirectUri: string | null; + cookieDomain: string | null; + baseUrl: string | null; +} + +/** Internal environment data - not included in report output */ +export interface EnvironmentRaw { + apiKey: string | null; + clientId: string | null; + baseUrl: string | null; +} + +export interface EnvironmentCheckResult { + info: EnvironmentInfo; + raw: EnvironmentRaw; +} + +export interface ConnectivityInfo { + apiReachable: boolean; + latencyMs: number | null; + tlsValid: boolean; + error?: string; +} + +export interface DashboardSettings { + redirectUris: string[]; + authMethods: string[]; + sessionTimeout: string | null; + mfa: 'optional' | 'required' | 'disabled' | null; + organizationCount: number; +} + +export interface RedirectUriComparison { + codeUri: string | null; + dashboardUris: string[]; + match: boolean; + source?: 'env' | 'inferred'; // Where the codeUri came from +} + +export interface CredentialValidation { + valid: boolean; + clientIdMatch: boolean; + error?: string; +} + +export interface DashboardFetchResult { + settings: DashboardSettings | null; + credentialValidation?: CredentialValidation; + error?: string; +} + +export interface DoctorReport { + version: string; + timestamp: string; + project: { + path: string; + packageManager: string | null; + }; + sdk: SdkInfo; + runtime: RuntimeInfo; + framework: FrameworkInfo; + environment: EnvironmentInfo; + connectivity: ConnectivityInfo; + dashboardSettings?: DashboardSettings; + dashboardError?: string; + redirectUris?: RedirectUriComparison; + credentialValidation?: CredentialValidation; + issues: Issue[]; + summary: { + errors: number; + warnings: number; + healthy: boolean; + }; +} + +export interface DoctorOptions { + installDir: string; + verbose?: boolean; + skipApi?: boolean; + json?: boolean; + copy?: boolean; +} diff --git a/tests/evals/graders/elixir.grader.ts b/tests/evals/graders/elixir.grader.ts index 63d589d..b05ab11 100644 --- a/tests/evals/graders/elixir.grader.ts +++ b/tests/evals/graders/elixir.grader.ts @@ -42,11 +42,7 @@ export class ElixirGrader implements Grader { // Bonus: existing app routes preserved (proves agent read existing code) bonusChecks.push( - await this.fileGrader.checkFileWithPattern( - 'lib/**/*.ex', - [/api\/health/], - 'Existing app routes preserved', - ), + await this.fileGrader.checkFileWithPattern('lib/**/*.ex', [/api\/health/], 'Existing app routes preserved'), ); const allChecks = [...requiredChecks, ...bonusChecks]; diff --git a/tests/evals/graders/go.grader.ts b/tests/evals/graders/go.grader.ts index 1aa16ff..e0c998a 100644 --- a/tests/evals/graders/go.grader.ts +++ b/tests/evals/graders/go.grader.ts @@ -36,11 +36,7 @@ export class GoGrader implements Grader { // Bonus: existing app routes preserved (proves agent read existing code) bonusChecks.push( - await this.fileGrader.checkFileWithPattern( - '**/*.go', - [/api\/health/], - 'Existing app routes preserved', - ), + await this.fileGrader.checkFileWithPattern('**/*.go', [/api\/health/], 'Existing app routes preserved'), ); const allChecks = [...requiredChecks, ...bonusChecks]; diff --git a/tests/evals/graders/kotlin.grader.ts b/tests/evals/graders/kotlin.grader.ts index c57df56..3c5d100 100644 --- a/tests/evals/graders/kotlin.grader.ts +++ b/tests/evals/graders/kotlin.grader.ts @@ -32,11 +32,7 @@ export class KotlinGrader implements Grader { // Bonus: existing app routes preserved (proves agent read existing code) bonusChecks.push( - await this.fileGrader.checkFileWithPattern( - '**/*.kt', - [/api\/health/], - 'Existing app routes preserved', - ), + await this.fileGrader.checkFileWithPattern('**/*.kt', [/api\/health/], 'Existing app routes preserved'), ); const allChecks = [...requiredChecks, ...bonusChecks]; diff --git a/tests/evals/graders/node.grader.ts b/tests/evals/graders/node.grader.ts index 3347042..d0c01bd 100644 --- a/tests/evals/graders/node.grader.ts +++ b/tests/evals/graders/node.grader.ts @@ -56,11 +56,7 @@ export class NodeGrader implements Grader { // Bonus: existing app routes preserved (proves agent read existing code) bonusChecks.push( - await this.fileGrader.checkFileWithPattern( - '**/*.{js,ts}', - [/api\/health/], - 'Existing app routes preserved', - ), + await this.fileGrader.checkFileWithPattern('**/*.{js,ts}', [/api\/health/], 'Existing app routes preserved'), ); // Bonus: sealed session handling (step 3 of quickstart) diff --git a/tests/evals/graders/php-laravel.grader.ts b/tests/evals/graders/php-laravel.grader.ts index 7047c7f..6fd9b5c 100644 --- a/tests/evals/graders/php-laravel.grader.ts +++ b/tests/evals/graders/php-laravel.grader.ts @@ -32,7 +32,9 @@ export class PhpLaravelGrader implements Grader { requiredChecks.push(...(await this.fileGrader.checkFileContains('composer.json', ['workos']))); // Required: auth integration exists somewhere in PHP files - requiredChecks.push(await this.fileGrader.checkFileWithPattern('**/*.php', [/workos/i], 'WorkOS integration in PHP files')); + requiredChecks.push( + await this.fileGrader.checkFileWithPattern('**/*.php', [/workos/i], 'WorkOS integration in PHP files'), + ); // Required: routes contain auth paths requiredChecks.push( diff --git a/tests/evals/graders/php.grader.ts b/tests/evals/graders/php.grader.ts index e5ec518..b2fc2eb 100644 --- a/tests/evals/graders/php.grader.ts +++ b/tests/evals/graders/php.grader.ts @@ -38,11 +38,7 @@ export class PhpGrader implements Grader { // Bonus: existing app routes preserved (proves agent read existing code) bonusChecks.push( - await this.fileGrader.checkFileWithPattern( - '**/*.php', - [/api\/health/], - 'Existing app routes preserved', - ), + await this.fileGrader.checkFileWithPattern('**/*.php', [/api\/health/], 'Existing app routes preserved'), ); const allChecks = [...requiredChecks, ...bonusChecks]; diff --git a/tests/evals/graders/python.grader.ts b/tests/evals/graders/python.grader.ts index e6420b5..0206188 100644 --- a/tests/evals/graders/python.grader.ts +++ b/tests/evals/graders/python.grader.ts @@ -56,11 +56,7 @@ export class PythonGrader implements Grader { // Bonus: existing app routes preserved (proves agent read existing code) bonusChecks.push( - await this.fileGrader.checkFileWithPattern( - '**/*.py', - [/api\/health/], - 'Existing app routes preserved', - ), + await this.fileGrader.checkFileWithPattern('**/*.py', [/api\/health/], 'Existing app routes preserved'), ); // Bonus: sealed session handling diff --git a/tests/evals/graders/ruby.grader.ts b/tests/evals/graders/ruby.grader.ts index 865a29d..406a731 100644 --- a/tests/evals/graders/ruby.grader.ts +++ b/tests/evals/graders/ruby.grader.ts @@ -51,11 +51,7 @@ export class RubyGrader implements Grader { // Bonus: existing app routes preserved (proves agent read existing code) bonusChecks.push( - await this.fileGrader.checkFileWithPattern( - '**/*.rb', - [/api\/health/], - 'Existing app routes preserved', - ), + await this.fileGrader.checkFileWithPattern('**/*.rb', [/api\/health/], 'Existing app routes preserved'), ); // Bonus: sealed session handling diff --git a/tests/fixtures/node/example-auth0/server.js b/tests/fixtures/node/example-auth0/server.js index 92d8ff1..23686e8 100644 --- a/tests/fixtures/node/example-auth0/server.js +++ b/tests/fixtures/node/example-auth0/server.js @@ -16,7 +16,7 @@ app.use( baseURL: process.env.AUTH0_BASE_URL || 'http://localhost:3000', clientID: process.env.AUTH0_CLIENT_ID, issuerBaseURL: `https://${process.env.AUTH0_DOMAIN}`, - }) + }), ); app.get('/', (req, res) => {