From da41695f031b1ecf83e541f24b3ceef6e2de1b55 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 10 Mar 2026 00:07:53 -0300 Subject: [PATCH 1/9] feat(color): add red color utility export --- src/lib/color.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/color.ts b/src/lib/color.ts index f31a90258..05fd9f807 100644 --- a/src/lib/color.ts +++ b/src/lib/color.ts @@ -3,5 +3,6 @@ export const bold = (s: string) => `\x1b[1m${s}\x1b[0m`; export const cyan = (s: string) => `\x1b[36m${s}\x1b[0m`; export const green = (s: string) => `\x1b[32m${s}\x1b[0m`; export const yellow = (s: string) => `\x1b[33m${s}\x1b[0m`; +export const red = (s: string) => `\x1b[31m${s}\x1b[0m`; export const blue = (s: string) => `\x1b[34m${s}\x1b[0m`; export const red = (s: string) => `\x1b[31m${s}\x1b[0m`; From cc4113c678f96291849d9f435e470951df470280 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 10 Mar 2026 00:08:02 -0300 Subject: [PATCH 2/9] feat(doctor): add doctor command for project health checks Runs 8 diagnostic checks covering authentication, project linkage, instances, git availability, environment variables, and CLI config. Supports --verbose, --json, --spotlight, and --fix options. --- src/commands/doctor/README.md | 94 ++++++ src/commands/doctor/checks.ts | 407 ++++++++++++++++++++++ src/commands/doctor/context.test.ts | 166 +++++++++ src/commands/doctor/context.ts | 65 ++++ src/commands/doctor/doctor.test.ts | 502 ++++++++++++++++++++++++++++ src/commands/doctor/format.ts | 35 ++ src/commands/doctor/index.ts | 124 +++++++ src/commands/doctor/types.ts | 40 +++ 8 files changed, 1433 insertions(+) create mode 100644 src/commands/doctor/README.md create mode 100644 src/commands/doctor/checks.ts create mode 100644 src/commands/doctor/context.test.ts create mode 100644 src/commands/doctor/context.ts create mode 100644 src/commands/doctor/doctor.test.ts create mode 100644 src/commands/doctor/format.ts create mode 100644 src/commands/doctor/index.ts create mode 100644 src/commands/doctor/types.ts diff --git a/src/commands/doctor/README.md b/src/commands/doctor/README.md new file mode 100644 index 000000000..aad671f0a --- /dev/null +++ b/src/commands/doctor/README.md @@ -0,0 +1,94 @@ +# Doctor Command + +Runs a series of diagnostic checks on your Clerk CLI setup and reports +the status of each check. The command is read-only and never modifies +any state (unless `--fix` is used). + +## Usage + +```sh +clerk doctor # Run all checks +clerk doctor --verbose # Show detailed output +clerk doctor --json # Output results as JSON +clerk doctor --spotlight # Only show warnings and failures +clerk doctor --fix # Offer to auto-fix issues +``` + +## Options + +| Flag | Description | +| ------------- | ----------------------------------------------------- | +| `--verbose` | Show detailed diagnostic info for each check | +| `--json` | Output results as machine-readable JSON | +| `--spotlight` | Only show warnings and failures (hide passing checks) | +| `--fix` | Offer to auto-fix issues with known remedies | + +## Checks + +| Check | Category | What it verifies | +| --------------------- | -------------- | ------------------------------------------------------------------ | +| Authentication token | Authentication | Credential store has a stored token | +| Token validity | Authentication | Token is still valid (calls `/oauth/userinfo`) | +| Project linkage | Project | Current directory is linked to a Clerk app | +| Linked app exists | Project | Linked application ID is accessible via the API | +| Instances | Project | Configured dev/prod instance IDs match the application's instances | +| Git | Environment | Git is installed and available | +| Environment variables | Environment | .env.local or .env has Clerk keys | +| CLI configuration | Configuration | ~/.clerk/config.json exists and parses | + +## Auto-Fix (`--fix`) + +When `--fix` is passed in human mode, the command prompts to fix each +issue after all checks complete. After applying fixes, all checks are +re-run to verify the results. + +`--fix` only works in human mode because the underlying fix actions are +interactive (`clerk auth login` opens a browser, `clerk link` shows a +picker). It is ignored in `--json` mode and agent mode. + +Fixable issues: + +| Issue | Fix action | +| ---------------------------------- | ----------------------------------- | +| Not logged in / expired token | Log in with `clerk auth login` | +| Not linked to an app / stale app | Link project with `clerk link` | +| Missing environment variables | Pull env vars with `clerk env pull` | +| Missing or corrupt CLI config file | Log in with `clerk auth login` | + +Duplicate fix actions (e.g., multiple checks suggesting `clerk auth login`) +are deduplicated. + +## Agent / CI Usage + +AI agents and CI pipelines should use `--json` to get structured output: + +```sh +clerk doctor --json # Diagnose, output JSON +clerk doctor --json --spotlight # JSON with only warnings/errors +``` + +Each result includes `name`, `status` (`pass` / `warn` / `fail`), +`message`, and optionally `detail` (extra diagnostic info), `remedy` +(a human-readable fix instruction), and `fix` (a label describing +the auto-fix action). + +Agents cannot use `--fix` directly because the fix actions are interactive. +Instead, agents should read the `remedy` field from the JSON output and +orchestrate fixes themselves (e.g., ask the user to run `clerk auth login`, +or call `clerk link --app ` with a known app ID). + +Exit code 1 signals one or more checks failed. + +## Exit Codes + +| Code | Meaning | +| ---- | ---------------------------------------- | +| 0 | All checks passed (warnings are allowed) | +| 1 | One or more checks failed | + +## API Endpoints + +| Method | Endpoint | Description | +| ------ | ----------------------------------- | ----------------------------------------------- | +| `GET` | `/oauth/userinfo` | Validates the stored auth token | +| `GET` | `/v1/platform/applications/{appId}` | Verifies the linked app and its instances exist | diff --git a/src/commands/doctor/checks.ts b/src/commands/doctor/checks.ts new file mode 100644 index 000000000..5886fb9f1 --- /dev/null +++ b/src/commands/doctor/checks.ts @@ -0,0 +1,407 @@ +import { join } from "node:path"; +import { homedir } from "node:os"; +import { fetchUserInfo } from "../../lib/token-exchange.ts"; +import { PlapiError } from "../../lib/plapi.ts"; +import { detectPublishableKeyName } from "../../lib/framework.ts"; +import { parseEnvFile } from "../../lib/dotenv.ts"; +import type { CheckResult, DoctorContext } from "./types.ts"; + +// ── Authentication ────────────────────────────────────────────────────────── + +export async function checkLoggedIn(ctx: DoctorContext): Promise { + const token = await ctx.getToken(); + if (!token) { + return { + name: "Authentication token", + status: "fail", + message: "Not logged in", + remedy: "Run `clerk auth login` to authenticate.", + fix: ctx.fixes.login(), + }; + } + return { + name: "Authentication token", + status: "pass", + message: "Token found in credential store", + }; +} + +export async function checkTokenValid(ctx: DoctorContext): Promise { + const token = await ctx.getToken(); + if (!token) { + return { + name: "Token validity", + status: "warn", + message: "Skipped (no token)", + }; + } + + try { + const userInfo = await fetchUserInfo(token); + return { + name: "Token validity", + status: "pass", + message: `Authenticated as ${userInfo.email}`, + }; + } catch (error) { + const message = (error as Error).message ?? ""; + const isAuthError = /\((401|403)\)/.test(message); + + if (isAuthError) { + return { + name: "Token validity", + status: "fail", + message: "Token is expired or invalid", + remedy: "Run `clerk auth login` to re-authenticate.", + fix: ctx.fixes.login(), + }; + } + + return { + name: "Token validity", + status: "warn", + message: "Could not verify token — network issue", + detail: + "Your stored token from a previous login is likely still valid. " + + "The auth server was unreachable.", + remedy: + "Check your network connection. If issues persist, run `clerk auth login` to re-authenticate.", + }; + } +} + +// ── Project ───────────────────────────────────────────────────────────────── + +export async function checkProjectLinked(ctx: DoctorContext): Promise { + const resolved = await ctx.getProfile(); + if (!resolved) { + return { + name: "Project linkage", + status: "fail", + message: "Not linked to a Clerk application", + remedy: "Run `clerk link` to associate this project with a Clerk app.", + fix: ctx.fixes.link(), + }; + } + + const via = + resolved.resolvedVia === "remote" + ? `via git remote (${resolved.path})` + : resolved.resolvedVia === "git-common-dir" + ? `via git repo (${resolved.path})` + : `via directory (${resolved.path})`; + + return { + name: "Project linkage", + status: "pass", + message: `Linked to ${resolved.profile.appId} ${via}`, + detail: `Workspace: ${resolved.profile.workspaceId || "(none)"}\nDev instance: ${resolved.profile.instances.development}\nProd instance: ${resolved.profile.instances.production ?? "(not set)"}`, + }; +} + +export async function checkLinkedAppExists(ctx: DoctorContext): Promise { + const token = await ctx.getToken(); + if (!token) { + return { + name: "Linked app exists", + status: "warn", + message: "Skipped (not authenticated)", + }; + } + + const resolved = await ctx.getProfile(); + if (!resolved) { + return { + name: "Linked app exists", + status: "warn", + message: "Skipped (no project linked)", + }; + } + + try { + const app = await ctx.getApplication(); + if (!app) { + return { + name: "Linked app exists", + status: "warn", + message: "Skipped (could not fetch application)", + }; + } + const label = app.name || app.application_id; + return { + name: "Linked app exists", + status: "pass", + message: `Application "${label}" is accessible`, + }; + } catch (error) { + if (error instanceof PlapiError && error.status === 404) { + return { + name: "Linked app exists", + status: "fail", + message: `Application ${resolved.profile.appId} not found`, + remedy: + "Run `clerk link` to link to a different application, or `clerk unlink` to remove the stale link.", + fix: ctx.fixes.link(), + }; + } + return { + name: "Linked app exists", + status: "fail", + message: `Could not verify application: ${(error as Error).message}`, + remedy: "Check your network connection and authentication.", + }; + } +} + +export async function checkInstances(ctx: DoctorContext): Promise { + const token = await ctx.getToken(); + if (!token) { + return { + name: "Instances", + status: "warn", + message: "Skipped (not authenticated)", + }; + } + + const resolved = await ctx.getProfile(); + if (!resolved) { + return { + name: "Instances", + status: "warn", + message: "Skipped (no project linked)", + }; + } + + try { + const app = await ctx.getApplication(); + if (!app) { + return { + name: "Instances", + status: "warn", + message: "Skipped (could not fetch application)", + }; + } + const apiInstanceIds = new Set(app.instances.map((i) => i.instance_id)); + + const devId = resolved.profile.instances.development; + const prodId = resolved.profile.instances.production; + + const devValid = apiInstanceIds.has(devId); + const prodValid = prodId ? apiInstanceIds.has(prodId) : undefined; + + const parts: string[] = []; + const stale: string[] = []; + + if (devValid) { + parts.push(`development (${devId})`); + } else { + stale.push(`development (${devId})`); + } + + if (prodId) { + if (prodValid) { + parts.push(`production (${prodId})`); + } else { + stale.push(`production (${prodId})`); + } + } + + if (stale.length > 0) { + return { + name: "Instances", + status: "fail", + message: `Stale instance ID: ${stale.join(", ")}`, + remedy: + "Run `clerk link` to re-link with valid instances, or `clerk unlink` and `clerk link` to start fresh.", + fix: ctx.fixes.link(), + }; + } + + if (!prodId) { + return { + name: "Instances", + status: "warn", + message: `Instances: ${parts.join(", ")} (production not configured)`, + detail: "Production instance is optional but recommended for deployment.", + }; + } + + return { + name: "Instances", + status: "pass", + message: `Instances: ${parts.join(", ")}`, + }; + } catch (error) { + return { + name: "Instances", + status: "fail", + message: `Could not verify instances: ${(error as Error).message}`, + remedy: "Check your network connection and authentication.", + }; + } +} + +// ── Environment ───────────────────────────────────────────────────────────── + +export async function checkGitAvailable(_ctx: DoctorContext): Promise { + try { + const result = await Bun.$`git --version`.quiet().nothrow(); + if (result.exitCode !== 0) { + return { + name: "Git", + status: "warn", + message: "Git is not available", + remedy: "Install git to enable repository-based project linking.", + }; + } + return { + name: "Git", + status: "pass", + message: result.text().trim(), + }; + } catch { + return { + name: "Git", + status: "warn", + message: "Git is not available", + remedy: "Install git to enable repository-based project linking.", + }; + } +} + +export async function checkEnvVars(ctx: DoctorContext): Promise { + const cwd = process.cwd(); + + const candidates = [".env.local", ".env"]; + let foundFile: string | undefined; + const entries: Record = {}; + + for (const name of candidates) { + const filePath = join(cwd, name); + const file = Bun.file(filePath); + if (await file.exists()) { + foundFile = name; + const content = await file.text(); + const lines = parseEnvFile(content); + for (const line of lines) { + if (line.type === "entry") { + entries[line.key] = line.value; + } + } + break; + } + } + + if (!foundFile) { + return { + name: "Environment variables", + status: "warn", + message: "No .env.local or .env file found", + remedy: "Run `clerk env pull` to create one with your Clerk keys.", + fix: ctx.fixes.envPull(), + }; + } + + const publishableKeyName = await detectPublishableKeyName(cwd); + const hasPublishable = publishableKeyName in entries && entries[publishableKeyName] !== ""; + const hasSecret = "CLERK_SECRET_KEY" in entries && entries["CLERK_SECRET_KEY"] !== ""; + + if (!hasPublishable || !hasSecret) { + const missing: string[] = []; + if (!hasPublishable) missing.push(publishableKeyName); + if (!hasSecret) missing.push("CLERK_SECRET_KEY"); + + return { + name: "Environment variables", + status: "warn", + message: `${foundFile} is missing: ${missing.join(", ")}`, + remedy: "Run `clerk env pull` to populate your environment variables.", + fix: ctx.fixes.envPull(), + }; + } + + const envLabel = await identifyEnvironment( + ctx, + entries[publishableKeyName]!, + entries["CLERK_SECRET_KEY"]!, + ); + + if (envLabel) { + return { + name: "Environment variables", + status: "pass", + message: `${foundFile} contains ${publishableKeyName} and CLERK_SECRET_KEY (${envLabel} instance)`, + }; + } + + return { + name: "Environment variables", + status: "pass", + message: `${foundFile} contains ${publishableKeyName} and CLERK_SECRET_KEY`, + }; +} + +/** Match the publishable key or secret key against the linked app's instances to identify the environment. */ +async function identifyEnvironment( + ctx: DoctorContext, + publishableKeyValue: string, + secretKeyValue: string, +): Promise { + let app; + try { + app = await ctx.getApplication(); + } catch { + return null; + } + if (!app) return null; + + const match = + app.instances.find((i) => i.publishable_key === publishableKeyValue) ?? + app.instances.find((i) => i.secret_key === secretKeyValue); + return match?.environment_type ?? null; +} + +// ── Configuration ─────────────────────────────────────────────────────────── + +export async function checkConfigFile(ctx: DoctorContext): Promise { + const configFile = getConfigFile(); + const file = Bun.file(configFile); + if (!(await file.exists())) { + return { + name: "CLI configuration", + status: "warn", + message: `${configFile} does not exist`, + detail: "The config file is created when you first run `clerk auth login` or `clerk link`.", + remedy: "Run `clerk auth login` to initialize the CLI.", + fix: ctx.fixes.login(), + }; + } + + try { + const config = (await file.json()) as { + profiles?: Record; + auth?: unknown; + }; + const profileCount = Object.keys(config.profiles ?? {}).length; + const hasAuth = !!config.auth; + return { + name: "CLI configuration", + status: "pass", + message: `${configFile} is valid (${profileCount} profile${profileCount !== 1 ? "s" : ""}, auth: ${hasAuth ? "yes" : "no"})`, + }; + } catch (error) { + return { + name: "CLI configuration", + status: "fail", + message: `${configFile} failed to parse`, + detail: (error as Error).message, + remedy: `Check the JSON syntax in ${configFile}, or delete it and re-run \`clerk auth login\`.`, + fix: ctx.fixes.login(), + }; + } +} + +function getConfigFile(): string { + const homeDir = process.env.CLERK_CONFIG_DIR ?? join(homedir(), ".clerk"); + return join(homeDir, "config.json"); +} diff --git a/src/commands/doctor/context.test.ts b/src/commands/doctor/context.test.ts new file mode 100644 index 000000000..902912278 --- /dev/null +++ b/src/commands/doctor/context.test.ts @@ -0,0 +1,166 @@ +import { test, expect, describe, mock, beforeEach, afterEach } from "bun:test"; +import { credentialStoreStubs, configStubs, gitStubs, stubFetch } from "../../test/stubs.ts"; +import type { Application } from "../../lib/plapi.ts"; + +const mockGetToken = mock(); + +mock.module("../../lib/credential-store.ts", () => ({ + ...credentialStoreStubs, + getToken: (...args: unknown[]) => mockGetToken(...args), +})); + +const mockResolveProfile = mock(); + +mock.module("../../lib/config.ts", () => ({ + ...configStubs, + resolveProfile: (...args: unknown[]) => mockResolveProfile(...args), +})); + +mock.module("../../lib/git.ts", () => gitStubs); + +// stubFetch instead of mock.module for plapi — mock.module leaks globally in Bun +let mockAppResponse: Application | null = null; +let mockAppError: Error | null = null; +const mockFetch = mock(); + +const { createDoctorContext } = await import("./context.ts"); + +describe("createDoctorContext", () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + mockGetToken.mockReset(); + mockGetToken.mockResolvedValue(null); + + mockResolveProfile.mockReset(); + mockResolveProfile.mockResolvedValue(undefined); + + mockAppResponse = null; + mockAppError = null; + mockFetch.mockReset(); + mockFetch.mockImplementation(async () => { + if (mockAppError) throw mockAppError; + return new Response(JSON.stringify(mockAppResponse), { status: 200 }); + }); + stubFetch((...args: unknown[]) => mockFetch(...args)); + + process.env.CLERK_PLATFORM_API_KEY = "test_key"; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + delete process.env.CLERK_PLATFORM_API_KEY; + mockGetToken.mockReset(); + mockResolveProfile.mockReset(); + mockFetch.mockReset(); + }); + + describe("getToken", () => { + test("returns the same promise on repeated calls", async () => { + mockGetToken.mockResolvedValue("test_token"); + + const ctx = createDoctorContext(); + const p1 = ctx.getToken(); + const p2 = ctx.getToken(); + + expect(p1).toBe(p2); // Same promise reference + expect(await p1).toBe("test_token"); + expect(mockGetToken).toHaveBeenCalledTimes(1); + }); + }); + + describe("getProfile", () => { + test("returns the same promise on repeated calls", async () => { + const profile = { + path: "github.com/org/repo", + profile: { workspaceId: "org_1", appId: "app_1", instances: { development: "ins_dev" } }, + resolvedVia: "remote" as const, + }; + mockResolveProfile.mockResolvedValue(profile); + + const ctx = createDoctorContext(); + const p1 = ctx.getProfile(); + const p2 = ctx.getProfile(); + + expect(p1).toBe(p2); + expect(await p1).toEqual(profile); + expect(mockResolveProfile).toHaveBeenCalledTimes(1); + }); + }); + + describe("getApplication", () => { + test("calls fetchApplication only once", async () => { + mockGetToken.mockResolvedValue("test_token"); + mockResolveProfile.mockResolvedValue({ + path: "github.com/org/repo", + profile: { workspaceId: "org_1", appId: "app_1", instances: { development: "ins_dev" } }, + resolvedVia: "remote" as const, + }); + mockAppResponse = { application_id: "app_1", name: "My App", instances: [] }; + + const ctx = createDoctorContext(); + const p1 = ctx.getApplication(); + const p2 = ctx.getApplication(); + + expect(p1).toBe(p2); + const result = await p1; + expect(result).toEqual(mockAppResponse); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + test("returns null when no token", async () => { + mockGetToken.mockResolvedValue(null); + + const ctx = createDoctorContext(); + const result = await ctx.getApplication(); + + expect(result).toBeNull(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + test("returns null when no profile", async () => { + mockGetToken.mockResolvedValue("test_token"); + mockResolveProfile.mockResolvedValue(undefined); + + const ctx = createDoctorContext(); + const result = await ctx.getApplication(); + + expect(result).toBeNull(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + test("propagates errors from fetchApplication", async () => { + mockGetToken.mockResolvedValue("test_token"); + mockResolveProfile.mockResolvedValue({ + path: "github.com/org/repo", + profile: { workspaceId: "org_1", appId: "app_1", instances: { development: "ins_dev" } }, + resolvedVia: "remote" as const, + }); + mockAppError = new Error("API failure"); + + const ctx = createDoctorContext(); + + await expect(ctx.getApplication()).rejects.toThrow("API failure"); + await expect(ctx.getApplication()).rejects.toThrow("API failure"); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + }); + + describe("fixes", () => { + test("fix factories return FixAction objects with labels", () => { + const ctx = createDoctorContext(); + + const loginFix = ctx.fixes.login(); + expect(loginFix.label).toContain("clerk auth login"); + expect(typeof loginFix.run).toBe("function"); + + const linkFix = ctx.fixes.link(); + expect(linkFix.label).toContain("clerk link"); + expect(typeof linkFix.run).toBe("function"); + + const envPullFix = ctx.fixes.envPull(); + expect(envPullFix.label).toContain("clerk env pull"); + expect(typeof envPullFix.run).toBe("function"); + }); + }); +}); diff --git a/src/commands/doctor/context.ts b/src/commands/doctor/context.ts new file mode 100644 index 000000000..bc910e868 --- /dev/null +++ b/src/commands/doctor/context.ts @@ -0,0 +1,65 @@ +import { getToken } from "../../lib/credential-store.ts"; +import { resolveProfile } from "../../lib/config.ts"; +import { fetchApplication, type Application } from "../../lib/plapi.ts"; +import type { DoctorContext, ResolvedProfile } from "./types.ts"; + +export function createDoctorContext(): DoctorContext { + let tokenPromise: Promise | undefined; + let profilePromise: Promise | undefined; + let appPromise: Promise | undefined; + + const ctx: DoctorContext = { + getToken() { + if (!tokenPromise) { + tokenPromise = getToken(); + } + return tokenPromise; + }, + + getProfile() { + if (!profilePromise) { + profilePromise = resolveProfile(process.cwd()); + } + return profilePromise; + }, + + getApplication() { + if (!appPromise) { + appPromise = (async () => { + const token = await ctx.getToken(); + if (!token) return null; + const resolved = await ctx.getProfile(); + if (!resolved) return null; + return fetchApplication(resolved.profile.appId); + })(); + } + return appPromise; + }, + + fixes: { + login: () => ({ + label: "Log in with clerk auth login", + run: async () => { + const { login } = await import("../auth/login.ts"); + await login(); + }, + }), + link: () => ({ + label: "Link project with clerk link", + run: async () => { + const { link } = await import("../link/index.ts"); + await link(); + }, + }), + envPull: () => ({ + label: "Pull env vars with clerk env pull", + run: async () => { + const { pull } = await import("../env/pull.ts"); + await pull({}); + }, + }), + }, + }; + + return ctx; +} diff --git a/src/commands/doctor/doctor.test.ts b/src/commands/doctor/doctor.test.ts new file mode 100644 index 000000000..9484663f1 --- /dev/null +++ b/src/commands/doctor/doctor.test.ts @@ -0,0 +1,502 @@ +import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { gitStubs, tokenExchangeStubs, stubFetch } from "../../test/stubs.ts"; +import type { DoctorContext, ResolvedProfile } from "./types.ts"; +import type { Application } from "../../lib/plapi.ts"; + +let mockUserInfo: { userId: string; email: string } | null = null; +let mockUserInfoError: Error | null = null; + +mock.module("../../lib/token-exchange.ts", () => ({ + ...tokenExchangeStubs, + fetchUserInfo: async () => { + if (mockUserInfoError) throw mockUserInfoError; + return mockUserInfo; + }, +})); + +mock.module("../../lib/git.ts", () => gitStubs); + +const { + checkLoggedIn, + checkTokenValid, + checkProjectLinked, + checkLinkedAppExists, + checkInstances, + checkGitAvailable, + checkEnvVars, + checkConfigFile, +} = await import("./checks.ts"); + +const originalCwd = process.cwd; +const originalFetch = globalThis.fetch; +const originalEnv = { ...process.env }; + +let tempDir: string; + +const mockApplication: Application = { + application_id: "app_1", + name: "My App", + instances: [ + { + instance_id: "ins_dev", + environment_type: "development", + publishable_key: "pk_test", + secret_key: "sk_test", + }, + { + instance_id: "ins_prod", + environment_type: "production", + publishable_key: "pk_live", + secret_key: "sk_live", + }, + ], +}; + +type Profile = { + workspaceId: string; + appId: string; + instances: { development: string; production?: string }; +}; + +const noopFix = () => ({ label: "noop", run: async () => {} }); + +function createMockContext( + overrides: { + token?: string | null; + profile?: { + path: string; + profile: Profile; + resolvedVia: "remote" | "git-common-dir" | "directory"; + }; + application?: Application | null; + applicationError?: Error; + } = {}, +): DoctorContext { + return { + getToken: async () => overrides.token ?? null, + getProfile: async () => overrides.profile as ResolvedProfile | undefined, + getApplication: async () => { + if (overrides.applicationError) throw overrides.applicationError; + return overrides.application ?? null; + }, + fixes: { + login: noopFix, + link: noopFix, + envPull: noopFix, + }, + }; +} + +beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "clerk-doctor-test-")); + process.cwd = () => tempDir; + process.env = { ...originalEnv }; + process.env.CLERK_PLATFORM_API_KEY = "test_key"; + + mockUserInfo = null; + mockUserInfoError = null; + + stubFetch(async () => new Response(JSON.stringify(mockApplication), { status: 200 })); +}); + +afterEach(async () => { + process.cwd = originalCwd; + process.env = { ...originalEnv }; + globalThis.fetch = originalFetch; + await rm(tempDir, { recursive: true, force: true }); +}); + +describe("checkLoggedIn", () => { + test("pass when token exists", async () => { + const ctx = createMockContext({ token: "test_token" }); + const result = await checkLoggedIn(ctx); + expect(result.status).toBe("pass"); + expect(result.message).toContain("Token found"); + }); + + test("fail when no token", async () => { + const ctx = createMockContext({ token: null }); + const result = await checkLoggedIn(ctx); + expect(result.status).toBe("fail"); + expect(result.remedy).toContain("clerk auth login"); + expect(result.fix).toBeDefined(); + }); +}); + +describe("checkTokenValid", () => { + test("pass with valid token", async () => { + mockUserInfo = { userId: "user_1", email: "dev@example.com" }; + const ctx = createMockContext({ token: "test_token" }); + const result = await checkTokenValid(ctx); + expect(result.status).toBe("pass"); + expect(result.message).toContain("dev@example.com"); + }); + + test("fail when token is expired (401)", async () => { + mockUserInfoError = new Error("Failed to fetch user info (401): Unauthorized"); + const ctx = createMockContext({ token: "expired_token" }); + const result = await checkTokenValid(ctx); + expect(result.status).toBe("fail"); + expect(result.message).toContain("expired or invalid"); + expect(result.remedy).toContain("clerk auth login"); + expect(result.fix).toBeDefined(); + }); + + test("warn when network is unreachable", async () => { + mockUserInfoError = new TypeError("fetch failed"); + const ctx = createMockContext({ token: "test_token" }); + const result = await checkTokenValid(ctx); + expect(result.status).toBe("warn"); + expect(result.message).toContain("network issue"); + expect(result.detail).toContain("likely still valid"); + expect(result.fix).toBeUndefined(); + }); + + test("warn+skip when no token", async () => { + const ctx = createMockContext({ token: null }); + const result = await checkTokenValid(ctx); + expect(result.status).toBe("warn"); + expect(result.message).toContain("Skipped"); + }); +}); + +describe("checkProjectLinked", () => { + test("pass when profile exists", async () => { + const ctx = createMockContext({ + profile: { + path: "github.com/org/repo", + profile: { workspaceId: "org_1", appId: "app_1", instances: { development: "ins_dev" } }, + resolvedVia: "remote", + }, + }); + const result = await checkProjectLinked(ctx); + expect(result.status).toBe("pass"); + expect(result.message).toContain("app_1"); + expect(result.message).toContain("via git remote"); + }); + + test("fail when no profile", async () => { + const ctx = createMockContext(); + const result = await checkProjectLinked(ctx); + expect(result.status).toBe("fail"); + expect(result.remedy).toContain("clerk link"); + expect(result.fix).toBeDefined(); + }); +}); + +describe("checkLinkedAppExists", () => { + test("pass when app is accessible", async () => { + const ctx = createMockContext({ + token: "test_token", + profile: { + path: "github.com/org/repo", + profile: { workspaceId: "org_1", appId: "app_1", instances: { development: "ins_dev" } }, + resolvedVia: "remote", + }, + application: mockApplication, + }); + const result = await checkLinkedAppExists(ctx); + expect(result.status).toBe("pass"); + expect(result.message).toContain("My App"); + }); + + test("fail when app not found (404)", async () => { + const { PlapiError } = await import("../../lib/plapi.ts"); + const ctx = createMockContext({ + token: "test_token", + profile: { + path: "github.com/org/repo", + profile: { workspaceId: "org_1", appId: "app_1", instances: { development: "ins_dev" } }, + resolvedVia: "remote", + }, + applicationError: new PlapiError(404, "Not found"), + }); + const result = await checkLinkedAppExists(ctx); + expect(result.status).toBe("fail"); + expect(result.message).toContain("not found"); + expect(result.fix).toBeDefined(); + }); + + test("fail with generic error on non-404", async () => { + const ctx = createMockContext({ + token: "test_token", + profile: { + path: "github.com/org/repo", + profile: { workspaceId: "org_1", appId: "app_1", instances: { development: "ins_dev" } }, + resolvedVia: "remote", + }, + applicationError: new Error("Connection timeout"), + }); + const result = await checkLinkedAppExists(ctx); + expect(result.status).toBe("fail"); + expect(result.message).toContain("Could not verify application"); + expect(result.fix).toBeUndefined(); + }); + + test("warn when not authenticated", async () => { + const ctx = createMockContext({ token: null }); + const result = await checkLinkedAppExists(ctx); + expect(result.status).toBe("warn"); + expect(result.message).toContain("Skipped"); + }); + + test("warn when no project linked", async () => { + const ctx = createMockContext({ token: "test_token" }); + const result = await checkLinkedAppExists(ctx); + expect(result.status).toBe("warn"); + expect(result.message).toContain("Skipped"); + }); +}); + +describe("checkInstances", () => { + test("pass when dev and prod match API", async () => { + const ctx = createMockContext({ + token: "test_token", + profile: { + path: "github.com/org/repo", + profile: { + workspaceId: "org_1", + appId: "app_1", + instances: { development: "ins_dev", production: "ins_prod" }, + }, + resolvedVia: "remote", + }, + application: mockApplication, + }); + const result = await checkInstances(ctx); + expect(result.status).toBe("pass"); + expect(result.message).toContain("ins_dev"); + expect(result.message).toContain("ins_prod"); + }); + + test("warn when production not configured", async () => { + const ctx = createMockContext({ + token: "test_token", + profile: { + path: "github.com/org/repo", + profile: { workspaceId: "org_1", appId: "app_1", instances: { development: "ins_dev" } }, + resolvedVia: "remote", + }, + application: mockApplication, + }); + const result = await checkInstances(ctx); + expect(result.status).toBe("warn"); + expect(result.message).toContain("production not configured"); + }); + + test("fail when stored instance ID is stale", async () => { + const ctx = createMockContext({ + token: "test_token", + profile: { + path: "github.com/org/repo", + profile: { workspaceId: "org_1", appId: "app_1", instances: { development: "ins_old" } }, + resolvedVia: "remote", + }, + application: mockApplication, + }); + const result = await checkInstances(ctx); + expect(result.status).toBe("fail"); + expect(result.message).toContain("Stale"); + expect(result.message).toContain("ins_old"); + expect(result.fix).toBeDefined(); + }); + + test("warn when not authenticated", async () => { + const ctx = createMockContext({ token: null }); + const result = await checkInstances(ctx); + expect(result.status).toBe("warn"); + expect(result.message).toContain("Skipped"); + }); + + test("warn when no project linked", async () => { + const ctx = createMockContext({ token: "test_token" }); + const result = await checkInstances(ctx); + expect(result.status).toBe("warn"); + expect(result.message).toContain("Skipped"); + }); +}); + +describe("checkGitAvailable", () => { + test("pass when git is installed", async () => { + const ctx = createMockContext(); + const result = await checkGitAvailable(ctx); + expect(result.status).toBe("pass"); + expect(result.message).toContain("git version"); + }); +}); + +describe("checkEnvVars", () => { + test("pass with environment label when keys match an instance", async () => { + await Bun.write( + join(tempDir, ".env.local"), + "CLERK_PUBLISHABLE_KEY=pk_test\nCLERK_SECRET_KEY=sk_test\n", + ); + const ctx = createMockContext({ + token: "test_token", + profile: { + path: "github.com/org/repo", + profile: { workspaceId: "org_1", appId: "app_1", instances: { development: "ins_dev" } }, + resolvedVia: "remote", + }, + application: mockApplication, + }); + const result = await checkEnvVars(ctx); + expect(result.status).toBe("pass"); + expect(result.message).toContain("CLERK_PUBLISHABLE_KEY"); + expect(result.message).toContain("development instance"); + }); + + test("pass without environment label when app not available", async () => { + await Bun.write( + join(tempDir, ".env.local"), + "CLERK_PUBLISHABLE_KEY=pk_test\nCLERK_SECRET_KEY=sk_test\n", + ); + const ctx = createMockContext(); + const result = await checkEnvVars(ctx); + expect(result.status).toBe("pass"); + expect(result.message).toContain("CLERK_PUBLISHABLE_KEY"); + expect(result.message).toContain("CLERK_SECRET_KEY"); + expect(result.message).not.toContain("instance"); + }); + + test("identifies environment via secret key when publishable key doesn't match", async () => { + await Bun.write( + join(tempDir, ".env.local"), + "CLERK_PUBLISHABLE_KEY=pk_test_unknown\nCLERK_SECRET_KEY=sk_test\n", + ); + const ctx = createMockContext({ + token: "test_token", + profile: { + path: "github.com/org/repo", + profile: { workspaceId: "org_1", appId: "app_1", instances: { development: "ins_dev" } }, + resolvedVia: "remote", + }, + application: mockApplication, + }); + const result = await checkEnvVars(ctx); + expect(result.status).toBe("pass"); + expect(result.message).toContain("development instance"); + }); + + test("pass without environment label when neither key matches any instance", async () => { + await Bun.write( + join(tempDir, ".env.local"), + "CLERK_PUBLISHABLE_KEY=pk_test_unknown\nCLERK_SECRET_KEY=sk_test_unknown\n", + ); + const ctx = createMockContext({ + token: "test_token", + profile: { + path: "github.com/org/repo", + profile: { workspaceId: "org_1", appId: "app_1", instances: { development: "ins_dev" } }, + resolvedVia: "remote", + }, + application: mockApplication, + }); + const result = await checkEnvVars(ctx); + expect(result.status).toBe("pass"); + expect(result.message).not.toContain("instance"); + }); + + test("detects framework-specific key name for Next.js", async () => { + await Bun.write( + join(tempDir, "package.json"), + JSON.stringify({ dependencies: { next: "14" } }), + ); + await Bun.write( + join(tempDir, ".env.local"), + "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test\nCLERK_SECRET_KEY=sk_test\n", + ); + const ctx = createMockContext({ + token: "test_token", + profile: { + path: "github.com/org/repo", + profile: { workspaceId: "org_1", appId: "app_1", instances: { development: "ins_dev" } }, + resolvedVia: "remote", + }, + application: mockApplication, + }); + const result = await checkEnvVars(ctx); + expect(result.status).toBe("pass"); + expect(result.message).toContain("NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY"); + expect(result.message).toContain("development instance"); + }); + + test("pass without environment label when getApplication throws", async () => { + await Bun.write( + join(tempDir, ".env.local"), + "CLERK_PUBLISHABLE_KEY=pk_test\nCLERK_SECRET_KEY=sk_test\n", + ); + const ctx = createMockContext({ + applicationError: new Error("Network timeout"), + }); + const result = await checkEnvVars(ctx); + expect(result.status).toBe("pass"); + expect(result.message).not.toContain("instance"); + }); + + test("falls back to .env when .env.local does not exist", async () => { + await Bun.write( + join(tempDir, ".env"), + "CLERK_PUBLISHABLE_KEY=pk_test\nCLERK_SECRET_KEY=sk_test\n", + ); + const ctx = createMockContext({ application: mockApplication }); + const result = await checkEnvVars(ctx); + expect(result.status).toBe("pass"); + expect(result.message).toContain(".env contains"); + }); + + test("warn when keys missing", async () => { + await Bun.write(join(tempDir, ".env.local"), "OTHER=value\n"); + const ctx = createMockContext(); + const result = await checkEnvVars(ctx); + expect(result.status).toBe("warn"); + expect(result.message).toContain("missing"); + expect(result.remedy).toContain("clerk env pull"); + expect(result.fix).toBeDefined(); + }); + + test("warn when no env file", async () => { + const ctx = createMockContext(); + const result = await checkEnvVars(ctx); + expect(result.status).toBe("warn"); + expect(result.message).toContain("No .env.local or .env file found"); + expect(result.fix).toBeDefined(); + }); +}); + +describe("checkConfigFile", () => { + test("pass when config is valid", async () => { + process.env.CLERK_CONFIG_DIR = tempDir; + await Bun.write( + join(tempDir, "config.json"), + JSON.stringify({ profiles: { "/a": {} }, auth: { userId: "u_1" } }), + ); + const ctx = createMockContext(); + const result = await checkConfigFile(ctx); + expect(result.status).toBe("pass"); + expect(result.message).toContain("valid"); + expect(result.message).toContain("1 profile"); + }); + + test("warn when config file does not exist", async () => { + process.env.CLERK_CONFIG_DIR = join(tempDir, "nonexistent"); + const ctx = createMockContext(); + const result = await checkConfigFile(ctx); + expect(result.status).toBe("warn"); + expect(result.message).toContain("does not exist"); + expect(result.fix).toBeDefined(); + }); + + test("fail when config has invalid JSON", async () => { + process.env.CLERK_CONFIG_DIR = tempDir; + await Bun.write(join(tempDir, "config.json"), "{ invalid json }"); + const ctx = createMockContext(); + const result = await checkConfigFile(ctx); + expect(result.status).toBe("fail"); + expect(result.message).toContain("failed to parse"); + expect(result.fix).toBeDefined(); + }); +}); diff --git a/src/commands/doctor/format.ts b/src/commands/doctor/format.ts new file mode 100644 index 000000000..a952098c4 --- /dev/null +++ b/src/commands/doctor/format.ts @@ -0,0 +1,35 @@ +import { dim, green, yellow, red } from "../../lib/color.ts"; +import type { CheckResult, CheckStatus } from "./types.ts"; + +const STATUS_ICON: Record = { + pass: green("✓"), + warn: yellow("!"), + fail: red("✗"), +}; + +export function formatCheckResult(result: CheckResult, verbose: boolean): string { + const icon = STATUS_ICON[result.status]; + let line = ` ${icon} ${result.message}`; + + if (verbose && result.detail) { + const indented = result.detail + .split("\n") + .map((l) => ` ${dim(l)}`) + .join("\n"); + line += "\n" + indented; + } + + if (result.status !== "pass" && result.remedy) { + line += `\n ${dim(result.remedy)}`; + } + + return line; +} + +export function formatJson(results: CheckResult[]): string { + const sanitized = results.map(({ fix, ...rest }) => ({ + ...rest, + ...(fix ? { fix: fix.label } : {}), + })); + return JSON.stringify(sanitized, null, 2); +} diff --git a/src/commands/doctor/index.ts b/src/commands/doctor/index.ts new file mode 100644 index 000000000..c52d75f35 --- /dev/null +++ b/src/commands/doctor/index.ts @@ -0,0 +1,124 @@ +import { isHuman } from "../../mode.ts"; +import { bold, green, red } from "../../lib/color.ts"; +import { createDoctorContext } from "./context.ts"; +import { + checkLoggedIn, + checkTokenValid, + checkProjectLinked, + checkLinkedAppExists, + checkInstances, + checkGitAvailable, + checkEnvVars, + checkConfigFile, +} from "./checks.ts"; +import { formatCheckResult, formatJson } from "./format.ts"; +import type { CheckFn, CheckResult, DoctorContext, DoctorOptions } from "./types.ts"; + +const CHECKS: CheckFn[] = [ + checkLoggedIn, + checkTokenValid, + checkProjectLinked, + checkLinkedAppExists, + checkInstances, + checkGitAvailable, + checkEnvVars, + checkConfigFile, +]; + +async function runChecks(ctx: DoctorContext, options: DoctorOptions): Promise { + const results = await Promise.all( + CHECKS.map(async (check) => { + try { + return await check(ctx); + } catch (error) { + return { + name: "Unknown check", + status: "fail" as const, + message: `Check crashed: ${(error as Error).message}`, + }; + } + }), + ); + + if (!options.json) { + for (const result of results) { + if (!options.spotlight || result.status !== "pass") { + console.log(formatCheckResult(result, options.verbose ?? false)); + } + } + console.log(""); + } + + return results; +} + +export async function doctor(options: DoctorOptions = {}): Promise { + if (!options.json) { + console.log(""); + } + + const ctx = createDoctorContext(); + const allResults = await runChecks(ctx, options); + + if (options.json) { + const output = options.spotlight ? allResults.filter((r) => r.status !== "pass") : allResults; + console.log(formatJson(output)); + } + + if (options.fix && !options.json && isHuman()) { + const fixable = allResults.filter((r) => r.status !== "pass" && r.fix); + + const seen = new Set(); + const uniqueFixable = fixable.filter((r) => { + if (seen.has(r.fix!.label)) return false; + seen.add(r.fix!.label); + return true; + }); + + if (uniqueFixable.length > 0) { + console.log(""); + console.log(bold("Auto-fix")); + console.log(""); + + const { confirm } = await import("@inquirer/prompts"); + + for (const result of uniqueFixable) { + const proceed = await confirm({ + message: `Fix "${result.name}"? (${result.fix!.label})`, + default: true, + }); + + if (proceed) { + try { + await result.fix!.run(); + console.log(` ${green("✓")} ${result.name} fixed`); + } catch (error) { + console.log(` ${red("✗")} Fix failed: ${(error as Error).message}`); + } + } + } + + console.log(""); + console.log(bold("Verifying fixes...")); + console.log(""); + + const verifyCtx = createDoctorContext(); + const verifyResults = await runChecks(verifyCtx, { + ...options, + fix: false, + spotlight: false, + }); + + const hasVerifyFailure = verifyResults.some((r) => r.status === "fail"); + if (hasVerifyFailure) { + process.exit(1); + } + return; + } + } + + const hasFailure = allResults.some((r) => r.status === "fail"); + if (hasFailure) { + process.exit(1); + } +} diff --git a/src/commands/doctor/types.ts b/src/commands/doctor/types.ts new file mode 100644 index 000000000..ae6d76260 --- /dev/null +++ b/src/commands/doctor/types.ts @@ -0,0 +1,40 @@ +import type { resolveProfile } from "../../lib/config.ts"; +import type { Application } from "../../lib/plapi.ts"; + +export type CheckStatus = "pass" | "warn" | "fail"; + +export type ResolvedProfile = NonNullable>>; + +export interface FixAction { + label: string; + run: () => Promise; +} + +export interface CheckResult { + name: string; + status: CheckStatus; + message: string; + detail?: string; + remedy?: string; + fix?: FixAction; +} + +export interface DoctorContext { + getToken(): Promise; + getProfile(): Promise; + getApplication(): Promise; + fixes: { + login: () => FixAction; + link: () => FixAction; + envPull: () => FixAction; + }; +} + +export type CheckFn = (ctx: DoctorContext) => Promise; + +export interface DoctorOptions { + verbose?: boolean; + json?: boolean; + spotlight?: boolean; + fix?: boolean; +} From aad17a3ceea273faff5df0bb1f6e1097a68847d6 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 10 Mar 2026 00:08:05 -0300 Subject: [PATCH 3/9] feat(cli): register doctor command --- src/cli.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/cli.ts b/src/cli.ts index 5b9507f85..e03260ad5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -13,6 +13,7 @@ import { configSchema } from "./commands/config/schema.js"; import { api } from "./commands/api/index.js"; import { link } from "./commands/link/index.js"; import { unlink } from "./commands/unlink/index.js"; +import { doctor } from "./commands/doctor/index.js"; import { CliError, UserAbortError, ApiError, EXIT_CODE, throwUsageError } from "./lib/errors.js"; import { red } from "./lib/color.js"; @@ -42,7 +43,12 @@ program.command("init").description("Initialize Clerk in your project").action(i const auth = program.command("auth").description("Manage authentication"); -auth.command("login").description("Log in to your Clerk account").action(login); +auth + .command("login") + .description("Log in to your Clerk account") + .action(async () => { + await login(); + }); auth.command("logout").description("Log out of your Clerk account").action(logout); @@ -122,6 +128,15 @@ program .option("--yes", "Skip confirmation for mutating requests") .action(api); +program + .command("doctor") + .description("Check your project's Clerk integration health") + .option("--verbose", "Show detailed output for each check") + .option("--json", "Output results as JSON") + .option("--spotlight", "Only show warnings and failures") + .option("--fix", "Attempt to auto-fix issues") + .action(doctor); + program .command("deploy", { hidden: true }) .description("Deploy your Clerk application") From 7666de8abafa2360225dc6266247d308d7bb2e09 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 10 Mar 2026 00:08:09 -0300 Subject: [PATCH 4/9] docs: add doctor command to CLI help output --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index a7666c694..a0dfed6ca 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Commands: api [options] [endpoint] [filter] Make authenticated requests to the Clerk API ls [filter] List available API endpoints (no args) Interactive request builder (TTY only) + doctor [options] Check your project's Clerk integration health deploy [options] Deploy your Clerk application (hidden) clerk init @@ -83,6 +84,12 @@ clerk api [endpoint] [filter] clerk api ls [filter] List available API endpoints clerk api Interactive request builder (TTY only) +clerk doctor + --verbose Show detailed output for each check + --json Output results as JSON + --spotlight Only show warnings and failures + --fix Attempt to auto-fix issues + clerk deploy --debug Show debug output ``` From a2aa71f941c174868cdef8841ea304ba4786ea29 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 10 Mar 2026 00:22:54 -0300 Subject: [PATCH 5/9] refactor(doctor): remove git availability check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Git is not required by any CLI command — the link command gracefully falls back to directory-based profile keys when git is unavailable. --- src/commands/doctor/README.md | 1 - src/commands/doctor/checks.ts | 26 -------------------------- src/commands/doctor/doctor.test.ts | 10 ---------- src/commands/doctor/index.ts | 2 -- 4 files changed, 39 deletions(-) diff --git a/src/commands/doctor/README.md b/src/commands/doctor/README.md index aad671f0a..f72341050 100644 --- a/src/commands/doctor/README.md +++ b/src/commands/doctor/README.md @@ -32,7 +32,6 @@ clerk doctor --fix # Offer to auto-fix issues | Project linkage | Project | Current directory is linked to a Clerk app | | Linked app exists | Project | Linked application ID is accessible via the API | | Instances | Project | Configured dev/prod instance IDs match the application's instances | -| Git | Environment | Git is installed and available | | Environment variables | Environment | .env.local or .env has Clerk keys | | CLI configuration | Configuration | ~/.clerk/config.json exists and parses | diff --git a/src/commands/doctor/checks.ts b/src/commands/doctor/checks.ts index 5886fb9f1..6d73ec556 100644 --- a/src/commands/doctor/checks.ts +++ b/src/commands/doctor/checks.ts @@ -243,32 +243,6 @@ export async function checkInstances(ctx: DoctorContext): Promise { // ── Environment ───────────────────────────────────────────────────────────── -export async function checkGitAvailable(_ctx: DoctorContext): Promise { - try { - const result = await Bun.$`git --version`.quiet().nothrow(); - if (result.exitCode !== 0) { - return { - name: "Git", - status: "warn", - message: "Git is not available", - remedy: "Install git to enable repository-based project linking.", - }; - } - return { - name: "Git", - status: "pass", - message: result.text().trim(), - }; - } catch { - return { - name: "Git", - status: "warn", - message: "Git is not available", - remedy: "Install git to enable repository-based project linking.", - }; - } -} - export async function checkEnvVars(ctx: DoctorContext): Promise { const cwd = process.cwd(); diff --git a/src/commands/doctor/doctor.test.ts b/src/commands/doctor/doctor.test.ts index 9484663f1..6a975ebfd 100644 --- a/src/commands/doctor/doctor.test.ts +++ b/src/commands/doctor/doctor.test.ts @@ -25,7 +25,6 @@ const { checkProjectLinked, checkLinkedAppExists, checkInstances, - checkGitAvailable, checkEnvVars, checkConfigFile, } = await import("./checks.ts"); @@ -319,15 +318,6 @@ describe("checkInstances", () => { }); }); -describe("checkGitAvailable", () => { - test("pass when git is installed", async () => { - const ctx = createMockContext(); - const result = await checkGitAvailable(ctx); - expect(result.status).toBe("pass"); - expect(result.message).toContain("git version"); - }); -}); - describe("checkEnvVars", () => { test("pass with environment label when keys match an instance", async () => { await Bun.write( diff --git a/src/commands/doctor/index.ts b/src/commands/doctor/index.ts index c52d75f35..4312994df 100644 --- a/src/commands/doctor/index.ts +++ b/src/commands/doctor/index.ts @@ -7,7 +7,6 @@ import { checkProjectLinked, checkLinkedAppExists, checkInstances, - checkGitAvailable, checkEnvVars, checkConfigFile, } from "./checks.ts"; @@ -20,7 +19,6 @@ const CHECKS: CheckFn[] = [ checkProjectLinked, checkLinkedAppExists, checkInstances, - checkGitAvailable, checkEnvVars, checkConfigFile, ]; From 4cac338dedf4f709840f2b13d0055f49b1703d3d Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Wed, 11 Mar 2026 13:40:16 -0300 Subject: [PATCH 6/9] refactor(doctor): DRY check functions with defineCheck builder and improve tests - Extract defineCheck() builder pattern to eliminate duplicated result construction across all 7 check functions - Make fix factories lazy (passed as references, invoked only when needed) - Extract AUTH_ERROR_STATUS regex to named constant - Add expectCheck() test helper and mockProfile fixture to reduce test boilerplate - Replace non-null assertion with runtime guard in fix loop - Rename "Linked app exists" check to "Linked application" --- src/commands/doctor/README.md | 2 +- src/commands/doctor/checks.ts | 290 ++++++++++---------------- src/commands/doctor/doctor.test.ts | 320 +++++++++++++++++------------ src/commands/doctor/index.ts | 11 +- 4 files changed, 312 insertions(+), 311 deletions(-) diff --git a/src/commands/doctor/README.md b/src/commands/doctor/README.md index f72341050..582125f12 100644 --- a/src/commands/doctor/README.md +++ b/src/commands/doctor/README.md @@ -30,7 +30,7 @@ clerk doctor --fix # Offer to auto-fix issues | Authentication token | Authentication | Credential store has a stored token | | Token validity | Authentication | Token is still valid (calls `/oauth/userinfo`) | | Project linkage | Project | Current directory is linked to a Clerk app | -| Linked app exists | Project | Linked application ID is accessible via the API | +| Linked application | Project | Linked application ID is accessible via the API | | Instances | Project | Configured dev/prod instance IDs match the application's instances | | Environment variables | Environment | .env.local or .env has Clerk keys | | CLI configuration | Configuration | ~/.clerk/config.json exists and parses | diff --git a/src/commands/doctor/checks.ts b/src/commands/doctor/checks.ts index 6d73ec556..a2bacf95d 100644 --- a/src/commands/doctor/checks.ts +++ b/src/commands/doctor/checks.ts @@ -4,84 +4,101 @@ import { fetchUserInfo } from "../../lib/token-exchange.ts"; import { PlapiError } from "../../lib/plapi.ts"; import { detectPublishableKeyName } from "../../lib/framework.ts"; import { parseEnvFile } from "../../lib/dotenv.ts"; -import type { CheckResult, DoctorContext } from "./types.ts"; +import type { CheckResult, DoctorContext, FixAction } from "./types.ts"; -// ── Authentication ────────────────────────────────────────────────────────── +const AUTH_ERROR_STATUS = /\((401|403)\)/; -export async function checkLoggedIn(ctx: DoctorContext): Promise { - const token = await ctx.getToken(); - if (!token) { +interface CheckOptions { + remedy?: string; + detail?: string; + fixable?: boolean; +} + +interface CheckBuilder { + pass(message: string, detail?: string): CheckResult; + fail(message: string, opts?: CheckOptions): CheckResult; + warn(message: string, opts?: CheckOptions): CheckResult; + skip(reason: string): CheckResult; +} + +function defineCheck(name: string, fixFactory?: () => FixAction): CheckBuilder { + function buildResult( + status: "fail" | "warn", + message: string, + opts: CheckOptions | undefined, + fixableByDefault: boolean, + ): CheckResult { + const { remedy, detail, fixable = fixableByDefault } = opts ?? {}; return { - name: "Authentication token", - status: "fail", - message: "Not logged in", - remedy: "Run `clerk auth login` to authenticate.", - fix: ctx.fixes.login(), + name, + status, + message, + ...(detail && { detail }), + ...(remedy && { remedy }), + ...(fixable && fixFactory && { fix: fixFactory() }), }; } + return { - name: "Authentication token", - status: "pass", - message: "Token found in credential store", + pass(message, detail) { + return { name, status: "pass", message, ...(detail && { detail }) }; + }, + fail(message, opts) { + return buildResult("fail", message, opts, true); + }, + warn(message, opts) { + return buildResult("warn", message, opts, false); + }, + skip(reason) { + return { name, status: "warn", message: `Skipped (${reason})` }; + }, }; } -export async function checkTokenValid(ctx: DoctorContext): Promise { +export async function checkLoggedIn(ctx: DoctorContext): Promise { + const check = defineCheck("Authentication token", ctx.fixes.login); const token = await ctx.getToken(); if (!token) { - return { - name: "Token validity", - status: "warn", - message: "Skipped (no token)", - }; + return check.fail("Not logged in", { + remedy: "Run `clerk auth login` to authenticate.", + }); } + return check.pass("Token found in credential store"); +} + +export async function checkTokenValid(ctx: DoctorContext): Promise { + const check = defineCheck("Token validity", ctx.fixes.login); + const token = await ctx.getToken(); + if (!token) return check.skip("no token"); try { const userInfo = await fetchUserInfo(token); - return { - name: "Token validity", - status: "pass", - message: `Authenticated as ${userInfo.email}`, - }; + return check.pass(`Authenticated as ${userInfo.email}`); } catch (error) { const message = (error as Error).message ?? ""; - const isAuthError = /\((401|403)\)/.test(message); - - if (isAuthError) { - return { - name: "Token validity", - status: "fail", - message: "Token is expired or invalid", + if (AUTH_ERROR_STATUS.test(message)) { + return check.fail("Token is expired or invalid", { remedy: "Run `clerk auth login` to re-authenticate.", - fix: ctx.fixes.login(), - }; + }); } - return { - name: "Token validity", - status: "warn", - message: "Could not verify token — network issue", + return check.warn("Could not verify token — network issue", { detail: "Your stored token from a previous login is likely still valid. " + "The auth server was unreachable.", remedy: "Check your network connection. If issues persist, run `clerk auth login` to re-authenticate.", - }; + }); } } -// ── Project ───────────────────────────────────────────────────────────────── - export async function checkProjectLinked(ctx: DoctorContext): Promise { + const check = defineCheck("Project linkage", ctx.fixes.link); const resolved = await ctx.getProfile(); if (!resolved) { - return { - name: "Project linkage", - status: "fail", - message: "Not linked to a Clerk application", + return check.fail("Not linked to a Clerk application", { remedy: "Run `clerk link` to associate this project with a Clerk app.", - fix: ctx.fixes.link(), - }; + }); } const via = @@ -91,96 +108,50 @@ export async function checkProjectLinked(ctx: DoctorContext): Promise { + const check = defineCheck("Linked application", ctx.fixes.link); const token = await ctx.getToken(); - if (!token) { - return { - name: "Linked app exists", - status: "warn", - message: "Skipped (not authenticated)", - }; - } + if (!token) return check.skip("not authenticated"); const resolved = await ctx.getProfile(); - if (!resolved) { - return { - name: "Linked app exists", - status: "warn", - message: "Skipped (no project linked)", - }; - } + if (!resolved) return check.skip("no project linked"); try { const app = await ctx.getApplication(); - if (!app) { - return { - name: "Linked app exists", - status: "warn", - message: "Skipped (could not fetch application)", - }; - } + if (!app) return check.skip("could not fetch application"); const label = app.name || app.application_id; - return { - name: "Linked app exists", - status: "pass", - message: `Application "${label}" is accessible`, - }; + return check.pass(`Application "${label}" is accessible`); } catch (error) { if (error instanceof PlapiError && error.status === 404) { - return { - name: "Linked app exists", - status: "fail", - message: `Application ${resolved.profile.appId} not found`, + return check.fail(`Application ${resolved.profile.appId} not found`, { remedy: "Run `clerk link` to link to a different application, or `clerk unlink` to remove the stale link.", - fix: ctx.fixes.link(), - }; + }); } - return { - name: "Linked app exists", - status: "fail", - message: `Could not verify application: ${(error as Error).message}`, + return check.fail(`Could not verify application: ${(error as Error).message}`, { remedy: "Check your network connection and authentication.", - }; + fixable: false, + }); } } export async function checkInstances(ctx: DoctorContext): Promise { + const check = defineCheck("Instances", ctx.fixes.link); const token = await ctx.getToken(); - if (!token) { - return { - name: "Instances", - status: "warn", - message: "Skipped (not authenticated)", - }; - } + if (!token) return check.skip("not authenticated"); const resolved = await ctx.getProfile(); - if (!resolved) { - return { - name: "Instances", - status: "warn", - message: "Skipped (no project linked)", - }; - } + if (!resolved) return check.skip("no project linked"); try { const app = await ctx.getApplication(); - if (!app) { - return { - name: "Instances", - status: "warn", - message: "Skipped (could not fetch application)", - }; - } + if (!app) return check.skip("could not fetch application"); const apiInstanceIds = new Set(app.instances.map((i) => i.instance_id)); const devId = resolved.profile.instances.development; @@ -207,54 +178,40 @@ export async function checkInstances(ctx: DoctorContext): Promise { } if (stale.length > 0) { - return { - name: "Instances", - status: "fail", - message: `Stale instance ID: ${stale.join(", ")}`, + return check.fail(`Stale instance ID: ${stale.join(", ")}`, { remedy: "Run `clerk link` to re-link with valid instances, or `clerk unlink` and `clerk link` to start fresh.", - fix: ctx.fixes.link(), - }; + }); } if (!prodId) { - return { - name: "Instances", - status: "warn", - message: `Instances: ${parts.join(", ")} (production not configured)`, + return check.warn(`Instances: ${parts.join(", ")} (production not configured)`, { detail: "Production instance is optional but recommended for deployment.", - }; + }); } - return { - name: "Instances", - status: "pass", - message: `Instances: ${parts.join(", ")}`, - }; + return check.pass(`Instances: ${parts.join(", ")}`); } catch (error) { - return { - name: "Instances", - status: "fail", - message: `Could not verify instances: ${(error as Error).message}`, + return check.fail(`Could not verify instances: ${(error as Error).message}`, { remedy: "Check your network connection and authentication.", - }; + fixable: false, + }); } } -// ── Environment ───────────────────────────────────────────────────────────── - export async function checkEnvVars(ctx: DoctorContext): Promise { + const check = defineCheck("Environment variables", ctx.fixes.envPull); const cwd = process.cwd(); const candidates = [".env.local", ".env"]; let foundFile: string | undefined; const entries: Record = {}; - for (const name of candidates) { - const filePath = join(cwd, name); + for (const candidate of candidates) { + const filePath = join(cwd, candidate); const file = Bun.file(filePath); if (await file.exists()) { - foundFile = name; + foundFile = candidate; const content = await file.text(); const lines = parseEnvFile(content); for (const line of lines) { @@ -267,13 +224,10 @@ export async function checkEnvVars(ctx: DoctorContext): Promise { } if (!foundFile) { - return { - name: "Environment variables", - status: "warn", - message: "No .env.local or .env file found", + return check.warn("No .env.local or .env file found", { remedy: "Run `clerk env pull` to create one with your Clerk keys.", - fix: ctx.fixes.envPull(), - }; + fixable: true, + }); } const publishableKeyName = await detectPublishableKeyName(cwd); @@ -285,13 +239,10 @@ export async function checkEnvVars(ctx: DoctorContext): Promise { if (!hasPublishable) missing.push(publishableKeyName); if (!hasSecret) missing.push("CLERK_SECRET_KEY"); - return { - name: "Environment variables", - status: "warn", - message: `${foundFile} is missing: ${missing.join(", ")}`, + return check.warn(`${foundFile} is missing: ${missing.join(", ")}`, { remedy: "Run `clerk env pull` to populate your environment variables.", - fix: ctx.fixes.envPull(), - }; + fixable: true, + }); } const envLabel = await identifyEnvironment( @@ -301,21 +252,14 @@ export async function checkEnvVars(ctx: DoctorContext): Promise { ); if (envLabel) { - return { - name: "Environment variables", - status: "pass", - message: `${foundFile} contains ${publishableKeyName} and CLERK_SECRET_KEY (${envLabel} instance)`, - }; + return check.pass( + `${foundFile} contains ${publishableKeyName} and CLERK_SECRET_KEY (${envLabel} instance)`, + ); } - return { - name: "Environment variables", - status: "pass", - message: `${foundFile} contains ${publishableKeyName} and CLERK_SECRET_KEY`, - }; + return check.pass(`${foundFile} contains ${publishableKeyName} and CLERK_SECRET_KEY`); } -/** Match the publishable key or secret key against the linked app's instances to identify the environment. */ async function identifyEnvironment( ctx: DoctorContext, publishableKeyValue: string, @@ -335,20 +279,16 @@ async function identifyEnvironment( return match?.environment_type ?? null; } -// ── Configuration ─────────────────────────────────────────────────────────── - export async function checkConfigFile(ctx: DoctorContext): Promise { + const check = defineCheck("CLI configuration", ctx.fixes.login); const configFile = getConfigFile(); const file = Bun.file(configFile); if (!(await file.exists())) { - return { - name: "CLI configuration", - status: "warn", - message: `${configFile} does not exist`, + return check.warn(`${configFile} does not exist`, { detail: "The config file is created when you first run `clerk auth login` or `clerk link`.", remedy: "Run `clerk auth login` to initialize the CLI.", - fix: ctx.fixes.login(), - }; + fixable: true, + }); } try { @@ -358,20 +298,14 @@ export async function checkConfigFile(ctx: DoctorContext): Promise }; const profileCount = Object.keys(config.profiles ?? {}).length; const hasAuth = !!config.auth; - return { - name: "CLI configuration", - status: "pass", - message: `${configFile} is valid (${profileCount} profile${profileCount !== 1 ? "s" : ""}, auth: ${hasAuth ? "yes" : "no"})`, - }; + return check.pass( + `${configFile} is valid (${profileCount} profile${profileCount !== 1 ? "s" : ""}, auth: ${hasAuth ? "yes" : "no"})`, + ); } catch (error) { - return { - name: "CLI configuration", - status: "fail", - message: `${configFile} failed to parse`, + return check.fail(`${configFile} failed to parse`, { detail: (error as Error).message, remedy: `Check the JSON syntax in ${configFile}, or delete it and re-run \`clerk auth login\`.`, - fix: ctx.fixes.login(), - }; + }); } } diff --git a/src/commands/doctor/doctor.test.ts b/src/commands/doctor/doctor.test.ts index 6a975ebfd..ab9aceb19 100644 --- a/src/commands/doctor/doctor.test.ts +++ b/src/commands/doctor/doctor.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { gitStubs, tokenExchangeStubs, stubFetch } from "../../test/stubs.ts"; -import type { DoctorContext, ResolvedProfile } from "./types.ts"; +import type { CheckResult, CheckStatus, DoctorContext, ResolvedProfile } from "./types.ts"; import type { Application } from "../../lib/plapi.ts"; let mockUserInfo: { userId: string; email: string } | null = null; @@ -62,6 +62,12 @@ type Profile = { const noopFix = () => ({ label: "noop", run: async () => {} }); +const mockProfile = { + path: "github.com/org/repo", + profile: { workspaceId: "org_1", appId: "app_1", instances: { development: "ins_dev" } }, + resolvedVia: "remote" as const, +}; + function createMockContext( overrides: { token?: string | null; @@ -89,6 +95,45 @@ function createMockContext( }; } +interface ExpectedCheck { + name: string; + status: CheckStatus; + message?: string | string[]; + messageNot?: string | string[]; + remedy?: string; + detail?: string; + fix?: boolean; +} + +function toArray(value: string | string[] | undefined): string[] { + if (!value) return []; + return Array.isArray(value) ? value : [value]; +} + +function expectCheck(result: CheckResult, expected: ExpectedCheck) { + expect(result.name).toBe(expected.name); + expect(result.status).toBe(expected.status); + + for (const msg of toArray(expected.message)) { + expect(result.message).toContain(msg); + } + for (const msg of toArray(expected.messageNot)) { + expect(result.message).not.toContain(msg); + } + + if (expected.remedy !== undefined) { + expect(result.remedy).toContain(expected.remedy); + } + if (expected.detail !== undefined) { + expect(result.detail).toContain(expected.detail); + } + if (expected.fix === true) { + expect(result.fix).toBeDefined(); + } else if (expected.fix === false) { + expect(result.fix).toBeUndefined(); + } +} + beforeEach(async () => { tempDir = await mkdtemp(join(tmpdir(), "clerk-doctor-test-")); process.cwd = () => tempDir; @@ -112,16 +157,18 @@ describe("checkLoggedIn", () => { test("pass when token exists", async () => { const ctx = createMockContext({ token: "test_token" }); const result = await checkLoggedIn(ctx); - expect(result.status).toBe("pass"); - expect(result.message).toContain("Token found"); + expectCheck(result, { name: "Authentication token", status: "pass", message: "Token found" }); }); test("fail when no token", async () => { const ctx = createMockContext({ token: null }); const result = await checkLoggedIn(ctx); - expect(result.status).toBe("fail"); - expect(result.remedy).toContain("clerk auth login"); - expect(result.fix).toBeDefined(); + expectCheck(result, { + name: "Authentication token", + status: "fail", + remedy: "clerk auth login", + fix: true, + }); }); }); @@ -130,59 +177,64 @@ describe("checkTokenValid", () => { mockUserInfo = { userId: "user_1", email: "dev@example.com" }; const ctx = createMockContext({ token: "test_token" }); const result = await checkTokenValid(ctx); - expect(result.status).toBe("pass"); - expect(result.message).toContain("dev@example.com"); + expectCheck(result, { name: "Token validity", status: "pass", message: "dev@example.com" }); }); test("fail when token is expired (401)", async () => { mockUserInfoError = new Error("Failed to fetch user info (401): Unauthorized"); const ctx = createMockContext({ token: "expired_token" }); const result = await checkTokenValid(ctx); - expect(result.status).toBe("fail"); - expect(result.message).toContain("expired or invalid"); - expect(result.remedy).toContain("clerk auth login"); - expect(result.fix).toBeDefined(); + expectCheck(result, { + name: "Token validity", + status: "fail", + message: "expired or invalid", + remedy: "clerk auth login", + fix: true, + }); }); test("warn when network is unreachable", async () => { mockUserInfoError = new TypeError("fetch failed"); const ctx = createMockContext({ token: "test_token" }); const result = await checkTokenValid(ctx); - expect(result.status).toBe("warn"); - expect(result.message).toContain("network issue"); - expect(result.detail).toContain("likely still valid"); - expect(result.fix).toBeUndefined(); + expectCheck(result, { + name: "Token validity", + status: "warn", + message: "network issue", + detail: "likely still valid", + fix: false, + }); }); test("warn+skip when no token", async () => { const ctx = createMockContext({ token: null }); const result = await checkTokenValid(ctx); - expect(result.status).toBe("warn"); - expect(result.message).toContain("Skipped"); + expectCheck(result, { name: "Token validity", status: "warn", message: "Skipped" }); }); }); describe("checkProjectLinked", () => { test("pass when profile exists", async () => { const ctx = createMockContext({ - profile: { - path: "github.com/org/repo", - profile: { workspaceId: "org_1", appId: "app_1", instances: { development: "ins_dev" } }, - resolvedVia: "remote", - }, + profile: mockProfile, }); const result = await checkProjectLinked(ctx); - expect(result.status).toBe("pass"); - expect(result.message).toContain("app_1"); - expect(result.message).toContain("via git remote"); + expectCheck(result, { + name: "Project linkage", + status: "pass", + message: ["app_1", "via git remote"], + }); }); test("fail when no profile", async () => { const ctx = createMockContext(); const result = await checkProjectLinked(ctx); - expect(result.status).toBe("fail"); - expect(result.remedy).toContain("clerk link"); - expect(result.fix).toBeDefined(); + expectCheck(result, { + name: "Project linkage", + status: "fail", + remedy: "clerk link", + fix: true, + }); }); }); @@ -190,63 +242,58 @@ describe("checkLinkedAppExists", () => { test("pass when app is accessible", async () => { const ctx = createMockContext({ token: "test_token", - profile: { - path: "github.com/org/repo", - profile: { workspaceId: "org_1", appId: "app_1", instances: { development: "ins_dev" } }, - resolvedVia: "remote", - }, + profile: mockProfile, application: mockApplication, }); const result = await checkLinkedAppExists(ctx); - expect(result.status).toBe("pass"); - expect(result.message).toContain("My App"); + expectCheck(result, { + name: "Linked application", + status: "pass", + message: "My App", + }); }); test("fail when app not found (404)", async () => { const { PlapiError } = await import("../../lib/plapi.ts"); const ctx = createMockContext({ token: "test_token", - profile: { - path: "github.com/org/repo", - profile: { workspaceId: "org_1", appId: "app_1", instances: { development: "ins_dev" } }, - resolvedVia: "remote", - }, + profile: mockProfile, applicationError: new PlapiError(404, "Not found"), }); const result = await checkLinkedAppExists(ctx); - expect(result.status).toBe("fail"); - expect(result.message).toContain("not found"); - expect(result.fix).toBeDefined(); + expectCheck(result, { + name: "Linked application", + status: "fail", + message: "not found", + fix: true, + }); }); test("fail with generic error on non-404", async () => { const ctx = createMockContext({ token: "test_token", - profile: { - path: "github.com/org/repo", - profile: { workspaceId: "org_1", appId: "app_1", instances: { development: "ins_dev" } }, - resolvedVia: "remote", - }, + profile: mockProfile, applicationError: new Error("Connection timeout"), }); const result = await checkLinkedAppExists(ctx); - expect(result.status).toBe("fail"); - expect(result.message).toContain("Could not verify application"); - expect(result.fix).toBeUndefined(); + expectCheck(result, { + name: "Linked application", + status: "fail", + message: "Could not verify application", + fix: false, + }); }); test("warn when not authenticated", async () => { const ctx = createMockContext({ token: null }); const result = await checkLinkedAppExists(ctx); - expect(result.status).toBe("warn"); - expect(result.message).toContain("Skipped"); + expectCheck(result, { name: "Linked application", status: "warn", message: "Skipped" }); }); test("warn when no project linked", async () => { const ctx = createMockContext({ token: "test_token" }); const result = await checkLinkedAppExists(ctx); - expect(result.status).toBe("warn"); - expect(result.message).toContain("Skipped"); + expectCheck(result, { name: "Linked application", status: "warn", message: "Skipped" }); }); }); @@ -266,24 +313,25 @@ describe("checkInstances", () => { application: mockApplication, }); const result = await checkInstances(ctx); - expect(result.status).toBe("pass"); - expect(result.message).toContain("ins_dev"); - expect(result.message).toContain("ins_prod"); + expectCheck(result, { + name: "Instances", + status: "pass", + message: ["ins_dev", "ins_prod"], + }); }); test("warn when production not configured", async () => { const ctx = createMockContext({ token: "test_token", - profile: { - path: "github.com/org/repo", - profile: { workspaceId: "org_1", appId: "app_1", instances: { development: "ins_dev" } }, - resolvedVia: "remote", - }, + profile: mockProfile, application: mockApplication, }); const result = await checkInstances(ctx); - expect(result.status).toBe("warn"); - expect(result.message).toContain("production not configured"); + expectCheck(result, { + name: "Instances", + status: "warn", + message: "production not configured", + }); }); test("fail when stored instance ID is stale", async () => { @@ -297,24 +345,24 @@ describe("checkInstances", () => { application: mockApplication, }); const result = await checkInstances(ctx); - expect(result.status).toBe("fail"); - expect(result.message).toContain("Stale"); - expect(result.message).toContain("ins_old"); - expect(result.fix).toBeDefined(); + expectCheck(result, { + name: "Instances", + status: "fail", + message: ["Stale", "ins_old"], + fix: true, + }); }); test("warn when not authenticated", async () => { const ctx = createMockContext({ token: null }); const result = await checkInstances(ctx); - expect(result.status).toBe("warn"); - expect(result.message).toContain("Skipped"); + expectCheck(result, { name: "Instances", status: "warn", message: "Skipped" }); }); test("warn when no project linked", async () => { const ctx = createMockContext({ token: "test_token" }); const result = await checkInstances(ctx); - expect(result.status).toBe("warn"); - expect(result.message).toContain("Skipped"); + expectCheck(result, { name: "Instances", status: "warn", message: "Skipped" }); }); }); @@ -326,17 +374,15 @@ describe("checkEnvVars", () => { ); const ctx = createMockContext({ token: "test_token", - profile: { - path: "github.com/org/repo", - profile: { workspaceId: "org_1", appId: "app_1", instances: { development: "ins_dev" } }, - resolvedVia: "remote", - }, + profile: mockProfile, application: mockApplication, }); const result = await checkEnvVars(ctx); - expect(result.status).toBe("pass"); - expect(result.message).toContain("CLERK_PUBLISHABLE_KEY"); - expect(result.message).toContain("development instance"); + expectCheck(result, { + name: "Environment variables", + status: "pass", + message: ["CLERK_PUBLISHABLE_KEY", "development instance"], + }); }); test("pass without environment label when app not available", async () => { @@ -346,10 +392,12 @@ describe("checkEnvVars", () => { ); const ctx = createMockContext(); const result = await checkEnvVars(ctx); - expect(result.status).toBe("pass"); - expect(result.message).toContain("CLERK_PUBLISHABLE_KEY"); - expect(result.message).toContain("CLERK_SECRET_KEY"); - expect(result.message).not.toContain("instance"); + expectCheck(result, { + name: "Environment variables", + status: "pass", + message: ["CLERK_PUBLISHABLE_KEY", "CLERK_SECRET_KEY"], + messageNot: "instance", + }); }); test("identifies environment via secret key when publishable key doesn't match", async () => { @@ -359,16 +407,15 @@ describe("checkEnvVars", () => { ); const ctx = createMockContext({ token: "test_token", - profile: { - path: "github.com/org/repo", - profile: { workspaceId: "org_1", appId: "app_1", instances: { development: "ins_dev" } }, - resolvedVia: "remote", - }, + profile: mockProfile, application: mockApplication, }); const result = await checkEnvVars(ctx); - expect(result.status).toBe("pass"); - expect(result.message).toContain("development instance"); + expectCheck(result, { + name: "Environment variables", + status: "pass", + message: "development instance", + }); }); test("pass without environment label when neither key matches any instance", async () => { @@ -378,16 +425,15 @@ describe("checkEnvVars", () => { ); const ctx = createMockContext({ token: "test_token", - profile: { - path: "github.com/org/repo", - profile: { workspaceId: "org_1", appId: "app_1", instances: { development: "ins_dev" } }, - resolvedVia: "remote", - }, + profile: mockProfile, application: mockApplication, }); const result = await checkEnvVars(ctx); - expect(result.status).toBe("pass"); - expect(result.message).not.toContain("instance"); + expectCheck(result, { + name: "Environment variables", + status: "pass", + messageNot: "instance", + }); }); test("detects framework-specific key name for Next.js", async () => { @@ -401,17 +447,15 @@ describe("checkEnvVars", () => { ); const ctx = createMockContext({ token: "test_token", - profile: { - path: "github.com/org/repo", - profile: { workspaceId: "org_1", appId: "app_1", instances: { development: "ins_dev" } }, - resolvedVia: "remote", - }, + profile: mockProfile, application: mockApplication, }); const result = await checkEnvVars(ctx); - expect(result.status).toBe("pass"); - expect(result.message).toContain("NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY"); - expect(result.message).toContain("development instance"); + expectCheck(result, { + name: "Environment variables", + status: "pass", + message: ["NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY", "development instance"], + }); }); test("pass without environment label when getApplication throws", async () => { @@ -423,8 +467,11 @@ describe("checkEnvVars", () => { applicationError: new Error("Network timeout"), }); const result = await checkEnvVars(ctx); - expect(result.status).toBe("pass"); - expect(result.message).not.toContain("instance"); + expectCheck(result, { + name: "Environment variables", + status: "pass", + messageNot: "instance", + }); }); test("falls back to .env when .env.local does not exist", async () => { @@ -434,26 +481,35 @@ describe("checkEnvVars", () => { ); const ctx = createMockContext({ application: mockApplication }); const result = await checkEnvVars(ctx); - expect(result.status).toBe("pass"); - expect(result.message).toContain(".env contains"); + expectCheck(result, { + name: "Environment variables", + status: "pass", + message: ".env contains", + }); }); test("warn when keys missing", async () => { await Bun.write(join(tempDir, ".env.local"), "OTHER=value\n"); const ctx = createMockContext(); const result = await checkEnvVars(ctx); - expect(result.status).toBe("warn"); - expect(result.message).toContain("missing"); - expect(result.remedy).toContain("clerk env pull"); - expect(result.fix).toBeDefined(); + expectCheck(result, { + name: "Environment variables", + status: "warn", + message: "missing", + remedy: "clerk env pull", + fix: true, + }); }); test("warn when no env file", async () => { const ctx = createMockContext(); const result = await checkEnvVars(ctx); - expect(result.status).toBe("warn"); - expect(result.message).toContain("No .env.local or .env file found"); - expect(result.fix).toBeDefined(); + expectCheck(result, { + name: "Environment variables", + status: "warn", + message: "No .env.local or .env file found", + fix: true, + }); }); }); @@ -466,18 +522,23 @@ describe("checkConfigFile", () => { ); const ctx = createMockContext(); const result = await checkConfigFile(ctx); - expect(result.status).toBe("pass"); - expect(result.message).toContain("valid"); - expect(result.message).toContain("1 profile"); + expectCheck(result, { + name: "CLI configuration", + status: "pass", + message: ["valid", "1 profile"], + }); }); test("warn when config file does not exist", async () => { process.env.CLERK_CONFIG_DIR = join(tempDir, "nonexistent"); const ctx = createMockContext(); const result = await checkConfigFile(ctx); - expect(result.status).toBe("warn"); - expect(result.message).toContain("does not exist"); - expect(result.fix).toBeDefined(); + expectCheck(result, { + name: "CLI configuration", + status: "warn", + message: "does not exist", + fix: true, + }); }); test("fail when config has invalid JSON", async () => { @@ -485,8 +546,11 @@ describe("checkConfigFile", () => { await Bun.write(join(tempDir, "config.json"), "{ invalid json }"); const ctx = createMockContext(); const result = await checkConfigFile(ctx); - expect(result.status).toBe("fail"); - expect(result.message).toContain("failed to parse"); - expect(result.fix).toBeDefined(); + expectCheck(result, { + name: "CLI configuration", + status: "fail", + message: "failed to parse", + fix: true, + }); }); }); diff --git a/src/commands/doctor/index.ts b/src/commands/doctor/index.ts index 4312994df..148825a58 100644 --- a/src/commands/doctor/index.ts +++ b/src/commands/doctor/index.ts @@ -68,8 +68,9 @@ export async function doctor(options: DoctorOptions = {}): Promise { const seen = new Set(); const uniqueFixable = fixable.filter((r) => { - if (seen.has(r.fix!.label)) return false; - seen.add(r.fix!.label); + const label = r.fix?.label; + if (!label || seen.has(label)) return false; + seen.add(label); return true; }); @@ -81,14 +82,16 @@ export async function doctor(options: DoctorOptions = {}): Promise { const { confirm } = await import("@inquirer/prompts"); for (const result of uniqueFixable) { + const fix = result.fix; + if (!fix) continue; const proceed = await confirm({ - message: `Fix "${result.name}"? (${result.fix!.label})`, + message: `Fix "${result.name}"? (${fix.label})`, default: true, }); if (proceed) { try { - await result.fix!.run(); + await fix.run(); console.log(` ${green("✓")} ${result.name} fixed`); } catch (error) { console.log(` ${red("✗")} Fix failed: ${(error as Error).message}`); From 29b6c0d51b447c9f4f06f7d20e638ffe47d64889 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Wed, 11 Mar 2026 14:09:41 -0300 Subject: [PATCH 7/9] fix: align doctor command with centralized error handling patterns Replace process.exit(1) calls with CliError throws to comply with the unicorn/no-process-exit lint rule and the centralized error handler in cli.ts. Import PlapiError from its canonical source (errors.ts) and remove duplicate red export in color.ts. --- src/commands/doctor/checks.ts | 2 +- src/commands/doctor/doctor.test.ts | 2 +- src/commands/doctor/index.ts | 5 +++-- src/lib/color.ts | 1 - 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/commands/doctor/checks.ts b/src/commands/doctor/checks.ts index a2bacf95d..87a59e081 100644 --- a/src/commands/doctor/checks.ts +++ b/src/commands/doctor/checks.ts @@ -1,7 +1,7 @@ import { join } from "node:path"; import { homedir } from "node:os"; import { fetchUserInfo } from "../../lib/token-exchange.ts"; -import { PlapiError } from "../../lib/plapi.ts"; +import { PlapiError } from "../../lib/errors.ts"; import { detectPublishableKeyName } from "../../lib/framework.ts"; import { parseEnvFile } from "../../lib/dotenv.ts"; import type { CheckResult, DoctorContext, FixAction } from "./types.ts"; diff --git a/src/commands/doctor/doctor.test.ts b/src/commands/doctor/doctor.test.ts index ab9aceb19..7b1ef7471 100644 --- a/src/commands/doctor/doctor.test.ts +++ b/src/commands/doctor/doctor.test.ts @@ -254,7 +254,7 @@ describe("checkLinkedAppExists", () => { }); test("fail when app not found (404)", async () => { - const { PlapiError } = await import("../../lib/plapi.ts"); + const { PlapiError } = await import("../../lib/errors.ts"); const ctx = createMockContext({ token: "test_token", profile: mockProfile, diff --git a/src/commands/doctor/index.ts b/src/commands/doctor/index.ts index 148825a58..f1bec9527 100644 --- a/src/commands/doctor/index.ts +++ b/src/commands/doctor/index.ts @@ -1,5 +1,6 @@ import { isHuman } from "../../mode.ts"; import { bold, green, red } from "../../lib/color.ts"; +import { CliError } from "../../lib/errors.ts"; import { createDoctorContext } from "./context.ts"; import { checkLoggedIn, @@ -112,7 +113,7 @@ export async function doctor(options: DoctorOptions = {}): Promise { const hasVerifyFailure = verifyResults.some((r) => r.status === "fail"); if (hasVerifyFailure) { - process.exit(1); + throw new CliError("Some checks still failing after auto-fix."); } return; } @@ -120,6 +121,6 @@ export async function doctor(options: DoctorOptions = {}): Promise { const hasFailure = allResults.some((r) => r.status === "fail"); if (hasFailure) { - process.exit(1); + throw new CliError("Doctor found issues with your Clerk integration."); } } diff --git a/src/lib/color.ts b/src/lib/color.ts index 05fd9f807..b0ec13591 100644 --- a/src/lib/color.ts +++ b/src/lib/color.ts @@ -5,4 +5,3 @@ export const green = (s: string) => `\x1b[32m${s}\x1b[0m`; export const yellow = (s: string) => `\x1b[33m${s}\x1b[0m`; export const red = (s: string) => `\x1b[31m${s}\x1b[0m`; export const blue = (s: string) => `\x1b[34m${s}\x1b[0m`; -export const red = (s: string) => `\x1b[31m${s}\x1b[0m`; From e93918033bf69f04a5c312350321ddde17047def Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Wed, 11 Mar 2026 14:58:08 -0300 Subject: [PATCH 8/9] refactor(doctor): improve check names and messages for clarity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename check titles to be user-focused rather than implementation-focused: - "Authentication token" → "Logged in" - "Token validity" → "Authentication valid" - "Project linkage" → "Project linked" - "Linked application" → "Application reachable" - "Instances" → "Instance IDs" Improve messages to be more explicit about what's happening: - App reachability pass now includes app ID - 404 fail says "not found on Clerk" with actionable remedy - Network failures say "Could not reach Clerk" - Stale instances say "mismatch ... not found in application" - Remove redundant app ID from project link (shown in app check) --- src/commands/doctor/checks.ts | 30 ++++++++-------- src/commands/doctor/doctor.test.ts | 55 ++++++++++++++++-------------- 2 files changed, 45 insertions(+), 40 deletions(-) diff --git a/src/commands/doctor/checks.ts b/src/commands/doctor/checks.ts index 87a59e081..db9f8eaa6 100644 --- a/src/commands/doctor/checks.ts +++ b/src/commands/doctor/checks.ts @@ -56,18 +56,18 @@ function defineCheck(name: string, fixFactory?: () => FixAction): CheckBuilder { } export async function checkLoggedIn(ctx: DoctorContext): Promise { - const check = defineCheck("Authentication token", ctx.fixes.login); + const check = defineCheck("Logged in", ctx.fixes.login); const token = await ctx.getToken(); if (!token) { return check.fail("Not logged in", { remedy: "Run `clerk auth login` to authenticate.", }); } - return check.pass("Token found in credential store"); + return check.pass("Logged in (token found in credential store)"); } export async function checkTokenValid(ctx: DoctorContext): Promise { - const check = defineCheck("Token validity", ctx.fixes.login); + const check = defineCheck("Authentication valid", ctx.fixes.login); const token = await ctx.getToken(); if (!token) return check.skip("no token"); @@ -82,7 +82,7 @@ export async function checkTokenValid(ctx: DoctorContext): Promise }); } - return check.warn("Could not verify token — network issue", { + return check.warn("Could not reach Clerk to verify authentication — network issue", { detail: "Your stored token from a previous login is likely still valid. " + "The auth server was unreachable.", @@ -93,7 +93,7 @@ export async function checkTokenValid(ctx: DoctorContext): Promise } export async function checkProjectLinked(ctx: DoctorContext): Promise { - const check = defineCheck("Project linkage", ctx.fixes.link); + const check = defineCheck("Project linked", ctx.fixes.link); const resolved = await ctx.getProfile(); if (!resolved) { return check.fail("Not linked to a Clerk application", { @@ -109,13 +109,13 @@ export async function checkProjectLinked(ctx: DoctorContext): Promise { - const check = defineCheck("Linked application", ctx.fixes.link); + const check = defineCheck("Application reachable", ctx.fixes.link); const token = await ctx.getToken(); if (!token) return check.skip("not authenticated"); @@ -126,15 +126,15 @@ export async function checkLinkedAppExists(ctx: DoctorContext): Promise { - const check = defineCheck("Instances", ctx.fixes.link); + const check = defineCheck("Instance IDs", ctx.fixes.link); const token = await ctx.getToken(); if (!token) return check.skip("not authenticated"); @@ -178,19 +178,19 @@ export async function checkInstances(ctx: DoctorContext): Promise { } if (stale.length > 0) { - return check.fail(`Stale instance ID: ${stale.join(", ")}`, { + return check.fail(`Instance ID mismatch: ${stale.join(", ")} not found in application`, { remedy: "Run `clerk link` to re-link with valid instances, or `clerk unlink` and `clerk link` to start fresh.", }); } if (!prodId) { - return check.warn(`Instances: ${parts.join(", ")} (production not configured)`, { + return check.warn(`${parts.join(", ")} (production not configured)`, { detail: "Production instance is optional but recommended for deployment.", }); } - return check.pass(`Instances: ${parts.join(", ")}`); + return check.pass(parts.join(", ")); } catch (error) { return check.fail(`Could not verify instances: ${(error as Error).message}`, { remedy: "Check your network connection and authentication.", diff --git a/src/commands/doctor/doctor.test.ts b/src/commands/doctor/doctor.test.ts index 7b1ef7471..8d8e3bfa0 100644 --- a/src/commands/doctor/doctor.test.ts +++ b/src/commands/doctor/doctor.test.ts @@ -157,14 +157,14 @@ describe("checkLoggedIn", () => { test("pass when token exists", async () => { const ctx = createMockContext({ token: "test_token" }); const result = await checkLoggedIn(ctx); - expectCheck(result, { name: "Authentication token", status: "pass", message: "Token found" }); + expectCheck(result, { name: "Logged in", status: "pass", message: "Logged in" }); }); test("fail when no token", async () => { const ctx = createMockContext({ token: null }); const result = await checkLoggedIn(ctx); expectCheck(result, { - name: "Authentication token", + name: "Logged in", status: "fail", remedy: "clerk auth login", fix: true, @@ -177,7 +177,11 @@ describe("checkTokenValid", () => { mockUserInfo = { userId: "user_1", email: "dev@example.com" }; const ctx = createMockContext({ token: "test_token" }); const result = await checkTokenValid(ctx); - expectCheck(result, { name: "Token validity", status: "pass", message: "dev@example.com" }); + expectCheck(result, { + name: "Authentication valid", + status: "pass", + message: "dev@example.com", + }); }); test("fail when token is expired (401)", async () => { @@ -185,7 +189,7 @@ describe("checkTokenValid", () => { const ctx = createMockContext({ token: "expired_token" }); const result = await checkTokenValid(ctx); expectCheck(result, { - name: "Token validity", + name: "Authentication valid", status: "fail", message: "expired or invalid", remedy: "clerk auth login", @@ -198,9 +202,9 @@ describe("checkTokenValid", () => { const ctx = createMockContext({ token: "test_token" }); const result = await checkTokenValid(ctx); expectCheck(result, { - name: "Token validity", + name: "Authentication valid", status: "warn", - message: "network issue", + message: ["Could not reach Clerk", "network issue"], detail: "likely still valid", fix: false, }); @@ -209,7 +213,7 @@ describe("checkTokenValid", () => { test("warn+skip when no token", async () => { const ctx = createMockContext({ token: null }); const result = await checkTokenValid(ctx); - expectCheck(result, { name: "Token validity", status: "warn", message: "Skipped" }); + expectCheck(result, { name: "Authentication valid", status: "warn", message: "Skipped" }); }); }); @@ -220,9 +224,9 @@ describe("checkProjectLinked", () => { }); const result = await checkProjectLinked(ctx); expectCheck(result, { - name: "Project linkage", + name: "Project linked", status: "pass", - message: ["app_1", "via git remote"], + message: ["Linked", "via git remote"], }); }); @@ -230,7 +234,7 @@ describe("checkProjectLinked", () => { const ctx = createMockContext(); const result = await checkProjectLinked(ctx); expectCheck(result, { - name: "Project linkage", + name: "Project linked", status: "fail", remedy: "clerk link", fix: true, @@ -239,7 +243,7 @@ describe("checkProjectLinked", () => { }); describe("checkLinkedAppExists", () => { - test("pass when app is accessible", async () => { + test("pass when app is reachable", async () => { const ctx = createMockContext({ token: "test_token", profile: mockProfile, @@ -247,9 +251,9 @@ describe("checkLinkedAppExists", () => { }); const result = await checkLinkedAppExists(ctx); expectCheck(result, { - name: "Linked application", + name: "Application reachable", status: "pass", - message: "My App", + message: ["My App", "app_1", "is reachable"], }); }); @@ -262,9 +266,10 @@ describe("checkLinkedAppExists", () => { }); const result = await checkLinkedAppExists(ctx); expectCheck(result, { - name: "Linked application", + name: "Application reachable", status: "fail", - message: "not found", + message: "not found on Clerk", + remedy: "doesn't exist or may have been deleted", fix: true, }); }); @@ -277,9 +282,9 @@ describe("checkLinkedAppExists", () => { }); const result = await checkLinkedAppExists(ctx); expectCheck(result, { - name: "Linked application", + name: "Application reachable", status: "fail", - message: "Could not verify application", + message: "Could not reach Clerk to verify application", fix: false, }); }); @@ -287,13 +292,13 @@ describe("checkLinkedAppExists", () => { test("warn when not authenticated", async () => { const ctx = createMockContext({ token: null }); const result = await checkLinkedAppExists(ctx); - expectCheck(result, { name: "Linked application", status: "warn", message: "Skipped" }); + expectCheck(result, { name: "Application reachable", status: "warn", message: "Skipped" }); }); test("warn when no project linked", async () => { const ctx = createMockContext({ token: "test_token" }); const result = await checkLinkedAppExists(ctx); - expectCheck(result, { name: "Linked application", status: "warn", message: "Skipped" }); + expectCheck(result, { name: "Application reachable", status: "warn", message: "Skipped" }); }); }); @@ -314,7 +319,7 @@ describe("checkInstances", () => { }); const result = await checkInstances(ctx); expectCheck(result, { - name: "Instances", + name: "Instance IDs", status: "pass", message: ["ins_dev", "ins_prod"], }); @@ -328,7 +333,7 @@ describe("checkInstances", () => { }); const result = await checkInstances(ctx); expectCheck(result, { - name: "Instances", + name: "Instance IDs", status: "warn", message: "production not configured", }); @@ -346,9 +351,9 @@ describe("checkInstances", () => { }); const result = await checkInstances(ctx); expectCheck(result, { - name: "Instances", + name: "Instance IDs", status: "fail", - message: ["Stale", "ins_old"], + message: ["mismatch", "ins_old", "not found in application"], fix: true, }); }); @@ -356,13 +361,13 @@ describe("checkInstances", () => { test("warn when not authenticated", async () => { const ctx = createMockContext({ token: null }); const result = await checkInstances(ctx); - expectCheck(result, { name: "Instances", status: "warn", message: "Skipped" }); + expectCheck(result, { name: "Instance IDs", status: "warn", message: "Skipped" }); }); test("warn when no project linked", async () => { const ctx = createMockContext({ token: "test_token" }); const result = await checkInstances(ctx); - expectCheck(result, { name: "Instances", status: "warn", message: "Skipped" }); + expectCheck(result, { name: "Instance IDs", status: "warn", message: "Skipped" }); }); }); From 1d725316d0876cb10de6b1f96c3a2745d9da3bca Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Wed, 11 Mar 2026 14:59:58 -0300 Subject: [PATCH 9/9] fix(doctor): restore Instance IDs prefix in check messages The check name isn't shown in terminal output, so the prefix is needed for context. --- src/commands/doctor/checks.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/doctor/checks.ts b/src/commands/doctor/checks.ts index db9f8eaa6..8f7b56d4b 100644 --- a/src/commands/doctor/checks.ts +++ b/src/commands/doctor/checks.ts @@ -185,12 +185,12 @@ export async function checkInstances(ctx: DoctorContext): Promise { } if (!prodId) { - return check.warn(`${parts.join(", ")} (production not configured)`, { + return check.warn(`Instance IDs: ${parts.join(", ")} (production not configured)`, { detail: "Production instance is optional but recommended for deployment.", }); } - return check.pass(parts.join(", ")); + return check.pass(`Instance IDs: ${parts.join(", ")}`); } catch (error) { return check.fail(`Could not verify instances: ${(error as Error).message}`, { remedy: "Check your network connection and authentication.",