Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,42 @@ yargs(hideBin(process.argv))
});
}),
)
.command(
'doctor',
'Diagnose WorkOS integration issues',
(yargs) =>
yargs.options({
verbose: {
type: 'boolean',
default: false,
description: 'Include additional diagnostic information',
},
'skip-api': {
type: 'boolean',
default: false,
description: 'Skip API calls (offline mode)',
},
'install-dir': {
type: 'string',
default: process.cwd(),
description: 'Project directory to analyze',
},
json: {
type: 'boolean',
default: false,
description: 'Output report as JSON',
},
copy: {
type: 'boolean',
default: false,
description: 'Copy report to clipboard',
},
}),
async (argv) => {
const { handleDoctor } = await import('./commands/doctor.js');
await handleDoctor(argv);
},
)
.command(
'install',
'Install WorkOS AuthKit into your project',
Expand Down
39 changes: 39 additions & 0 deletions src/commands/doctor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { ArgumentsCamelCase } from 'yargs';
import { runDoctor, outputReport } from '../doctor/index.js';
import clack from '../utils/clack.js';

interface DoctorArgs {
verbose?: boolean;
skipApi?: boolean;
installDir?: string;
json?: boolean;
copy?: boolean;
}

export async function handleDoctor(argv: ArgumentsCamelCase<DoctorArgs>): Promise<void> {
const options = {
installDir: argv.installDir ?? process.cwd(),
verbose: argv.verbose ?? false,
skipApi: argv.skipApi ?? false,
json: argv.json ?? false,
copy: argv.copy ?? false,
};

try {
const report = await runDoctor(options);
await outputReport(report, options);

// Exit with error code if critical issues found
if (report.summary.errors > 0) {
process.exit(1);
}
process.exit(0);
} catch (error) {
if (!options.json) {
clack.log.error(`Doctor failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
} else {
console.error(JSON.stringify({ error: error instanceof Error ? error.message : 'Unknown error' }));
}
process.exit(1);
}
}
39 changes: 39 additions & 0 deletions src/doctor/checks/connectivity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { DoctorOptions, ConnectivityInfo } from '../types.js';

export async function checkConnectivity(options: DoctorOptions, baseUrl: string): Promise<ConnectivityInfo> {
if (options.skipApi) {
return {
apiReachable: false,
latencyMs: null,
tlsValid: false,
error: 'Skipped (--skip-api)',
};
}
const startTime = Date.now();

try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);

const response = await fetch(`${baseUrl}/health`, {
signal: controller.signal,
});

clearTimeout(timeoutId);
const latencyMs = Date.now() - startTime;

return {
apiReachable: response.ok,
latencyMs,
tlsValid: true, // If fetch succeeded over HTTPS, TLS is valid
error: response.ok ? undefined : `HTTP ${response.status}`,
};
} catch (error) {
return {
apiReachable: false,
latencyMs: null,
tlsValid: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
161 changes: 161 additions & 0 deletions src/doctor/checks/dashboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import type {
CredentialValidation,
DashboardSettings,
DashboardFetchResult,
DoctorOptions,
RedirectUriComparison,
EnvironmentRaw,
} from '../types.js';

const WORKOS_API_URL = 'https://api.workos.com';

export async function checkDashboardSettings(
options: DoctorOptions,
apiKeyType: 'staging' | 'production' | null,
raw: EnvironmentRaw,
): Promise<DashboardFetchResult> {
// Never call API with production keys
if (apiKeyType === 'production') {
return { settings: null, error: 'Skipped (production API key)' };
}

if (options.skipApi) {
return { settings: null, error: 'Skipped (--skip-api)' };
}

const apiKey = raw.apiKey;
if (!apiKey) {
return { settings: null, error: 'No API key configured' };
}

try {
return await fetchDashboardSettings(apiKey, raw.baseUrl);
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return { settings: null, error: message };
}
}

async function fetchDashboardSettings(apiKey: string, baseUrlOverride: string | null): Promise<DashboardFetchResult> {
const baseUrl = baseUrlOverride ?? WORKOS_API_URL;

const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);

try {
const redirectUris: string[] = [];

// Single /organizations?limit=1 call — validates credentials AND gets org count
const orgsResponse = await fetch(`${baseUrl}/organizations?limit=1`, {
headers: { Authorization: `Bearer ${apiKey}` },
signal: controller.signal,
});

if (orgsResponse.status === 401) {
return {
settings: null,
credentialValidation: { valid: false, clientIdMatch: true, error: 'Invalid API key' },
error: 'Invalid API key (401)',
};
}
if (orgsResponse.status === 403) {
return {
settings: null,
credentialValidation: { valid: false, clientIdMatch: true, error: 'API key lacks permissions' },
error: 'API key lacks permissions (403)',
};
}
if (!orgsResponse.ok) {
return {
settings: null,
credentialValidation: { valid: false, clientIdMatch: true, error: `API error: ${orgsResponse.status}` },
error: `API error: ${orgsResponse.status}`,
};
}

// Credentials valid — extract org count from the same response
const credentialValidation: CredentialValidation = { valid: true, clientIdMatch: true };

let organizationCount = 0;
const orgsData = (await orgsResponse.json()) as {
list_metadata?: { total_count?: number };
data?: unknown[];
};
organizationCount = orgsData.list_metadata?.total_count ?? orgsData.data?.length ?? 0;

// Fetch environment settings
const envResponse = await fetch(`${baseUrl}/environments/current`, {
headers: { Authorization: `Bearer ${apiKey}` },
signal: controller.signal,
});

let authMethods: string[] = [];
let sessionTimeout: string | null = null;
let mfa: 'optional' | 'required' | 'disabled' | null = null;

if (envResponse.ok) {
const envData = (await envResponse.json()) as {
auth_methods?: string[];
session_timeout?: string;
mfa_policy?: 'optional' | 'required' | 'disabled';
};
authMethods = envData.auth_methods ?? [];
sessionTimeout = envData.session_timeout ?? null;
mfa = envData.mfa_policy ?? null;
}

return {
settings: { redirectUris, authMethods, sessionTimeout, mfa, organizationCount },
credentialValidation,
};
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
throw new Error('Request timeout (10s)');
}
throw err;
} finally {
clearTimeout(timeoutId);
}
}

/**
* Normalize a URI for comparison:
* - Remove trailing slashes
* - Normalize localhost variants (127.0.0.1 → localhost)
* - Lowercase the host portion
*/
function normalizeUri(uri: string): string {
try {
const url = new URL(uri);
// Normalize localhost variants
if (url.hostname === '127.0.0.1' || url.hostname === '[::1]') {
url.hostname = 'localhost';
}
// Lowercase hostname (but preserve path case for compatibility)
url.hostname = url.hostname.toLowerCase();
// Remove trailing slash from pathname (unless it's just "/")
if (url.pathname.length > 1 && url.pathname.endsWith('/')) {
url.pathname = url.pathname.slice(0, -1);
}
return url.toString();
} catch {
// If URL parsing fails, return as-is for exact match fallback
return uri;
}
}

export function compareRedirectUris(
codeUri: string | null,
dashboardUris: string[],
source?: 'env' | 'inferred',
): RedirectUriComparison {
if (!codeUri) {
return { codeUri, dashboardUris, match: false, source };
}

const normalizedCode = normalizeUri(codeUri);
const normalizedDashboard = dashboardUris.map(normalizeUri);
const match = normalizedDashboard.includes(normalizedCode);

return { codeUri, dashboardUris, match, source };
}
71 changes: 71 additions & 0 deletions src/doctor/checks/environment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { parse as parseDotenv } from 'dotenv';
import type { EnvironmentInfo, EnvironmentCheckResult, DoctorOptions } from '../types.js';

/**
* Load environment variables from project's .env files.
* Priority: .env.local > .env (matching Next.js/Vite conventions)
* Uses dotenv parser for proper handling of quotes, multiline, exports, etc.
*/
function loadProjectEnv(installDir: string): Record<string, string> {
const env: Record<string, string> = {};

// Load in order: .env first, then .env.local (later overrides earlier)
const envFiles = ['.env', '.env.local'];

for (const file of envFiles) {
const filePath = join(installDir, file);
if (existsSync(filePath)) {
try {
const content = readFileSync(filePath, 'utf-8');
const parsed = parseDotenv(content);
Object.assign(env, parsed);
} catch {
// Ignore read errors
}
}
}

return env;
}

export function checkEnvironment(options?: DoctorOptions): EnvironmentCheckResult {
// Load project env files, then fall back to process.env
const projectEnv = options?.installDir ? loadProjectEnv(options.installDir) : {};

const apiKey = projectEnv.WORKOS_API_KEY ?? process.env.WORKOS_API_KEY ?? null;
const clientId = projectEnv.WORKOS_CLIENT_ID ?? process.env.WORKOS_CLIENT_ID ?? null;
const redirectUri = projectEnv.WORKOS_REDIRECT_URI ?? process.env.WORKOS_REDIRECT_URI ?? null;
const cookieDomain = projectEnv.WORKOS_COOKIE_DOMAIN ?? process.env.WORKOS_COOKIE_DOMAIN ?? null;
const baseUrl = projectEnv.WORKOS_BASE_URL ?? process.env.WORKOS_BASE_URL ?? null;

return {
info: {
apiKeyConfigured: !!apiKey,
apiKeyType: getApiKeyType(apiKey),
clientId: truncateClientId(clientId),
redirectUri: redirectUri,
cookieDomain: cookieDomain,
baseUrl: baseUrl ?? 'https://api.workos.com',
},
raw: {
apiKey,
clientId,
baseUrl: baseUrl ?? 'https://api.workos.com',
},
};
}

function getApiKeyType(apiKey: string | undefined): 'staging' | 'production' | null {
if (!apiKey) return null;
if (apiKey.startsWith('sk_test_')) return 'staging';
if (apiKey.startsWith('sk_live_')) return 'production';
return null; // Unknown format
}

function truncateClientId(clientId: string | undefined): string | null {
if (!clientId) return null;
if (clientId.length <= 15) return clientId;
return `${clientId.slice(0, 10)}...${clientId.slice(-3)}`;
}
Loading