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 ``` 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") diff --git a/src/commands/doctor/README.md b/src/commands/doctor/README.md new file mode 100644 index 000000000..582125f12 --- /dev/null +++ b/src/commands/doctor/README.md @@ -0,0 +1,93 @@ +# 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 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 | + +## 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..8f7b56d4b --- /dev/null +++ b/src/commands/doctor/checks.ts @@ -0,0 +1,315 @@ +import { join } from "node:path"; +import { homedir } from "node:os"; +import { fetchUserInfo } from "../../lib/token-exchange.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"; + +const AUTH_ERROR_STATUS = /\((401|403)\)/; + +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, + status, + message, + ...(detail && { detail }), + ...(remedy && { remedy }), + ...(fixable && fixFactory && { fix: fixFactory() }), + }; + } + + return { + 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 checkLoggedIn(ctx: DoctorContext): Promise { + 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("Logged in (token found in credential store)"); +} + +export async function checkTokenValid(ctx: DoctorContext): Promise { + const check = defineCheck("Authentication valid", ctx.fixes.login); + const token = await ctx.getToken(); + if (!token) return check.skip("no token"); + + try { + const userInfo = await fetchUserInfo(token); + return check.pass(`Authenticated as ${userInfo.email}`); + } catch (error) { + const message = (error as Error).message ?? ""; + if (AUTH_ERROR_STATUS.test(message)) { + return check.fail("Token is expired or invalid", { + remedy: "Run `clerk auth login` to re-authenticate.", + }); + } + + 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.", + remedy: + "Check your network connection. If issues persist, run `clerk auth login` to re-authenticate.", + }); + } +} + +export async function checkProjectLinked(ctx: DoctorContext): Promise { + const check = defineCheck("Project linked", ctx.fixes.link); + const resolved = await ctx.getProfile(); + if (!resolved) { + return check.fail("Not linked to a Clerk application", { + remedy: "Run `clerk link` to associate this project with a Clerk app.", + }); + } + + 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 check.pass( + `Linked ${via}`, + `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 check = defineCheck("Application reachable", ctx.fixes.link); + const token = await ctx.getToken(); + if (!token) return check.skip("not authenticated"); + + const resolved = await ctx.getProfile(); + if (!resolved) return check.skip("no project linked"); + + try { + const app = await ctx.getApplication(); + if (!app) return check.skip("could not fetch application"); + const label = app.name || app.application_id; + return check.pass(`Application "${label}" (${app.application_id}) is reachable`); + } catch (error) { + if (error instanceof PlapiError && error.status === 404) { + return check.fail(`Application ${resolved.profile.appId} not found on Clerk`, { + remedy: + "The application doesn't exist or may have been deleted from the Clerk Dashboard. Run `clerk link` to link to a different application, or `clerk unlink` to remove the stale link.", + }); + } + return check.fail(`Could not reach Clerk to 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("Instance IDs", ctx.fixes.link); + const token = await ctx.getToken(); + if (!token) return check.skip("not authenticated"); + + const resolved = await ctx.getProfile(); + if (!resolved) return check.skip("no project linked"); + + try { + const app = await ctx.getApplication(); + 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; + 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 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(`Instance IDs: ${parts.join(", ")} (production not configured)`, { + detail: "Production instance is optional but recommended for deployment.", + }); + } + + 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.", + fixable: false, + }); + } +} + +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 candidate of candidates) { + const filePath = join(cwd, candidate); + const file = Bun.file(filePath); + if (await file.exists()) { + foundFile = candidate; + 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 check.warn("No .env.local or .env file found", { + remedy: "Run `clerk env pull` to create one with your Clerk keys.", + fixable: true, + }); + } + + 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 check.warn(`${foundFile} is missing: ${missing.join(", ")}`, { + remedy: "Run `clerk env pull` to populate your environment variables.", + fixable: true, + }); + } + + const envLabel = await identifyEnvironment( + ctx, + entries[publishableKeyName]!, + entries["CLERK_SECRET_KEY"]!, + ); + + if (envLabel) { + return check.pass( + `${foundFile} contains ${publishableKeyName} and CLERK_SECRET_KEY (${envLabel} instance)`, + ); + } + + return check.pass(`${foundFile} contains ${publishableKeyName} and CLERK_SECRET_KEY`); +} + +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; +} + +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 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.", + fixable: true, + }); + } + + try { + const config = (await file.json()) as { + profiles?: Record; + auth?: unknown; + }; + const profileCount = Object.keys(config.profiles ?? {}).length; + const hasAuth = !!config.auth; + return check.pass( + `${configFile} is valid (${profileCount} profile${profileCount !== 1 ? "s" : ""}, auth: ${hasAuth ? "yes" : "no"})`, + ); + } catch (error) { + 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\`.`, + }); + } +} + +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..8d8e3bfa0 --- /dev/null +++ b/src/commands/doctor/doctor.test.ts @@ -0,0 +1,561 @@ +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 { CheckResult, CheckStatus, 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, + 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 () => {} }); + +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; + 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, + }, + }; +} + +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; + 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); + 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: "Logged in", + status: "fail", + remedy: "clerk auth login", + fix: true, + }); + }); +}); + +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); + expectCheck(result, { + name: "Authentication valid", + 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); + expectCheck(result, { + name: "Authentication valid", + 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); + expectCheck(result, { + name: "Authentication valid", + status: "warn", + message: ["Could not reach Clerk", "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); + expectCheck(result, { name: "Authentication valid", status: "warn", message: "Skipped" }); + }); +}); + +describe("checkProjectLinked", () => { + test("pass when profile exists", async () => { + const ctx = createMockContext({ + profile: mockProfile, + }); + const result = await checkProjectLinked(ctx); + expectCheck(result, { + name: "Project linked", + status: "pass", + message: ["Linked", "via git remote"], + }); + }); + + test("fail when no profile", async () => { + const ctx = createMockContext(); + const result = await checkProjectLinked(ctx); + expectCheck(result, { + name: "Project linked", + status: "fail", + remedy: "clerk link", + fix: true, + }); + }); +}); + +describe("checkLinkedAppExists", () => { + test("pass when app is reachable", async () => { + const ctx = createMockContext({ + token: "test_token", + profile: mockProfile, + application: mockApplication, + }); + const result = await checkLinkedAppExists(ctx); + expectCheck(result, { + name: "Application reachable", + status: "pass", + message: ["My App", "app_1", "is reachable"], + }); + }); + + test("fail when app not found (404)", async () => { + const { PlapiError } = await import("../../lib/errors.ts"); + const ctx = createMockContext({ + token: "test_token", + profile: mockProfile, + applicationError: new PlapiError(404, "Not found"), + }); + const result = await checkLinkedAppExists(ctx); + expectCheck(result, { + name: "Application reachable", + status: "fail", + message: "not found on Clerk", + remedy: "doesn't exist or may have been deleted", + fix: true, + }); + }); + + test("fail with generic error on non-404", async () => { + const ctx = createMockContext({ + token: "test_token", + profile: mockProfile, + applicationError: new Error("Connection timeout"), + }); + const result = await checkLinkedAppExists(ctx); + expectCheck(result, { + name: "Application reachable", + status: "fail", + message: "Could not reach Clerk to verify application", + fix: false, + }); + }); + + test("warn when not authenticated", async () => { + const ctx = createMockContext({ token: null }); + const result = await checkLinkedAppExists(ctx); + 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: "Application reachable", status: "warn", message: "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); + expectCheck(result, { + name: "Instance IDs", + status: "pass", + message: ["ins_dev", "ins_prod"], + }); + }); + + test("warn when production not configured", async () => { + const ctx = createMockContext({ + token: "test_token", + profile: mockProfile, + application: mockApplication, + }); + const result = await checkInstances(ctx); + expectCheck(result, { + name: "Instance IDs", + status: "warn", + message: "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); + expectCheck(result, { + name: "Instance IDs", + status: "fail", + message: ["mismatch", "ins_old", "not found in application"], + fix: true, + }); + }); + + test("warn when not authenticated", async () => { + const ctx = createMockContext({ token: null }); + const result = await checkInstances(ctx); + 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: "Instance IDs", status: "warn", message: "Skipped" }); + }); +}); + +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: mockProfile, + application: mockApplication, + }); + const result = await checkEnvVars(ctx); + expectCheck(result, { + name: "Environment variables", + status: "pass", + message: ["CLERK_PUBLISHABLE_KEY", "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); + 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 () => { + 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: mockProfile, + application: mockApplication, + }); + const result = await checkEnvVars(ctx); + expectCheck(result, { + name: "Environment variables", + status: "pass", + message: "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: mockProfile, + application: mockApplication, + }); + const result = await checkEnvVars(ctx); + expectCheck(result, { + name: "Environment variables", + status: "pass", + messageNot: "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: mockProfile, + application: mockApplication, + }); + const result = await checkEnvVars(ctx); + expectCheck(result, { + name: "Environment variables", + status: "pass", + message: ["NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY", "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); + expectCheck(result, { + name: "Environment variables", + status: "pass", + messageNot: "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); + 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); + 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); + expectCheck(result, { + name: "Environment variables", + status: "warn", + message: "No .env.local or .env file found", + fix: true, + }); + }); +}); + +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); + 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); + expectCheck(result, { + name: "CLI configuration", + status: "warn", + message: "does not exist", + fix: true, + }); + }); + + 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); + expectCheck(result, { + name: "CLI configuration", + status: "fail", + message: "failed to parse", + fix: true, + }); + }); +}); 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..f1bec9527 --- /dev/null +++ b/src/commands/doctor/index.ts @@ -0,0 +1,126 @@ +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, + checkTokenValid, + checkProjectLinked, + checkLinkedAppExists, + checkInstances, + 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, + 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) => { + const label = r.fix?.label; + if (!label || seen.has(label)) return false; + seen.add(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 fix = result.fix; + if (!fix) continue; + const proceed = await confirm({ + message: `Fix "${result.name}"? (${fix.label})`, + default: true, + }); + + if (proceed) { + try { + await 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) { + throw new CliError("Some checks still failing after auto-fix."); + } + return; + } + } + + const hasFailure = allResults.some((r) => r.status === "fail"); + if (hasFailure) { + throw new CliError("Doctor found issues with your Clerk integration."); + } +} 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; +} diff --git a/src/lib/color.ts b/src/lib/color.ts index f31a90258..b0ec13591 100644 --- a/src/lib/color.ts +++ b/src/lib/color.ts @@ -3,5 +3,5 @@ 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 blue = (s: string) => `\x1b[34m${s}\x1b[0m`; export const red = (s: string) => `\x1b[31m${s}\x1b[0m`; +export const blue = (s: string) => `\x1b[34m${s}\x1b[0m`;