From 2ff57640f22b6d3637ed48f10554925534336308 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 5 Feb 2026 09:12:37 -0500 Subject: [PATCH 01/12] feat: add `workos doctor` command for diagnosing integration issues Introduces a new diagnostic command that checks: - SDK detection and version status - Framework detection (Next.js, React Router, etc.) - Runtime info (Node.js, package manager) - Environment variables (API key, client ID) - API connectivity and latency Outputs actionable remediation steps for any issues found. Supports --json for scripting and --skip-api for offline mode. --- src/bin.ts | 31 ++++++++ src/commands/doctor.ts | 38 ++++++++++ src/doctor/checks/connectivity.ts | 41 +++++++++++ src/doctor/checks/environment.ts | 31 ++++++++ src/doctor/checks/framework.ts | 57 +++++++++++++++ src/doctor/checks/runtime.ts | 24 +++++++ src/doctor/checks/sdk.ts | 91 +++++++++++++++++++++++ src/doctor/index.ts | 59 +++++++++++++++ src/doctor/issues.ts | 94 ++++++++++++++++++++++++ src/doctor/output.ts | 115 ++++++++++++++++++++++++++++++ src/doctor/types.ts | 72 +++++++++++++++++++ 11 files changed, 653 insertions(+) create mode 100644 src/commands/doctor.ts create mode 100644 src/doctor/checks/connectivity.ts create mode 100644 src/doctor/checks/environment.ts create mode 100644 src/doctor/checks/framework.ts create mode 100644 src/doctor/checks/runtime.ts create mode 100644 src/doctor/checks/sdk.ts create mode 100644 src/doctor/index.ts create mode 100644 src/doctor/issues.ts create mode 100644 src/doctor/output.ts create mode 100644 src/doctor/types.ts diff --git a/src/bin.ts b/src/bin.ts index ee7efcf..11ab430 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -187,6 +187,37 @@ 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', + }, + }), + 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..1715c3f --- /dev/null +++ b/src/commands/doctor.ts @@ -0,0 +1,38 @@ +import type { ArgumentsCamelCase } from 'yargs'; +import { runDoctor, formatReport } from '../doctor/index.js'; +import clack from '../utils/clack.js'; + +interface DoctorArgs { + verbose?: boolean; + skipApi?: boolean; + installDir?: string; + json?: boolean; +} + +export async function handleDoctor(argv: ArgumentsCamelCase): Promise { + const options = { + installDir: argv.installDir ?? process.cwd(), + verbose: argv.verbose ?? false, + skipApi: argv.skipApi ?? false, + }; + + try { + const report = await runDoctor(options); + + // JSON output mode + if (argv.json) { + console.log(JSON.stringify(report, null, 2)); + } else { + formatReport(report); + } + + // Exit with error code if critical issues found + if (report.summary.errors > 0) { + process.exit(1); + } + process.exit(0); + } catch (error) { + clack.log.error(`Doctor failed: ${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..d9fa45a --- /dev/null +++ b/src/doctor/checks/connectivity.ts @@ -0,0 +1,41 @@ +import type { DoctorOptions, ConnectivityInfo } from '../types.js'; + +export async function checkConnectivity(options: DoctorOptions): Promise { + if (options.skipApi) { + return { + apiReachable: false, + latencyMs: null, + tlsValid: false, + error: 'Skipped (--skip-api)', + }; + } + + const baseUrl = process.env.WORKOS_BASE_URL ?? 'https://api.workos.com'; + 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/environment.ts b/src/doctor/checks/environment.ts new file mode 100644 index 0000000..cbd6b96 --- /dev/null +++ b/src/doctor/checks/environment.ts @@ -0,0 +1,31 @@ +import type { EnvironmentInfo } from '../types.js'; + +export function checkEnvironment(): EnvironmentInfo { + const apiKey = process.env.WORKOS_API_KEY; + const clientId = process.env.WORKOS_CLIENT_ID; + const redirectUri = process.env.WORKOS_REDIRECT_URI; + const cookieDomain = process.env.WORKOS_COOKIE_DOMAIN; + const baseUrl = process.env.WORKOS_BASE_URL; + + return { + apiKeyConfigured: !!apiKey, + apiKeyType: getApiKeyType(apiKey), + clientId: truncateClientId(clientId), + redirectUri: redirectUri ?? null, + cookieDomain: cookieDomain ?? null, + 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..606dc2b --- /dev/null +++ b/src/doctor/checks/framework.ts @@ -0,0 +1,57 @@ +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 type { DoctorOptions, FrameworkInfo } from '../types.js'; + +interface FrameworkConfig { + name: string; + detectVariant: ((options: DoctorOptions) => Promise) | null; +} + +const FRAMEWORKS: Record = { + next: { name: 'Next.js', detectVariant: detectNextVariant }, + express: { name: 'Express', detectVariant: null }, + '@remix-run/node': { name: 'Remix', detectVariant: null }, + '@tanstack/react-router': { name: 'TanStack Router', detectVariant: null }, + '@tanstack/start': { name: 'TanStack Start', detectVariant: null }, + 'react-router-dom': { name: 'React Router', detectVariant: null }, +}; + +export async function checkFramework(options: DoctorOptions): Promise { + let packageJson; + try { + packageJson = await getPackageDotJson(options); + } catch { + return { name: null, version: null }; + } + + for (const [pkg, config] of Object.entries(FRAMEWORKS)) { + if (hasPackageInstalled(pkg, packageJson)) { + const version = getPackageVersion(pkg, packageJson) ?? null; + const variant = config.detectVariant ? await config.detectVariant(options) : undefined; + + return { + name: config.name, + version, + variant, + }; + } + } + + 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..3c4477e --- /dev/null +++ b/src/doctor/checks/sdk.ts @@ -0,0 +1,91 @@ +import { getPackageDotJson } from '../../utils/clack-utils.js'; +import { hasPackageInstalled, getPackageVersion } from '../../utils/package-json.js'; +import type { DoctorOptions, SdkInfo } from '../types.js'; + +const SDK_PACKAGES = [ + '@workos-inc/authkit-nextjs', + '@workos-inc/authkit-remix', + '@workos-inc/authkit-react-router', + '@workos-inc/authkit-tanstack-start', + '@workos-inc/node', + 'workos', // legacy +] as const; + +const AUTHKIT_PACKAGES = new Set([ + '@workos-inc/authkit-nextjs', + '@workos-inc/authkit-remix', + '@workos-inc/authkit-react-router', + '@workos-inc/authkit-tanstack-start', +]); + +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.) + const cleanCurrent = current.replace(/^[\^~>=<]+/, ''); + const cleanLatest = latest.replace(/^[\^~>=<]+/, ''); + + const [currMajor, currMinor, currPatch] = cleanCurrent.split('.').map(Number); + const [latMajor, latMinor, latPatch] = cleanLatest.split('.').map(Number); + + 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/index.ts b/src/doctor/index.ts new file mode 100644 index 0000000..9d4d9fb --- /dev/null +++ b/src/doctor/index.ts @@ -0,0 +1,59 @@ +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 { detectIssues } from './issues.js'; +import type { DoctorOptions, DoctorReport } from './types.js'; + +const DOCTOR_VERSION = '1.0.0'; + +export async function runDoctor(options: DoctorOptions): Promise { + // Run all checks concurrently where possible + const [sdk, framework, runtime, connectivity] = await Promise.all([ + checkSdk(options), + checkFramework(options), + checkRuntime(options), + checkConnectivity(options), + ]); + + // Environment check is synchronous + const environment = checkEnvironment(); + + // Build partial report + const partialReport = { + version: DOCTOR_VERSION, + timestamp: new Date().toISOString(), + project: { + path: options.installDir, + packageManager: runtime.packageManager, + }, + sdk, + runtime, + framework, + environment, + connectivity, + }; + + // 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 { formatReport } from './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..a74c88d --- /dev/null +++ b/src/doctor/issues.ts @@ -0,0 +1,94 @@ +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 + }, +}; + +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', + }); + } + + 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/output.ts b/src/doctor/output.ts new file mode 100644 index 0000000..1e25c98 --- /dev/null +++ b/src/doctor/output.ts @@ -0,0 +1,115 @@ +import Chalk from 'chalk'; +import type { DoctorReport, Issue } from './types.js'; + +const DOCTOR_VERSION = '1.0.0'; + +export function formatReport(report: DoctorReport): void { + console.log(''); + console.log(Chalk.cyan(`WorkOS Doctor v${DOCTOR_VERSION}`)); + 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('✓')}`); + + // Redirect URI + if (report.environment.redirectUri) { + console.log(''); + console.log('Redirect URI'); + console.log(` Code: ${report.environment.redirectUri}`); + } + + // Connectivity + 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 (--no-api)')}`); + } else { + console.log(` API: ${Chalk.red('✗')} ${report.connectivity.error}`); + } + + 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 --json | pbcopy')); + 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(''); +} diff --git a/src/doctor/types.ts b/src/doctor/types.ts new file mode 100644 index 0000000..7146cff --- /dev/null +++ b/src/doctor/types.ts @@ -0,0 +1,72 @@ +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' +} + +export interface RuntimeInfo { + nodeVersion: string; + packageManager: string | null; + packageManagerVersion: string | null; +} + +export interface EnvironmentInfo { + apiKeyConfigured: boolean; + apiKeyType: 'staging' | 'production' | null; + clientId: string | null; // truncated + redirectUri: string | null; + cookieDomain: string | null; + baseUrl: string | null; +} + +export interface ConnectivityInfo { + apiReachable: boolean; + latencyMs: number | null; + tlsValid: boolean; + 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; + issues: Issue[]; + summary: { + errors: number; + warnings: number; + healthy: boolean; + }; +} + +export interface DoctorOptions { + installDir: string; + verbose?: boolean; + skipApi?: boolean; +} From f0e0f5c2f1c342e2ceedf8ef6f86f573f2f746ed Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 5 Feb 2026 11:22:03 -0500 Subject: [PATCH 02/12] feat(doctor): add dashboard integration, JSON output, and credential validation Phase 2 enhancements to the doctor command: - Fetch dashboard settings from WorkOS API (staging only, production blocked) - Validate credentials against /organizations endpoint - Detect redirect URI mismatches between code and dashboard - Add --json flag for machine-readable output - Add --verbose flag for additional diagnostic details - Add --copy flag for cross-platform clipboard support - Support both @workos/* and @workos-inc/* SDK package scopes - Load environment variables from project's .env and .env.local files --- src/bin.ts | 5 + src/commands/doctor.ts | 19 ++-- src/doctor/checks/dashboard.ts | 163 +++++++++++++++++++++++++++++++ src/doctor/checks/environment.ts | 78 ++++++++++++--- src/doctor/checks/sdk.ts | 25 ++++- src/doctor/clipboard.ts | 43 ++++++++ src/doctor/index.ts | 48 ++++++++- src/doctor/issues.ts | 62 ++++++++++++ src/doctor/json-output.ts | 5 + src/doctor/output.ts | 70 ++++++++++++- src/doctor/types.ts | 39 +++++++- 11 files changed, 526 insertions(+), 31 deletions(-) create mode 100644 src/doctor/checks/dashboard.ts create mode 100644 src/doctor/clipboard.ts create mode 100644 src/doctor/json-output.ts diff --git a/src/bin.ts b/src/bin.ts index 11ab430..4a8b1d9 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -212,6 +212,11 @@ yargs(hideBin(process.argv)) 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'); diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 1715c3f..6be9649 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -1,5 +1,5 @@ import type { ArgumentsCamelCase } from 'yargs'; -import { runDoctor, formatReport } from '../doctor/index.js'; +import { runDoctor, outputReport } from '../doctor/index.js'; import clack from '../utils/clack.js'; interface DoctorArgs { @@ -7,6 +7,7 @@ interface DoctorArgs { skipApi?: boolean; installDir?: string; json?: boolean; + copy?: boolean; } export async function handleDoctor(argv: ArgumentsCamelCase): Promise { @@ -14,17 +15,13 @@ export async function handleDoctor(argv: ArgumentsCamelCase): Promis 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); - - // JSON output mode - if (argv.json) { - console.log(JSON.stringify(report, null, 2)); - } else { - formatReport(report); - } + await outputReport(report, options); // Exit with error code if critical issues found if (report.summary.errors > 0) { @@ -32,7 +29,11 @@ export async function handleDoctor(argv: ArgumentsCamelCase): Promis } process.exit(0); } catch (error) { - clack.log.error(`Doctor failed: ${error instanceof Error ? error.message : 'Unknown 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/dashboard.ts b/src/doctor/checks/dashboard.ts new file mode 100644 index 0000000..8209087 --- /dev/null +++ b/src/doctor/checks/dashboard.ts @@ -0,0 +1,163 @@ +import type { DashboardSettings, DoctorOptions, RedirectUriComparison, EnvironmentRaw } from '../types.js'; + +const WORKOS_API_URL = 'https://api.workos.com'; + +export interface CredentialValidation { + valid: boolean; + clientIdMatch: boolean; + error?: string; +} + +export async function checkDashboardSettings( + options: DoctorOptions, + apiKeyType: 'staging' | 'production' | null, + raw: EnvironmentRaw, +): Promise { + // Never call API with production keys + if (apiKeyType === 'production') { + return null; + } + + if (options.skipApi) { + return null; + } + + const apiKey = raw.apiKey; + if (!apiKey) { + return null; + } + + try { + const settings = await fetchDashboardSettings(apiKey, raw.baseUrl); + return settings; + } catch { + // Fail silently - dashboard data is supplementary + return null; + } +} + +/** + * Validate credentials against WorkOS API. + * Checks if API key is valid by making a simple API call. + */ +export async function validateCredentials( + apiKeyType: 'staging' | 'production' | null, + raw: EnvironmentRaw, + skipApi?: boolean, +): Promise { + // Skip for production keys or if API calls disabled + if (apiKeyType === 'production' || skipApi || !raw.apiKey) { + return null; + } + + const baseUrl = raw.baseUrl ?? WORKOS_API_URL; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); + + try { + // Use /organizations endpoint to validate API key (lightweight call) + const response = await fetch(`${baseUrl}/organizations?limit=1`, { + headers: { Authorization: `Bearer ${raw.apiKey}` }, + signal: controller.signal, + }); + + if (!response.ok) { + if (response.status === 401) { + return { valid: false, clientIdMatch: true, error: 'Invalid API key' }; + } + if (response.status === 403) { + return { valid: false, clientIdMatch: true, error: 'API key lacks permissions' }; + } + return { valid: false, clientIdMatch: true, error: `API error: ${response.status}` }; + } + + // API key is valid - we can't easily verify client ID match without a dedicated endpoint + // but at least we know the key works + return { + valid: true, + clientIdMatch: true, // Assume match since we can't verify + }; + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') { + return { valid: false, clientIdMatch: true, error: 'Validation timeout' }; + } + return null; // Network error, skip validation + } finally { + clearTimeout(timeoutId); + } +} + +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 { + // Fetch redirect URIs + const redirectUrisResponse = await fetch(`${baseUrl}/redirect_uris`, { + headers: { Authorization: `Bearer ${apiKey}` }, + signal: controller.signal, + }); + + let redirectUris: string[] = []; + if (redirectUrisResponse.ok) { + const data = (await redirectUrisResponse.json()) as { data?: { uri: string }[] }; + redirectUris = data.data?.map((r) => r.uri) ?? []; + } + + // 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; + } + + // Fetch organization count + const orgsResponse = await fetch(`${baseUrl}/organizations?limit=1`, { + headers: { Authorization: `Bearer ${apiKey}` }, + signal: controller.signal, + }); + + let organizationCount = 0; + if (orgsResponse.ok) { + const orgsData = (await orgsResponse.json()) as { + list_metadata?: { total_count?: number }; + data?: unknown[]; + }; + organizationCount = orgsData.list_metadata?.total_count ?? orgsData.data?.length ?? 0; + } + + return { + redirectUris, + authMethods, + sessionTimeout, + mfa, + organizationCount, + }; + } finally { + clearTimeout(timeoutId); + } +} + +export function compareRedirectUris(codeUri: string | null, dashboardUris: string[]): RedirectUriComparison { + return { + codeUri, + dashboardUris, + match: codeUri ? dashboardUris.includes(codeUri) : false, + }; +} diff --git a/src/doctor/checks/environment.ts b/src/doctor/checks/environment.ts index cbd6b96..e21356b 100644 --- a/src/doctor/checks/environment.ts +++ b/src/doctor/checks/environment.ts @@ -1,19 +1,71 @@ -import type { EnvironmentInfo } from '../types.js'; +import { readFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import type { EnvironmentInfo, EnvironmentCheckResult, DoctorOptions } from '../types.js'; -export function checkEnvironment(): EnvironmentInfo { - const apiKey = process.env.WORKOS_API_KEY; - const clientId = process.env.WORKOS_CLIENT_ID; - const redirectUri = process.env.WORKOS_REDIRECT_URI; - const cookieDomain = process.env.WORKOS_COOKIE_DOMAIN; - const baseUrl = process.env.WORKOS_BASE_URL; +/** + * Load environment variables from project's .env files. + * Priority: .env.local > .env (matching Next.js/Vite conventions) + */ +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'); + for (const line of content.split('\n')) { + const trimmed = line.trim(); + // Skip comments and empty lines + if (!trimmed || trimmed.startsWith('#')) continue; + + const eqIndex = trimmed.indexOf('='); + if (eqIndex > 0) { + const key = trimmed.slice(0, eqIndex).trim(); + let value = trimmed.slice(eqIndex + 1).trim(); + // Remove surrounding quotes if present + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + env[key] = value; + } + } + } 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 { - apiKeyConfigured: !!apiKey, - apiKeyType: getApiKeyType(apiKey), - clientId: truncateClientId(clientId), - redirectUri: redirectUri ?? null, - cookieDomain: cookieDomain ?? null, - baseUrl: baseUrl ?? 'https://api.workos.com', + 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', + }, }; } diff --git a/src/doctor/checks/sdk.ts b/src/doctor/checks/sdk.ts index 3c4477e..3364b09 100644 --- a/src/doctor/checks/sdk.ts +++ b/src/doctor/checks/sdk.ts @@ -2,20 +2,39 @@ 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-tanstack-start', + '@workos-inc/authkit-react', + '@workos-inc/authkit-js', '@workos-inc/node', - 'workos', // legacy + '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-tanstack-start', + '@workos-inc/authkit-react', + '@workos-inc/authkit-js', ]); export async function checkSdk(options: DoctorOptions): Promise { 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 index 9d4d9fb..cb6992e 100644 --- a/src/doctor/index.ts +++ b/src/doctor/index.ts @@ -3,7 +3,12 @@ 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, validateCredentials } 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'; @@ -17,8 +22,19 @@ export async function runDoctor(options: DoctorOptions): Promise { checkConnectivity(options), ]); - // Environment check is synchronous - const environment = checkEnvironment(); + // Environment check - loads project's .env/.env.local files + const { info: environment, raw: envRaw } = checkEnvironment(options); + + // Validate credentials against API (staging only) + const credentialValidation = await validateCredentials(environment.apiKeyType, envRaw, options.skipApi); + + // Dashboard settings (only for staging, non-blocking) + const dashboardSettings = await checkDashboardSettings(options, environment.apiKeyType, envRaw); + + // Compare redirect URIs if we have dashboard data + const redirectUris = dashboardSettings + ? compareRedirectUris(environment.redirectUri, dashboardSettings.redirectUris) + : undefined; // Build partial report const partialReport = { @@ -33,6 +49,9 @@ export async function runDoctor(options: DoctorOptions): Promise { framework, environment, connectivity, + credentialValidation: credentialValidation ?? undefined, + dashboardSettings: dashboardSettings ?? undefined, + redirectUris, }; // Detect issues based on collected data @@ -55,5 +74,30 @@ export async function runDoctor(options: DoctorOptions): Promise { 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 index a74c88d..d46925a 100644 --- a/src/doctor/issues.ts +++ b/src/doctor/issues.ts @@ -35,6 +35,28 @@ export const ISSUE_DEFINITIONS = { message: 'Cannot reach WorkOS API', // remediation generated dynamically based on error }, + REDIRECT_URI_MISMATCH: { + severity: 'error' as const, + message: 'Redirect URI does not match 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[] { @@ -76,6 +98,46 @@ export function detectIssues(report: Omit): }); } + // Redirect URI mismatch + if (report.redirectUris && !report.redirectUris.match && report.redirectUris.codeUri) { + issues.push({ + code: 'REDIRECT_URI_MISMATCH', + severity: 'error', + message: 'Redirect URI mismatch', + details: { + code: report.redirectUris.codeUri, + dashboard: report.redirectUris.dashboardUris, + }, + remediation: `Add "${report.redirectUris.codeUri}" to your WorkOS dashboard redirect URIs`, + docsUrl: ISSUE_DEFINITIONS.REDIRECT_URI_MISMATCH.docsUrl, + }); + } + + // 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; } 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 index 1e25c98..2aef20e 100644 --- a/src/doctor/output.ts +++ b/src/doctor/output.ts @@ -3,7 +3,11 @@ import type { DoctorReport, Issue } from './types.js'; const DOCTOR_VERSION = '1.0.0'; -export function formatReport(report: DoctorReport): void { +export interface FormatOptions { + verbose?: boolean; +} + +export function formatReport(report: DoctorReport, options?: FormatOptions): void { console.log(''); console.log(Chalk.cyan(`WorkOS Doctor v${DOCTOR_VERSION}`)); console.log(Chalk.dim('━'.repeat(70))); @@ -45,17 +49,64 @@ export function formatReport(report: DoctorReport): void { console.log(` Code: ${report.environment.redirectUri}`); } - // Connectivity + // 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 (--no-api)')}`); + 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`); + } + + // Redirect URI comparison + if (report.redirectUris) { + console.log(''); + console.log('Redirect URIs'); + console.log(` Code: ${report.redirectUris.codeUri ?? Chalk.dim('Not set')}`); + if (report.redirectUris.dashboardUris.length > 0) { + console.log(` Dashboard: ${report.redirectUris.dashboardUris[0]}`); + for (const uri of report.redirectUris.dashboardUris.slice(1)) { + console.log(` ${uri}`); + } + } + const matchStatus = report.redirectUris.match ? Chalk.green('✓ Match found') : Chalk.red('✗ No match'); + console.log(` Status: ${matchStatus}`); + } + + // 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))); @@ -113,3 +164,16 @@ function formatIssue(issue: Issue): void { } 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 index 7146cff..1edb411 100644 --- a/src/doctor/types.ts +++ b/src/doctor/types.ts @@ -32,12 +32,24 @@ export interface RuntimeInfo { export interface EnvironmentInfo { apiKeyConfigured: boolean; apiKeyType: 'staging' | 'production' | null; - clientId: string | null; // truncated + 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; @@ -45,6 +57,26 @@ export interface ConnectivityInfo { 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; +} + +export interface CredentialValidation { + valid: boolean; + clientIdMatch: boolean; + error?: string; +} + export interface DoctorReport { version: string; timestamp: string; @@ -57,6 +89,9 @@ export interface DoctorReport { framework: FrameworkInfo; environment: EnvironmentInfo; connectivity: ConnectivityInfo; + dashboardSettings?: DashboardSettings; + redirectUris?: RedirectUriComparison; + credentialValidation?: CredentialValidation; issues: Issue[]; summary: { errors: number; @@ -69,4 +104,6 @@ export interface DoctorOptions { installDir: string; verbose?: boolean; skipApi?: boolean; + json?: boolean; + copy?: boolean; } From 34c89aa51321af43bf9542449e54f817cc571217 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 5 Feb 2026 11:38:29 -0500 Subject: [PATCH 03/12] fix(doctor): handle edge cases in env parsing, URI matching, and version comparison - Use dotenv parser for proper .env handling (multiline, exports, inline comments) - Normalize redirect URIs before comparison (trailing slashes, localhost variants) - Handle prerelease/workspace versions gracefully in outdated check - Surface dashboard API errors instead of failing silently --- src/doctor/checks/dashboard.ts | 71 +++++++++++++++++++++++++------- src/doctor/checks/environment.ts | 20 ++------- src/doctor/checks/sdk.ts | 20 +++++++-- src/doctor/index.ts | 9 ++-- src/doctor/output.ts | 4 ++ src/doctor/types.ts | 6 +++ 6 files changed, 92 insertions(+), 38 deletions(-) diff --git a/src/doctor/checks/dashboard.ts b/src/doctor/checks/dashboard.ts index 8209087..a8d5137 100644 --- a/src/doctor/checks/dashboard.ts +++ b/src/doctor/checks/dashboard.ts @@ -1,4 +1,4 @@ -import type { DashboardSettings, DoctorOptions, RedirectUriComparison, EnvironmentRaw } from '../types.js'; +import type { DashboardSettings, DashboardFetchResult, DoctorOptions, RedirectUriComparison, EnvironmentRaw } from '../types.js'; const WORKOS_API_URL = 'https://api.workos.com'; @@ -12,27 +12,27 @@ export async function checkDashboardSettings( options: DoctorOptions, apiKeyType: 'staging' | 'production' | null, raw: EnvironmentRaw, -): Promise { +): Promise { // Never call API with production keys if (apiKeyType === 'production') { - return null; + return { settings: null, error: 'Skipped (production API key)' }; } if (options.skipApi) { - return null; + return { settings: null, error: 'Skipped (--skip-api)' }; } const apiKey = raw.apiKey; if (!apiKey) { - return null; + return { settings: null, error: 'No API key configured' }; } try { const settings = await fetchDashboardSettings(apiKey, raw.baseUrl); - return settings; - } catch { - // Fail silently - dashboard data is supplementary - return null; + return { settings }; + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + return { settings: null, error: message }; } } @@ -100,6 +100,14 @@ async function fetchDashboardSettings(apiKey: string, baseUrlOverride: string | signal: controller.signal, }); + // Check for auth errors on first request + if (redirectUrisResponse.status === 401) { + throw new Error('Invalid API key (401)'); + } + if (redirectUrisResponse.status === 403) { + throw new Error('API key lacks permissions (403)'); + } + let redirectUris: string[] = []; if (redirectUrisResponse.ok) { const data = (await redirectUrisResponse.json()) as { data?: { uri: string }[] }; @@ -149,15 +157,50 @@ async function fetchDashboardSettings(apiKey: string, baseUrlOverride: string | mfa, organizationCount, }; + } 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[]): RedirectUriComparison { - return { - codeUri, - dashboardUris, - match: codeUri ? dashboardUris.includes(codeUri) : false, - }; + if (!codeUri) { + return { codeUri, dashboardUris, match: false }; + } + + const normalizedCode = normalizeUri(codeUri); + const normalizedDashboard = dashboardUris.map(normalizeUri); + const match = normalizedDashboard.includes(normalizedCode); + + return { codeUri, dashboardUris, match }; } diff --git a/src/doctor/checks/environment.ts b/src/doctor/checks/environment.ts index e21356b..4853980 100644 --- a/src/doctor/checks/environment.ts +++ b/src/doctor/checks/environment.ts @@ -1,10 +1,12 @@ 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 = {}; @@ -17,22 +19,8 @@ function loadProjectEnv(installDir: string): Record { if (existsSync(filePath)) { try { const content = readFileSync(filePath, 'utf-8'); - for (const line of content.split('\n')) { - const trimmed = line.trim(); - // Skip comments and empty lines - if (!trimmed || trimmed.startsWith('#')) continue; - - const eqIndex = trimmed.indexOf('='); - if (eqIndex > 0) { - const key = trimmed.slice(0, eqIndex).trim(); - let value = trimmed.slice(eqIndex + 1).trim(); - // Remove surrounding quotes if present - if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { - value = value.slice(1, -1); - } - env[key] = value; - } - } + const parsed = parseDotenv(content); + Object.assign(env, parsed); } catch { // Ignore read errors } diff --git a/src/doctor/checks/sdk.ts b/src/doctor/checks/sdk.ts index 3364b09..15537c7 100644 --- a/src/doctor/checks/sdk.ts +++ b/src/doctor/checks/sdk.ts @@ -96,12 +96,24 @@ async function fetchLatestVersion(packageName: string): Promise { } function isVersionOutdated(current: string, latest: string): boolean { - // Strip semver range prefixes (^, ~, etc.) - const cleanCurrent = current.replace(/^[\^~>=<]+/, ''); + // Strip semver range prefixes (^, ~, etc.) and workspace protocol + const cleanCurrent = current.replace(/^[\^~>=<]+/, '').replace(/^workspace:\*?/, ''); const cleanLatest = latest.replace(/^[\^~>=<]+/, ''); - const [currMajor, currMinor, currPatch] = cleanCurrent.split('.').map(Number); - const [latMajor, latMinor, latPatch] = cleanLatest.split('.').map(Number); + // 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; diff --git a/src/doctor/index.ts b/src/doctor/index.ts index cb6992e..1479b16 100644 --- a/src/doctor/index.ts +++ b/src/doctor/index.ts @@ -29,11 +29,11 @@ export async function runDoctor(options: DoctorOptions): Promise { const credentialValidation = await validateCredentials(environment.apiKeyType, envRaw, options.skipApi); // Dashboard settings (only for staging, non-blocking) - const dashboardSettings = await checkDashboardSettings(options, environment.apiKeyType, envRaw); + const dashboardResult = await checkDashboardSettings(options, environment.apiKeyType, envRaw); // Compare redirect URIs if we have dashboard data - const redirectUris = dashboardSettings - ? compareRedirectUris(environment.redirectUri, dashboardSettings.redirectUris) + const redirectUris = dashboardResult.settings + ? compareRedirectUris(environment.redirectUri, dashboardResult.settings.redirectUris) : undefined; // Build partial report @@ -50,7 +50,8 @@ export async function runDoctor(options: DoctorOptions): Promise { environment, connectivity, credentialValidation: credentialValidation ?? undefined, - dashboardSettings: dashboardSettings ?? undefined, + dashboardSettings: dashboardResult.settings ?? undefined, + dashboardError: dashboardResult.settings ? undefined : dashboardResult.error, redirectUris, }; diff --git a/src/doctor/output.ts b/src/doctor/output.ts index 2aef20e..39dd004 100644 --- a/src/doctor/output.ts +++ b/src/doctor/output.ts @@ -81,6 +81,10 @@ export function formatReport(report: DoctorReport, options?: FormatOptions): voi 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 comparison diff --git a/src/doctor/types.ts b/src/doctor/types.ts index 1edb411..a2ad441 100644 --- a/src/doctor/types.ts +++ b/src/doctor/types.ts @@ -77,6 +77,11 @@ export interface CredentialValidation { error?: string; } +export interface DashboardFetchResult { + settings: DashboardSettings | null; + error?: string; +} + export interface DoctorReport { version: string; timestamp: string; @@ -90,6 +95,7 @@ export interface DoctorReport { environment: EnvironmentInfo; connectivity: ConnectivityInfo; dashboardSettings?: DashboardSettings; + dashboardError?: string; redirectUris?: RedirectUriComparison; credentialValidation?: CredentialValidation; issues: Issue[]; From 215e0ec28b3191c6d4387e5520829e766b94ff19 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 5 Feb 2026 11:39:37 -0500 Subject: [PATCH 04/12] fix(doctor): downgrade redirect URI mismatch from error to warning The mismatch may be intentional or dashboard data may be incomplete. --- src/doctor/issues.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/doctor/issues.ts b/src/doctor/issues.ts index d46925a..2a1a095 100644 --- a/src/doctor/issues.ts +++ b/src/doctor/issues.ts @@ -36,8 +36,8 @@ export const ISSUE_DEFINITIONS = { // remediation generated dynamically based on error }, REDIRECT_URI_MISMATCH: { - severity: 'error' as const, - message: 'Redirect URI does not match dashboard configuration', + severity: 'warning' as const, + message: 'Redirect URI not found in dashboard configuration', docsUrl: 'https://workos.com/docs/authkit/redirect-uri', }, PROD_API_CALL_BLOCKED: { @@ -98,17 +98,17 @@ export function detectIssues(report: Omit): }); } - // Redirect URI mismatch + // Redirect URI mismatch (warning only - might be intentional or dashboard data incomplete) if (report.redirectUris && !report.redirectUris.match && report.redirectUris.codeUri) { issues.push({ code: 'REDIRECT_URI_MISMATCH', - severity: 'error', - message: 'Redirect URI mismatch', + severity: 'warning', + message: 'Redirect URI not found in dashboard', details: { code: report.redirectUris.codeUri, dashboard: report.redirectUris.dashboardUris, }, - remediation: `Add "${report.redirectUris.codeUri}" to your WorkOS dashboard redirect URIs`, + remediation: `Verify "${report.redirectUris.codeUri}" is in your WorkOS dashboard redirect URIs`, docsUrl: ISSUE_DEFINITIONS.REDIRECT_URI_MISMATCH.docsUrl, }); } From 2c766d713ebdfc1b41141b03b36c2c1ba9b76f33 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 5 Feb 2026 11:48:52 -0500 Subject: [PATCH 05/12] feat(doctor): infer expected redirect URI from framework detection - Compute expected redirect URI from framework callback path + detected port - Add source tracking ('env' vs 'inferred') for redirect URI - Fix TanStack Start detection (check @tanstack/react-start before @tanstack/react-router) - Remove version number from doctor header (it's part of the CLI, not standalone) - Use array for framework detection order to guarantee specificity --- src/doctor/checks/dashboard.ts | 10 ++++++--- src/doctor/checks/framework.ts | 39 ++++++++++++++++++++++++---------- src/doctor/index.ts | 10 ++++++++- src/doctor/output.ts | 11 +++++----- src/doctor/types.ts | 3 +++ 5 files changed, 53 insertions(+), 20 deletions(-) diff --git a/src/doctor/checks/dashboard.ts b/src/doctor/checks/dashboard.ts index a8d5137..22b16aa 100644 --- a/src/doctor/checks/dashboard.ts +++ b/src/doctor/checks/dashboard.ts @@ -193,14 +193,18 @@ function normalizeUri(uri: string): string { } } -export function compareRedirectUris(codeUri: string | null, dashboardUris: string[]): RedirectUriComparison { +export function compareRedirectUris( + codeUri: string | null, + dashboardUris: string[], + source?: 'env' | 'inferred', +): RedirectUriComparison { if (!codeUri) { - return { codeUri, dashboardUris, match: false }; + return { codeUri, dashboardUris, match: false, source }; } const normalizedCode = normalizeUri(codeUri); const normalizedDashboard = dashboardUris.map(normalizeUri); const match = normalizedDashboard.includes(normalizedCode); - return { codeUri, dashboardUris, match }; + return { codeUri, dashboardUris, match, source }; } diff --git a/src/doctor/checks/framework.ts b/src/doctor/checks/framework.ts index 606dc2b..ba8046b 100644 --- a/src/doctor/checks/framework.ts +++ b/src/doctor/checks/framework.ts @@ -2,21 +2,27 @@ 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 { 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; } -const FRAMEWORKS: Record = { - next: { name: 'Next.js', detectVariant: detectNextVariant }, - express: { name: 'Express', detectVariant: null }, - '@remix-run/node': { name: 'Remix', detectVariant: null }, - '@tanstack/react-router': { name: 'TanStack Router', detectVariant: null }, - '@tanstack/start': { name: 'TanStack Start', detectVariant: null }, - 'react-router-dom': { name: 'React Router', detectVariant: null }, -}; +// Order matters - more specific frameworks should come first (array guarantees order) +const FRAMEWORKS: FrameworkConfig[] = [ + { package: 'next', name: 'Next.js', integration: Integration.nextjs, detectVariant: detectNextVariant }, + { package: '@tanstack/react-start', name: 'TanStack Start', integration: Integration.tanstackStart, detectVariant: null }, + { package: '@tanstack/start', name: 'TanStack Start', integration: Integration.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: Integration.reactRouter, detectVariant: null }, + { package: 'express', name: 'Express', integration: null, detectVariant: null }, +]; export async function checkFramework(options: DoctorOptions): Promise { let packageJson; @@ -26,15 +32,26 @@ export async function checkFramework(options: DoctorOptions): Promise { // Dashboard settings (only for staging, non-blocking) 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(environment.redirectUri, dashboardResult.settings.redirectUris) + ? compareRedirectUris(expectedRedirectUri, dashboardResult.settings.redirectUris, redirectUriSource) : undefined; // Build partial report diff --git a/src/doctor/output.ts b/src/doctor/output.ts index 39dd004..63e03fe 100644 --- a/src/doctor/output.ts +++ b/src/doctor/output.ts @@ -1,15 +1,13 @@ import Chalk from 'chalk'; import type { DoctorReport, Issue } from './types.js'; -const DOCTOR_VERSION = '1.0.0'; - export interface FormatOptions { verbose?: boolean; } export function formatReport(report: DoctorReport, options?: FormatOptions): void { console.log(''); - console.log(Chalk.cyan(`WorkOS Doctor v${DOCTOR_VERSION}`)); + console.log(Chalk.cyan('WorkOS Doctor')); console.log(Chalk.dim('━'.repeat(70))); // SDK & Project @@ -91,14 +89,17 @@ export function formatReport(report: DoctorReport, options?: FormatOptions): voi if (report.redirectUris) { console.log(''); console.log('Redirect URIs'); - console.log(` Code: ${report.redirectUris.codeUri ?? Chalk.dim('Not set')}`); + const sourceLabel = report.redirectUris.source === 'inferred' ? Chalk.dim(' (inferred from framework)') : ''; + console.log(` Expected: ${report.redirectUris.codeUri ?? Chalk.dim('Unknown')}${sourceLabel}`); if (report.redirectUris.dashboardUris.length > 0) { console.log(` Dashboard: ${report.redirectUris.dashboardUris[0]}`); for (const uri of report.redirectUris.dashboardUris.slice(1)) { console.log(` ${uri}`); } + } else { + console.log(` Dashboard: ${Chalk.dim('None configured')}`); } - const matchStatus = report.redirectUris.match ? Chalk.green('✓ Match found') : Chalk.red('✗ No match'); + const matchStatus = report.redirectUris.match ? Chalk.green('✓ Match found') : Chalk.yellow('? No match'); console.log(` Status: ${matchStatus}`); } diff --git a/src/doctor/types.ts b/src/doctor/types.ts index a2ad441..c788f96 100644 --- a/src/doctor/types.ts +++ b/src/doctor/types.ts @@ -21,6 +21,8 @@ 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 { @@ -69,6 +71,7 @@ export interface RedirectUriComparison { codeUri: string | null; dashboardUris: string[]; match: boolean; + source?: 'env' | 'inferred'; // Where the codeUri came from } export interface CredentialValidation { From da5093f08d7b1e591ccf05a544733109ddcd17bd Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 5 Feb 2026 11:51:27 -0500 Subject: [PATCH 06/12] fix(doctor): remove redirect URI verification (no public list API) WorkOS API doesn't expose an endpoint to list configured redirect URIs. The management API only supports POST (create), not GET (list). - Remove false mismatch warnings - Show expected redirect URI with note to verify manually in dashboard --- src/doctor/checks/dashboard.ts | 21 ++++++++++----------- src/doctor/issues.ts | 16 ++-------------- src/doctor/output.ts | 21 ++++++--------------- 3 files changed, 18 insertions(+), 40 deletions(-) diff --git a/src/doctor/checks/dashboard.ts b/src/doctor/checks/dashboard.ts index 22b16aa..b9875f9 100644 --- a/src/doctor/checks/dashboard.ts +++ b/src/doctor/checks/dashboard.ts @@ -94,26 +94,25 @@ async function fetchDashboardSettings(apiKey: string, baseUrlOverride: string | const timeoutId = setTimeout(() => controller.abort(), 10000); try { - // Fetch redirect URIs - const redirectUrisResponse = await fetch(`${baseUrl}/redirect_uris`, { + // Note: WorkOS API doesn't expose a public endpoint to list redirect URIs + // The management API only supports creating them (POST), not listing (GET) + // We skip redirect URI fetching - the installer creates them but can't verify them + const redirectUris: string[] = []; + + // Validate API key by making a lightweight call + const orgsCheckResponse = await fetch(`${baseUrl}/organizations?limit=1`, { headers: { Authorization: `Bearer ${apiKey}` }, signal: controller.signal, }); - // Check for auth errors on first request - if (redirectUrisResponse.status === 401) { + // Check for auth errors + if (orgsCheckResponse.status === 401) { throw new Error('Invalid API key (401)'); } - if (redirectUrisResponse.status === 403) { + if (orgsCheckResponse.status === 403) { throw new Error('API key lacks permissions (403)'); } - let redirectUris: string[] = []; - if (redirectUrisResponse.ok) { - const data = (await redirectUrisResponse.json()) as { data?: { uri: string }[] }; - redirectUris = data.data?.map((r) => r.uri) ?? []; - } - // Fetch environment settings const envResponse = await fetch(`${baseUrl}/environments/current`, { headers: { Authorization: `Bearer ${apiKey}` }, diff --git a/src/doctor/issues.ts b/src/doctor/issues.ts index 2a1a095..969f6e5 100644 --- a/src/doctor/issues.ts +++ b/src/doctor/issues.ts @@ -98,20 +98,8 @@ export function detectIssues(report: Omit): }); } - // Redirect URI mismatch (warning only - might be intentional or dashboard data incomplete) - if (report.redirectUris && !report.redirectUris.match && report.redirectUris.codeUri) { - issues.push({ - code: 'REDIRECT_URI_MISMATCH', - severity: 'warning', - message: 'Redirect URI not found in dashboard', - details: { - code: report.redirectUris.codeUri, - dashboard: report.redirectUris.dashboardUris, - }, - remediation: `Verify "${report.redirectUris.codeUri}" is in your WorkOS dashboard redirect URIs`, - docsUrl: ISSUE_DEFINITIONS.REDIRECT_URI_MISMATCH.docsUrl, - }); - } + // 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) { diff --git a/src/doctor/output.ts b/src/doctor/output.ts index 63e03fe..7207b3b 100644 --- a/src/doctor/output.ts +++ b/src/doctor/output.ts @@ -85,22 +85,13 @@ export function formatReport(report: DoctorReport, options?: FormatOptions): voi console.log(` Status: ${Chalk.dim(report.dashboardError)}`); } - // Redirect URI comparison - if (report.redirectUris) { + // Expected Redirect URI (can't verify against dashboard - no list API) + if (report.redirectUris?.codeUri) { console.log(''); - console.log('Redirect URIs'); - const sourceLabel = report.redirectUris.source === 'inferred' ? Chalk.dim(' (inferred from framework)') : ''; - console.log(` Expected: ${report.redirectUris.codeUri ?? Chalk.dim('Unknown')}${sourceLabel}`); - if (report.redirectUris.dashboardUris.length > 0) { - console.log(` Dashboard: ${report.redirectUris.dashboardUris[0]}`); - for (const uri of report.redirectUris.dashboardUris.slice(1)) { - console.log(` ${uri}`); - } - } else { - console.log(` Dashboard: ${Chalk.dim('None configured')}`); - } - const matchStatus = report.redirectUris.match ? Chalk.green('✓ Match found') : Chalk.yellow('? No match'); - console.log(` Status: ${matchStatus}`); + console.log('Redirect URI'); + const sourceLabel = report.redirectUris.source === 'inferred' ? Chalk.dim(' (inferred)') : ''; + console.log(` Expected: ${report.redirectUris.codeUri}${sourceLabel}`); + console.log(` ${Chalk.dim('Verify this is configured in your WorkOS dashboard')}`); } // Verbose mode additions From d056a7ff9f15a0bfc3ae6792666d3a04dd2a10f0 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 5 Feb 2026 11:55:05 -0500 Subject: [PATCH 07/12] fix(doctor): remove duplicate redirect URI section --- src/doctor/output.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/doctor/output.ts b/src/doctor/output.ts index 7207b3b..8069d48 100644 --- a/src/doctor/output.ts +++ b/src/doctor/output.ts @@ -40,13 +40,6 @@ export function formatReport(report: DoctorReport, options?: FormatOptions): voi ); console.log(` Base URL: ${report.environment.baseUrl} ${Chalk.green('✓')}`); - // Redirect URI - if (report.environment.redirectUri) { - console.log(''); - console.log('Redirect URI'); - console.log(` Code: ${report.environment.redirectUri}`); - } - // Connectivity & Credential Validation console.log(''); console.log('Connectivity'); From 66f5a5a466c63bb23fcc5a636fa17213e6e67a81 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 5 Feb 2026 11:56:13 -0500 Subject: [PATCH 08/12] fix(doctor): simplify redirect URI display wording --- src/doctor/output.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/doctor/output.ts b/src/doctor/output.ts index 8069d48..73a91ba 100644 --- a/src/doctor/output.ts +++ b/src/doctor/output.ts @@ -78,13 +78,12 @@ export function formatReport(report: DoctorReport, options?: FormatOptions): voi console.log(` Status: ${Chalk.dim(report.dashboardError)}`); } - // Expected Redirect URI (can't verify against dashboard - no list API) + // Redirect URI (can't verify against dashboard - no list API) if (report.redirectUris?.codeUri) { console.log(''); - console.log('Redirect URI'); - const sourceLabel = report.redirectUris.source === 'inferred' ? Chalk.dim(' (inferred)') : ''; - console.log(` Expected: ${report.redirectUris.codeUri}${sourceLabel}`); - console.log(` ${Chalk.dim('Verify this is configured in your WorkOS dashboard')}`); + const source = report.redirectUris.source === 'inferred' ? 'Inferred' : 'Configured'; + console.log(`Redirect URI (${source})`); + console.log(` ${report.redirectUris.codeUri}`); } // Verbose mode additions From 70fa55f8c9a0d349905f079f7983f35b100a4b7c Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Tue, 10 Feb 2026 09:58:03 -0600 Subject: [PATCH 09/12] fix(doctor): deduplicate types, reduce API calls, fix env consistency - Remove duplicate CredentialValidation interface from dashboard.ts (import from types.ts instead) - Merge 3 redundant /organizations?limit=1 calls into 1 by folding credential validation into fetchDashboardSettings - Pass resolved project env base URL to connectivity check instead of reading process.env directly - Fix cross-platform copy hint (--copy flag instead of pbcopy) --- src/doctor/checks/connectivity.ts | 4 +- src/doctor/checks/dashboard.ts | 127 +++++++++--------------------- src/doctor/index.ts | 20 +++-- src/doctor/output.ts | 2 +- src/doctor/types.ts | 1 + 5 files changed, 48 insertions(+), 106 deletions(-) diff --git a/src/doctor/checks/connectivity.ts b/src/doctor/checks/connectivity.ts index d9fa45a..76b32d8 100644 --- a/src/doctor/checks/connectivity.ts +++ b/src/doctor/checks/connectivity.ts @@ -1,6 +1,6 @@ import type { DoctorOptions, ConnectivityInfo } from '../types.js'; -export async function checkConnectivity(options: DoctorOptions): Promise { +export async function checkConnectivity(options: DoctorOptions, baseUrl: string): Promise { if (options.skipApi) { return { apiReachable: false, @@ -9,8 +9,6 @@ export async function checkConnectivity(options: DoctorOptions): Promise { - // Skip for production keys or if API calls disabled - if (apiKeyType === 'production' || skipApi || !raw.apiKey) { - return null; - } - - const baseUrl = raw.baseUrl ?? WORKOS_API_URL; - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 5000); - - try { - // Use /organizations endpoint to validate API key (lightweight call) - const response = await fetch(`${baseUrl}/organizations?limit=1`, { - headers: { Authorization: `Bearer ${raw.apiKey}` }, - signal: controller.signal, - }); - - if (!response.ok) { - if (response.status === 401) { - return { valid: false, clientIdMatch: true, error: 'Invalid API key' }; - } - if (response.status === 403) { - return { valid: false, clientIdMatch: true, error: 'API key lacks permissions' }; - } - return { valid: false, clientIdMatch: true, error: `API error: ${response.status}` }; - } - - // API key is valid - we can't easily verify client ID match without a dedicated endpoint - // but at least we know the key works - return { - valid: true, - clientIdMatch: true, // Assume match since we can't verify - }; - } catch (err) { - if (err instanceof Error && err.name === 'AbortError') { - return { valid: false, clientIdMatch: true, error: 'Validation timeout' }; - } - return null; // Network error, skip validation - } finally { - clearTimeout(timeoutId); - } -} - -async function fetchDashboardSettings(apiKey: string, baseUrlOverride: string | null): Promise { +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 { - // Note: WorkOS API doesn't expose a public endpoint to list redirect URIs - // The management API only supports creating them (POST), not listing (GET) - // We skip redirect URI fetching - the installer creates them but can't verify them const redirectUris: string[] = []; - // Validate API key by making a lightweight call - const orgsCheckResponse = await fetch(`${baseUrl}/organizations?limit=1`, { + // 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, }); - // Check for auth errors - if (orgsCheckResponse.status === 401) { - throw new Error('Invalid API key (401)'); + if (orgsResponse.status === 401) { + return { + settings: null, + credentialValidation: { valid: false, clientIdMatch: true, error: 'Invalid API key' }, + error: 'Invalid API key (401)', + }; } - if (orgsCheckResponse.status === 403) { - throw new Error('API key lacks permissions (403)'); + 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}` }, @@ -134,27 +97,9 @@ async function fetchDashboardSettings(apiKey: string, baseUrlOverride: string | mfa = envData.mfa_policy ?? null; } - // Fetch organization count - const orgsResponse = await fetch(`${baseUrl}/organizations?limit=1`, { - headers: { Authorization: `Bearer ${apiKey}` }, - signal: controller.signal, - }); - - let organizationCount = 0; - if (orgsResponse.ok) { - const orgsData = (await orgsResponse.json()) as { - list_metadata?: { total_count?: number }; - data?: unknown[]; - }; - organizationCount = orgsData.list_metadata?.total_count ?? orgsData.data?.length ?? 0; - } - return { - redirectUris, - authMethods, - sessionTimeout, - mfa, - organizationCount, + settings: { redirectUris, authMethods, sessionTimeout, mfa, organizationCount }, + credentialValidation, }; } catch (err) { if (err instanceof Error && err.name === 'AbortError') { diff --git a/src/doctor/index.ts b/src/doctor/index.ts index 0cc47bb..3a12d02 100644 --- a/src/doctor/index.ts +++ b/src/doctor/index.ts @@ -3,7 +3,7 @@ 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, validateCredentials } from './checks/dashboard.js'; +import { checkDashboardSettings, compareRedirectUris } from './checks/dashboard.js'; import { detectIssues } from './issues.js'; import { formatReport } from './output.js'; import { formatReportAsJson } from './json-output.js'; @@ -14,21 +14,19 @@ import type { DoctorOptions, DoctorReport } from './types.js'; const DOCTOR_VERSION = '1.0.0'; export async function runDoctor(options: DoctorOptions): Promise { - // Run all checks concurrently where possible + // 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), + checkConnectivity(options, environment.baseUrl ?? 'https://api.workos.com'), ]); - // Environment check - loads project's .env/.env.local files - const { info: environment, raw: envRaw } = checkEnvironment(options); - - // Validate credentials against API (staging only) - const credentialValidation = await validateCredentials(environment.apiKeyType, envRaw, options.skipApi); - - // Dashboard settings (only for staging, non-blocking) + // 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 @@ -57,7 +55,7 @@ export async function runDoctor(options: DoctorOptions): Promise { framework, environment, connectivity, - credentialValidation: credentialValidation ?? undefined, + credentialValidation: dashboardResult.credentialValidation, dashboardSettings: dashboardResult.settings ?? undefined, dashboardError: dashboardResult.settings ? undefined : dashboardResult.error, redirectUris, diff --git a/src/doctor/output.ts b/src/doctor/output.ts index 73a91ba..cabeb68 100644 --- a/src/doctor/output.ts +++ b/src/doctor/output.ts @@ -134,7 +134,7 @@ export function formatReport(report: DoctorReport, options?: FormatOptions): voi } console.log(''); - console.log(Chalk.dim('Copy this report: workos doctor --json | pbcopy')); + console.log(Chalk.dim('Copy this report: workos doctor --copy')); console.log(Chalk.dim('Troubleshooting: https://workos.com/docs/troubleshooting')); console.log(''); } diff --git a/src/doctor/types.ts b/src/doctor/types.ts index c788f96..25b3608 100644 --- a/src/doctor/types.ts +++ b/src/doctor/types.ts @@ -82,6 +82,7 @@ export interface CredentialValidation { export interface DashboardFetchResult { settings: DashboardSettings | null; + credentialValidation?: CredentialValidation; error?: string; } From fe30f1288e137fe7636ee7d71beae9653253f9a5 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Tue, 10 Feb 2026 22:17:45 -0600 Subject: [PATCH 10/12] chore: formatting --- src/doctor/checks/dashboard.ts | 9 ++++++++- src/doctor/checks/framework.ts | 7 ++++++- tests/evals/graders/elixir.grader.ts | 6 +----- tests/evals/graders/go.grader.ts | 6 +----- tests/evals/graders/kotlin.grader.ts | 6 +----- tests/evals/graders/node.grader.ts | 6 +----- tests/evals/graders/php-laravel.grader.ts | 4 +++- tests/evals/graders/php.grader.ts | 6 +----- tests/evals/graders/python.grader.ts | 6 +----- tests/evals/graders/ruby.grader.ts | 6 +----- tests/fixtures/node/example-auth0/server.js | 2 +- 11 files changed, 25 insertions(+), 39 deletions(-) diff --git a/src/doctor/checks/dashboard.ts b/src/doctor/checks/dashboard.ts index 42c3091..3940d77 100644 --- a/src/doctor/checks/dashboard.ts +++ b/src/doctor/checks/dashboard.ts @@ -1,4 +1,11 @@ -import type { CredentialValidation, DashboardSettings, DashboardFetchResult, DoctorOptions, RedirectUriComparison, EnvironmentRaw } from '../types.js'; +import type { + CredentialValidation, + DashboardSettings, + DashboardFetchResult, + DoctorOptions, + RedirectUriComparison, + EnvironmentRaw, +} from '../types.js'; const WORKOS_API_URL = 'https://api.workos.com'; diff --git a/src/doctor/checks/framework.ts b/src/doctor/checks/framework.ts index ba8046b..6271b51 100644 --- a/src/doctor/checks/framework.ts +++ b/src/doctor/checks/framework.ts @@ -16,7 +16,12 @@ interface FrameworkConfig { // Order matters - more specific frameworks should come first (array guarantees order) const FRAMEWORKS: FrameworkConfig[] = [ { package: 'next', name: 'Next.js', integration: Integration.nextjs, detectVariant: detectNextVariant }, - { package: '@tanstack/react-start', name: 'TanStack Start', integration: Integration.tanstackStart, detectVariant: null }, + { + package: '@tanstack/react-start', + name: 'TanStack Start', + integration: Integration.tanstackStart, + detectVariant: null, + }, { package: '@tanstack/start', name: 'TanStack Start', integration: Integration.tanstackStart, detectVariant: null }, { package: '@tanstack/react-router', name: 'TanStack Router', integration: null, detectVariant: null }, { package: '@remix-run/node', name: 'Remix', integration: null, detectVariant: null }, 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) => { From cde0447d3d464a3c214a16658bb5fac4e2c66fbf Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Tue, 10 Feb 2026 22:20:42 -0600 Subject: [PATCH 11/12] fix(doctor): use KNOWN_INTEGRATIONS instead of Integration type as value --- src/doctor/checks/framework.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/doctor/checks/framework.ts b/src/doctor/checks/framework.ts index 6271b51..e192f83 100644 --- a/src/doctor/checks/framework.ts +++ b/src/doctor/checks/framework.ts @@ -3,7 +3,8 @@ 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 { Integration } from '../../lib/constants.js'; +import { KNOWN_INTEGRATIONS } from '../../lib/constants.js'; +import type { Integration } from '../../lib/constants.js'; import type { DoctorOptions, FrameworkInfo } from '../types.js'; interface FrameworkConfig { @@ -15,17 +16,17 @@ interface FrameworkConfig { // Order matters - more specific frameworks should come first (array guarantees order) const FRAMEWORKS: FrameworkConfig[] = [ - { package: 'next', name: 'Next.js', integration: Integration.nextjs, detectVariant: detectNextVariant }, + { package: 'next', name: 'Next.js', integration: KNOWN_INTEGRATIONS.nextjs, detectVariant: detectNextVariant }, { package: '@tanstack/react-start', name: 'TanStack Start', - integration: Integration.tanstackStart, + integration: KNOWN_INTEGRATIONS.tanstackStart, detectVariant: null, }, - { package: '@tanstack/start', name: 'TanStack Start', integration: Integration.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: Integration.reactRouter, detectVariant: null }, + { package: 'react-router-dom', name: 'React Router', integration: KNOWN_INTEGRATIONS.reactRouter, detectVariant: null }, { package: 'express', name: 'Express', integration: null, detectVariant: null }, ]; From 72fd9028933c70c505f4595f043ff3175abcb6a4 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Tue, 10 Feb 2026 22:21:03 -0600 Subject: [PATCH 12/12] chore: formatting --- src/doctor/checks/framework.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/doctor/checks/framework.ts b/src/doctor/checks/framework.ts index e192f83..d6f59b9 100644 --- a/src/doctor/checks/framework.ts +++ b/src/doctor/checks/framework.ts @@ -23,10 +23,20 @@ const FRAMEWORKS: FrameworkConfig[] = [ integration: KNOWN_INTEGRATIONS.tanstackStart, detectVariant: null, }, - { package: '@tanstack/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: 'react-router-dom', + name: 'React Router', + integration: KNOWN_INTEGRATIONS.reactRouter, + detectVariant: null, + }, { package: 'express', name: 'Express', integration: null, detectVariant: null }, ];