diff --git a/src/cli-program.ts b/src/cli-program.ts index 7a681758..aacb7100 100644 --- a/src/cli-program.ts +++ b/src/cli-program.ts @@ -1,5 +1,5 @@ import { Command } from "commander"; -import { setMode, type Mode } from "./mode.js"; +import { setMode, setJsonFlag, type Mode } from "./mode.js"; import { init } from "./commands/init/index.js"; import { login } from "./commands/auth/login.js"; import { logout } from "./commands/auth/logout.js"; @@ -23,11 +23,13 @@ export function createProgram(): Command { .name("clerk") .description("Clerk CLI") .version(require("../package.json").version) + .enablePositionalOptions() .option( "--mode ", "Force interaction mode (human or agent). Defaults to auto-detect based on TTY.", ) - .option("--verbose", "Show detailed error output"); + .option("--verbose", "Show detailed error output") + .option("--json", "Output structured JSON (same format as agent mode)"); program.hook("preAction", () => { const opts = program.opts(); @@ -37,6 +39,9 @@ export function createProgram(): Command { } setMode(opts.mode as Mode); } + if (opts.json) { + setJsonFlag(true); + } }); program.command("init").description("Initialize Clerk in your project").action(init); @@ -80,7 +85,10 @@ export function createProgram(): Command { .option("--file ", "Target env file (default: auto-detect)") .action(pull); - const config = program.command("config").description("Manage instance configuration"); + const config = program + .command("config") + .description("Manage instance configuration") + .enablePositionalOptions(); config .command("pull") diff --git a/src/commands/api/README.md b/src/commands/api/README.md index 09128842..11e36d16 100644 --- a/src/commands/api/README.md +++ b/src/commands/api/README.md @@ -103,7 +103,7 @@ Base URL: `https://api.clerk.com` (overridable via `CLERK_PLATFORM_API_URL`) Lists available API endpoints from the Clerk OpenAPI spec. - Fetches the spec from `clerk/openapi-specs` on GitHub -- Caches locally in `~/.clerk/cache/` for 24 hours +- Caches locally in the platform cache directory for 24 hours - Supports `--platform` to list Platform API endpoints - Optional filter keyword matches against path, summary, tag, and operation ID diff --git a/src/commands/deploy/index.test.ts b/src/commands/deploy/index.test.ts index 4d7ab155..f6212d11 100644 --- a/src/commands/deploy/index.test.ts +++ b/src/commands/deploy/index.test.ts @@ -1,5 +1,10 @@ import { test, expect, describe, afterEach, mock, spyOn } from "bun:test"; -import { capturedOutput, promptsStubs } from "../../test/stubs.ts"; +import { + capturedOutput, + configStubs, + credentialStoreStubs, + promptsStubs, +} from "../../test/stubs.ts"; const mockIsAgent = mock(); let _modeOverride: string | undefined; @@ -9,12 +14,25 @@ mock.module("../../mode.ts", () => ({ _modeOverride !== undefined ? _modeOverride === "agent" : mockIsAgent(...args), isHuman: (...args: unknown[]) => _modeOverride !== undefined ? _modeOverride !== "agent" : !mockIsAgent(...args), + isJSON: () => (_modeOverride !== undefined ? _modeOverride === "agent" : mockIsAgent()), setMode: (m: string) => { _modeOverride = m; }, getMode: () => _modeOverride ?? "human", })); +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), +})); + const mockSelect = mock(); const mockInput = mock(); const mockConfirm = mock(); @@ -30,12 +48,20 @@ mock.module("@inquirer/prompts", () => ({ const { deploy } = await import("./index.ts"); +const mockProfile = { + path: "github.com/org/repo", + profile: { workspaceId: "", appId: "app_xyz789", instances: { development: "ins_dev" } }, + resolvedVia: "remote" as const, +}; + describe("deploy", () => { let consoleSpy: ReturnType; afterEach(() => { _modeOverride = undefined; mockIsAgent.mockReset(); + mockGetToken.mockReset(); + mockResolveProfile.mockReset(); mockSelect.mockReset(); mockInput.mockReset(); mockConfirm.mockReset(); @@ -44,56 +70,61 @@ describe("deploy", () => { }); describe("agent mode", () => { - test("outputs deploy prompt and returns", async () => { - mockIsAgent.mockReturnValue(true); - consoleSpy = spyOn(console, "log").mockImplementation(() => {}); - - await deploy({}); - - expect(consoleSpy).toHaveBeenCalledTimes(1); - const output = consoleSpy.mock.calls[0][0] as string; - expect(output).toContain("deploying a Clerk application to production"); - }); - - test("prompt includes all deployment steps", async () => { - mockIsAgent.mockReturnValue(true); - consoleSpy = spyOn(console, "log").mockImplementation(() => {}); - - await deploy({}); - - const output = consoleSpy.mock.calls[0][0] as string; - expect(output).toContain("Prerequisites"); - expect(output).toContain("Verify Subscription Compatibility"); - expect(output).toContain("Choose a Production Domain"); - expect(output).toContain("Create the Production Instance"); - expect(output).toContain("Configure Social OAuth Providers"); - expect(output).toContain("Finalize"); - }); - - test("prompt includes API reference", async () => { + test("outputs structured JSON with pre-flight checks", async () => { mockIsAgent.mockReturnValue(true); + mockGetToken.mockResolvedValue("token"); + mockResolveProfile.mockResolvedValue(mockProfile); consoleSpy = spyOn(console, "log").mockImplementation(() => {}); await deploy({}); - const output = consoleSpy.mock.calls[0][0] as string; - expect(output).toContain("/v1/platform/applications"); - expect(output).toContain("instances/production/config"); - expect(output).toContain("instances/development/config"); + const jsonLine = consoleSpy.mock.calls.find((c: unknown[]) => { + try { + JSON.parse(c[0] as string); + return true; + } catch { + return false; + } + }); + expect(jsonLine).toBeDefined(); + const parsed = JSON.parse(jsonLine![0] as string); + expect(parsed.command).toBe("deploy"); + expect(parsed.checks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "authenticated", ok: true }), + expect.objectContaining({ name: "production_instance", ok: false }), + ]), + ); }); - test("prompt includes OAuth redirect URI pattern", async () => { + test("reports unauthenticated when no token", async () => { mockIsAgent.mockReturnValue(true); + mockGetToken.mockResolvedValue(null); consoleSpy = spyOn(console, "log").mockImplementation(() => {}); await deploy({}); - const output = consoleSpy.mock.calls[0][0] as string; - expect(output).toContain("accounts.{domain}/v1/oauth_callback"); + const jsonLine = consoleSpy.mock.calls.find((c: unknown[]) => { + try { + JSON.parse(c[0] as string); + return true; + } catch { + return false; + } + }); + expect(jsonLine).toBeDefined(); + const parsed = JSON.parse(jsonLine![0] as string); + expect(parsed.checks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "authenticated", ok: false, fix: "clerk auth login" }), + ]), + ); }); test("does not trigger interactive prompts", async () => { mockIsAgent.mockReturnValue(true); + mockGetToken.mockResolvedValue("token"); + mockResolveProfile.mockResolvedValue(mockProfile); consoleSpy = spyOn(console, "log").mockImplementation(() => {}); await deploy({ debug: true }); @@ -108,20 +139,29 @@ describe("deploy", () => { describe("human mode", () => { function mockHumanFlow() { mockIsAgent.mockReturnValue(false); + mockGetToken.mockResolvedValue("token"); + mockResolveProfile.mockResolvedValue(mockProfile); // Domain selection → OAuth credential choice mockSelect.mockResolvedValueOnce("clerk-subdomain").mockResolvedValueOnce("have-credentials"); mockInput.mockResolvedValueOnce("fake-client-id-12345"); mockPassword.mockResolvedValueOnce("fake-secret"); } - test("does not print deploy prompt", async () => { + test("does not print JSON on dispose", async () => { mockHumanFlow(); consoleSpy = spyOn(console, "log").mockImplementation(() => {}); await deploy({}); - const allOutput = capturedOutput(consoleSpy); - expect(allOutput).not.toContain("deploying a Clerk application to production"); + const jsonLine = consoleSpy.mock.calls.find((c: unknown[]) => { + try { + JSON.parse(c[0] as string); + return true; + } catch { + return false; + } + }); + expect(jsonLine).toBeUndefined(); }); test("shows mock banner", async () => { diff --git a/src/commands/deploy/index.ts b/src/commands/deploy/index.ts index a8fc4319..9974e949 100644 --- a/src/commands/deploy/index.ts +++ b/src/commands/deploy/index.ts @@ -1,96 +1,41 @@ import { select, input, confirm, password } from "@inquirer/prompts"; import { isAgent } from "../../mode.js"; import { dim, bold, cyan, green, blue, yellow } from "../../lib/color.js"; +import { createCommandOutput } from "../../lib/cli.js"; +import { getToken } from "../../lib/credential-store.js"; +import { resolveProfile } from "../../lib/config.js"; -const DEPLOY_PROMPT = `You are deploying a Clerk application to production. Follow these steps: - -## Prerequisites - -Ensure the following before starting: -- The user is authenticated (\`clerk auth login\` has been run) -- A Clerk application is linked to the project (\`clerk link\` has been run) -- The project has a development instance with a working configuration - -## Step 1: Verify Subscription Compatibility - -Check that the development instance's features are covered by the application's subscription plan. - -- Fetch the development config: \`GET /v1/platform/applications/{appID}/instances/development/config\` -- Fetch the subscription: \`GET /v1/platform/applications/{appID}/subscription\` -- If any development features are not covered by the plan, the user must upgrade before deploying. - -## Step 2: Choose a Production Domain - -Ask the user which domain setup they prefer: - -**Option A: Custom domain** -- The user provides their own domain (e.g., example.com) -- DNS must be configured to point to Clerk. Check if the DNS provider supports Domain Connect for automatic setup. -- If Domain Connect is available, direct the user to the Domain Connect URL to authorize DNS changes. -- If not, provide the DNS records the user must add manually. -- Verify DNS propagation: \`POST /v1/platform/applications/{appID}/domains/{domainID}/dns_check\` - -**Option B: Clerk-provided subdomain** -- A subdomain like \`{adjective}-{animal}-{number}.clerk.app\` is automatically assigned. -- No DNS configuration is needed. - -## Step 3: Create the Production Instance - -Create or configure the production instance for the application. -- Add the domain: \`POST /v1/platform/applications/{appID}/domains\` with body \`{ "name": "", "is_satellite": false }\` -- Note: There is currently no dedicated endpoint to add a production instance to an existing app. This may require \`POST /v1/platform/applications\` with \`environment_types: ["development", "production"]\`. - -## Step 4: Configure Social OAuth Providers - -For each social provider enabled in the development instance (e.g., Google, GitHub, Apple), production OAuth credentials are required. - -Check the dev config for \`connection_oauth_*\` keys. For each enabled provider: - -1. Collect the required credentials from the user: - - Most providers: \`client_id\` and \`client_secret\` - - Apple: also requires \`key_id\` and \`team_id\` - -2. When helping the user create OAuth credentials, provide these values: - - Authorized JavaScript origins: \`https://{domain}\` and \`https://www.{domain}\` - - Authorized redirect URI: \`https://accounts.{domain}/v1/oauth_callback\` - -3. Write credentials to production config: - \`PATCH /v1/platform/applications/{appID}/instances/production/config\` - Body: \`{ "connection_oauth_{provider}": { "enabled": true, "client_id": "...", "client_secret": "..." } }\` - -Provider-specific documentation: https://clerk.com/docs/guides/configure/auth-strategies/social-connections/{provider} - -## Step 5: Finalize - -After all configuration is complete: -- Inform the user their production application is ready at \`https://{domain}\` -- Remind them to redeploy their application with the updated Clerk production secret keys -- They can pull production keys with: \`clerk env pull --instance prod\` - -## API Reference +export async function deploy(options: { debug?: boolean }) { + using out = createCommandOutput("deploy"); -| Method | Endpoint | Purpose | -|--------|----------|---------| -| GET | /v1/platform/applications/{appID} | Fetch application details | -| GET | .../instances/development/config | Read dev instance config and enabled features | -| GET | .../instances/production/config | Check if production instance exists (404 if not) | -| GET | .../subscription | Check subscription plan | -| POST | /v1/platform/applications | Create application with production instance | -| POST | .../domains | Add a custom domain | -| POST | .../domains/{domainID}/dns_check | Trigger DNS verification | -| PATCH | .../instances/production/config | Write OAuth credentials to production | + const debug = options.debug ? (...args: unknown[]) => console.log("[debug]", ...args) : () => {}; -Refer to the Clerk Platform API docs for detailed request/response schemas.`; + // Pre-flight: auth + link + const token = await getToken(); + out.add("authenticated", !!token, token ? "Logged in" : "Not authenticated", "clerk auth login"); + if (!token) return; + + const profile = await resolveProfile(process.cwd()); + out.add( + "linked", + !!profile, + profile ? `Linked to ${profile.profile.appId}` : "Not linked", + "clerk link", + ); + if (!profile) return; -export async function deploy(options: { debug?: boolean }) { if (isAgent()) { - console.log(DEPLOY_PROMPT); + // Agent mode: report pre-flight status only — the deploy wizard requires interactive input + out.add("subscription", true, "Check subscription compatibility before deploying"); + out.add("production_instance", false, "No production instance configured"); + out.suggest("clerk deploy (run interactively to complete setup)"); return; } - const debug = options.debug ? (...args: unknown[]) => console.log("[debug]", ...args) : () => {}; + + // ── Human interactive flow ─────────────────────────────────────────────── console.log( - yellow("[mock] This command uses mocked data and is not yet wired up to real APIs.") + "\n", + yellow(" [mock] This command uses mocked data and is not yet wired up to real APIs.") + "\n", ); debug("Checking for authenticated user and linked application..."); @@ -113,11 +58,11 @@ export async function deploy(options: { debug?: boolean }) { if (unsupported.length > 0) { debug(`Found features not covered by subscription: ${unsupported.join(", ")}`); - debug("User must upgrade their plan before deploying."); + out.add("subscription", false, `Features not covered: ${unsupported.join(", ")}`); return; } - debug("All development features are covered by subscription."); + out.add("subscription", true, "All dev features covered by plan"); const domainChoice = await select({ message: "How would you like to set up your production domain?", @@ -171,6 +116,8 @@ export async function deploy(options: { debug?: boolean }) { debug("Opening Domain Connect flow in browser..."); } + out.add("domain", true, `Production domain: ${domain}`); + // Check dev instance settings that require production credentials debug("Checking development instance settings for production requirements..."); @@ -231,11 +178,13 @@ export async function deploy(options: { debug?: boolean }) { debug(`Received ${displayName} credentials (client ID: ${clientId.slice(0, 8)}...)`); } - debug("All social provider credentials collected."); + out.add("oauth_credentials", true, "All provider credentials configured"); } debug("Deploy complete."); + out.add("deployed", true, `Ready at https://${domain}`); + console.log( `\n${bold(green(`Your production application is set up and ready at ${blue(`https://${domain}`)}`))}`, ); diff --git a/src/commands/env/README.md b/src/commands/env/README.md index 9504dffa..39b9736e 100644 --- a/src/commands/env/README.md +++ b/src/commands/env/README.md @@ -27,7 +27,7 @@ sequenceDiagram Note over CLI: clerk env pull [--instance dev|prod] [--file .env] %% Resolve project profile - CLI->>FS: Read ~/.clerk/config.json + CLI->>FS: Read config.json FS-->>CLI: { appId, instances } %% Fetch application with keys diff --git a/src/commands/init/README.md b/src/commands/init/README.md index 26595b28..51a1081c 100644 --- a/src/commands/init/README.md +++ b/src/commands/init/README.md @@ -35,6 +35,15 @@ The command detects the project's framework by checking `package.json` dependenc The package manager is detected from lock files (`bun.lockb` → bun, `yarn.lock` → yarn, `pnpm-lock.yaml` → pnpm, else npm). +## Post-Setup Recipes + +After completing the setup steps, the command outputs a framework-specific integration recipe with the code changes needed to finish the Clerk integration (middleware, providers, components). Recipes are bundled as static markdown files in `recipes/`. + +- **Human mode**: the recipe text is printed to the console after the check lines. +- **Agent mode**: the recipe is included as a `recipe` field in the TOON output, giving agents the full integration guide to follow. + +Supported frameworks: Next.js, React/Vite, Expo, Astro, Nuxt, TanStack Start, React Router, Fastify, Express, Vue. + ## API Endpoints See [auth/README.md](../auth/README.md), [link/README.md](../link/README.md), and [env/README.md](../env/README.md) for the API endpoints used by each step. diff --git a/src/commands/init/index.ts b/src/commands/init/index.ts index 9a151249..efd9c51f 100644 --- a/src/commands/init/index.ts +++ b/src/commands/init/index.ts @@ -3,73 +3,39 @@ import { login } from "../auth/login.js"; import { link } from "../link/index.js"; import { pull } from "../env/pull.js"; import { detectFramework } from "../../lib/framework.js"; -import { isAgent } from "../../mode.js"; -import { dim, cyan } from "../../lib/color.js"; - -const AGENT_PROMPT = `You are integrating Clerk authentication into an existing project. Follow these steps: - -1. Authenticate with Clerk: Run \`clerk auth login\` to log in via the browser. - -2. Link a Clerk application: Run \`clerk link\` to associate this directory with a Clerk application. - -3. Install the Clerk SDK appropriate for the project's framework: - - Next.js: \`@clerk/nextjs\` - - React: \`@clerk/clerk-react\` - - Express: \`@clerk/express\` - - Fastify: \`@clerk/fastify\` - - Astro: \`@clerk/astro\` - - Tanstack Start: \`@clerk/tanstack-start\` - - React Router: \`@clerk/react-router\` - - Nuxt: \`@clerk/nuxt\` - - Vue: \`@clerk/vue\` - -4. Add the environment variable NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY (or the equivalent for your framework) and CLERK_SECRET_KEY to the project's .env.local file. You can retrieve these with \`clerk env pull\`. - -5. Set up the Clerk provider at the root of the application: - - For Next.js: Wrap the app with \`\` in the root layout. - - For React: Wrap the app with \`\`. - - For Express/Fastify: Use the \`clerkMiddleware()\` middleware. - -6. Add sign-in and sign-up routes/components: - - Use \`\` and \`\` for trigger buttons. - - Use \`\` and \`\` for full-page components. - - Use \`\` to show the signed-in user's avatar and menu. - -7. Protect routes that require authentication: - - Next.js: Use \`clerkMiddleware()\` in \`middleware.ts\` and configure with \`createRouteMatcher\`. - - React: Use \`\` and \`\` components to conditionally render. - - Express/Fastify: Use \`requireAuth()\` middleware on protected routes. - -8. Access the current user: - - Client-side: \`useUser()\` hook returns the current user object. - - Server-side (Next.js): \`auth()\` or \`currentUser()\` from \`@clerk/nextjs/server\`. - - Express/Fastify: \`req.auth\` after applying \`clerkMiddleware()\`. - -Refer to the Clerk docs at https://clerk.com/docs for framework-specific details.`; - -async function detectPackageManager(cwd: string): Promise<{ cmd: string; add: string }> { - const checks: Array<{ files: string[]; cmd: string; add: string }> = [ - { files: ["bun.lockb", "bun.lock"], cmd: "bun", add: "bun add" }, - { files: ["yarn.lock"], cmd: "yarn", add: "yarn add" }, - { files: ["pnpm-lock.yaml"], cmd: "pnpm", add: "pnpm add" }, - ]; - - for (const { files, cmd, add } of checks) { +import { isHuman } from "../../mode.js"; +import { cyan } from "../../lib/color.js"; +import { createCommandOutput } from "../../lib/cli.js"; +import { getToken } from "../../lib/credential-store.js"; +import { resolveProfile } from "../../lib/config.js"; +import { CliError } from "../../lib/errors.js"; +import { getRecipe } from "./recipes/index.js"; + +// ── Package manager detection ────────────────────────────────────────────── + +const PM_CHECKS: Array<{ files: string[]; add: string }> = [ + { files: ["bun.lockb", "bun.lock"], add: "bun add" }, + { files: ["yarn.lock"], add: "yarn add" }, + { files: ["pnpm-lock.yaml"], add: "pnpm add" }, +]; + +/** Returns the install command for the detected package manager (e.g. "bun add", "npm install"). */ +async function detectPackageManager(cwd: string): Promise { + for (const { files, add } of PM_CHECKS) { for (const file of files) { if (await Bun.file(join(cwd, file)).exists()) { - return { cmd, add }; + return add; } } } - - return { cmd: "npm", add: "npm install" }; + return "npm install"; } async function installSdk(cwd: string, sdk: string, frameworkName: string): Promise { const pm = await detectPackageManager(cwd); - console.log(`Installing ${cyan(sdk)} for ${frameworkName}...`); + console.log(` Installing ${cyan(sdk)} for ${frameworkName}...`); - const proc = Bun.spawn(pm.add.split(" ").concat(sdk), { + const proc = Bun.spawn(pm.split(" ").concat(sdk), { cwd, stdout: "inherit", stderr: "inherit", @@ -77,34 +43,84 @@ async function installSdk(cwd: string, sdk: string, frameworkName: string): Prom const exitCode = await proc.exited; if (exitCode !== 0) { - console.error(`Failed to install ${sdk}. You can install it manually: ${pm.add} ${sdk}`); + console.error(` Failed to install ${sdk}. You can install it manually: ${pm} ${sdk}`); } } export async function init() { - if (isAgent()) { - console.log(AGENT_PROMPT); - return; - } + using out = createCommandOutput("init"); + const cwd = process.cwd(); - // Step 1: Authenticate the user - await login(); + if (isHuman()) { + // Human mode: run the full interactive flow (login/link throw on failure) + await login(); + out.add("authenticated", true, "Logged in"); - // Step 2: Link to a Clerk application - await link({ skipIfLinked: true }); + await link({ skipIfLinked: true }); - const cwd = process.cwd(); + // Verify link succeeded — link() doesn't return a value, so check directly + const profile = await resolveProfile(cwd); + if (!profile) { + throw new CliError("Failed to link application. Run `clerk link` to link manually."); + } + out.add("linked", true, `Linked to ${profile.profile.appId}`); + } else { + // Agent mode: check pre-requisites without side effects + const token = await getToken(); + out.add( + "authenticated", + !!token, + token ? "Logged in" : "Not authenticated", + "clerk auth login", + ); + if (!token) return; + + const profile = await resolveProfile(cwd); + out.add( + "linked", + !!profile, + profile ? `Linked to ${profile.profile.appId}` : "Not linked", + "clerk link", + ); + if (!profile) return; + } - // Step 3: Detect framework and install SDK const fw = await detectFramework(cwd); + if (fw) { - await installSdk(cwd, fw.sdk, fw.name); + out.add("framework", true, `Detected ${fw.name}`); + + if (isHuman()) { + await installSdk(cwd, fw.sdk, fw.name); + out.add("sdk", true, `Installed ${fw.sdk}`); + } else { + const pm = await detectPackageManager(cwd); + out.add("sdk", false, `${fw.sdk} needed`, `${pm} ${fw.sdk}`); + } } else { - console.log( - `Could not detect a framework. Install the appropriate Clerk SDK manually: ${dim("https://clerk.com/docs")}`, + out.add( + "framework", + false, + "Could not detect framework", + "See https://clerk.com/docs for SDK installation", ); } - // Step 4: Pull environment variables - await pull({}); + if (isHuman()) { + await pull({}); + out.add("env", true, "Environment variables pulled"); + } else { + out.add("env", false, "Environment variables not pulled", "clerk env pull"); + } + + if (fw) { + const recipe = getRecipe(fw.dep); + if (recipe) { + out.meta("recipe", recipe); + if (isHuman()) { + console.log(); + console.log(recipe); + } + } + } } diff --git a/src/commands/init/recipes/astro.md b/src/commands/init/recipes/astro.md new file mode 100644 index 00000000..c5ff9f04 --- /dev/null +++ b/src/commands/init/recipes/astro.md @@ -0,0 +1,49 @@ +## Next Steps: Astro Integration + +### 1. Add Clerk integration + +Add the `clerk()` integration and an SSR adapter to `astro.config.mjs`: + +```ts +import { defineConfig } from "astro/config"; +import clerk from "@clerk/astro"; +import node from "@astrojs/node"; + +export default defineConfig({ + integrations: [clerk()], + adapter: node({ mode: "standalone" }), + output: "server", +}); +``` + +### 2. Create middleware + +Create `src/middleware.ts`: + +```ts +import { clerkMiddleware } from "@clerk/astro/server"; + +export const onRequest = clerkMiddleware(); +``` + +### 3. Add auth components + +Use Clerk components in your Astro layouts and pages: + +```astro +--- +import { Show, UserButton, SignInButton, SignUpButton } from "@clerk/astro/components"; +--- + +
+ + + + + + + +
+``` + +Docs: https://clerk.com/docs/quickstarts/astro diff --git a/src/commands/init/recipes/expo.md b/src/commands/init/recipes/expo.md new file mode 100644 index 00000000..f14b563a --- /dev/null +++ b/src/commands/init/recipes/expo.md @@ -0,0 +1,45 @@ +## Next Steps: Expo Integration + +### 1. Add ClerkProvider + +Wrap your app with `` in your root layout: + +```tsx +import { ClerkProvider } from "@clerk/expo"; +import { tokenCache } from "@clerk/expo/token-cache"; + +const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!; + +export default function RootLayout() { + return ( + + + + ); +} +``` + +### 2. Add auth components + +Use `` to conditionally render content based on auth state: + +```tsx +import { useUser } from "@clerk/expo"; +import { Show } from "@clerk/expo/native"; +import { Text, View } from "react-native"; + +export default function Home() { + return ( + + + + + + Please sign in + + + ); +} +``` + +Docs: https://clerk.com/docs/quickstarts/expo diff --git a/src/commands/init/recipes/express.md b/src/commands/init/recipes/express.md new file mode 100644 index 00000000..59211bef --- /dev/null +++ b/src/commands/init/recipes/express.md @@ -0,0 +1,31 @@ +## Next Steps: Express Integration + +### 1. Add Clerk middleware + +Add `clerkMiddleware()` to your Express app: + +```ts +import express from "express"; +import { clerkMiddleware, requireAuth, getAuth } from "@clerk/express"; + +const app = express(); + +app.use(clerkMiddleware()); +``` + +### 2. Protect routes + +Use `requireAuth()` to protect specific routes, and `getAuth()` to access auth state: + +```ts +app.get("/protected", requireAuth(), (req, res) => { + const { userId } = getAuth(req); + res.json({ userId }); +}); + +app.get("/public", (req, res) => { + res.json({ message: "This is a public route" }); +}); +``` + +Docs: https://clerk.com/docs/quickstarts/express diff --git a/src/commands/init/recipes/fastify.md b/src/commands/init/recipes/fastify.md new file mode 100644 index 00000000..fe8fb5b3 --- /dev/null +++ b/src/commands/init/recipes/fastify.md @@ -0,0 +1,32 @@ +## Next Steps: Fastify Integration + +### 1. Register Clerk plugin + +Register `clerkPlugin` with your Fastify instance: + +```ts +import Fastify from "fastify"; +import { clerkPlugin, getAuth } from "@clerk/fastify"; + +const fastify = Fastify(); + +fastify.register(clerkPlugin); +``` + +### 2. Protect routes + +Use `getAuth` to access auth state in route handlers: + +```ts +fastify.get("/protected", async (request, reply) => { + const { isAuthenticated, userId } = getAuth(request); + + if (!isAuthenticated) { + return reply.code(401).send({ error: "Unauthorized" }); + } + + return { userId }; +}); +``` + +Docs: https://clerk.com/docs/quickstarts/fastify diff --git a/src/commands/init/recipes/index.ts b/src/commands/init/recipes/index.ts new file mode 100644 index 00000000..b331c8a4 --- /dev/null +++ b/src/commands/init/recipes/index.ts @@ -0,0 +1,30 @@ +import nextjs from "./nextjs.md" with { type: "text" }; +import react from "./react.md" with { type: "text" }; +import expo from "./expo.md" with { type: "text" }; +import astro from "./astro.md" with { type: "text" }; +import nuxt from "./nuxt.md" with { type: "text" }; +import tanstackStart from "./tanstack-start.md" with { type: "text" }; +import reactRouter from "./react-router.md" with { type: "text" }; +import fastify from "./fastify.md" with { type: "text" }; +import express from "./express.md" with { type: "text" }; +import vue from "./vue.md" with { type: "text" }; + +import type { FrameworkDep } from "../../../lib/framework.ts"; + +const RECIPES: Partial> = { + next: nextjs, + react: react, + vite: react, + expo: expo, + astro: astro, + nuxt: nuxt, + "@tanstack/react-start": tanstackStart, + "react-router": reactRouter, + fastify: fastify, + express: express, + vue: vue, +}; + +export function getRecipe(dep: FrameworkDep): string | null { + return RECIPES[dep] ?? null; +} diff --git a/src/commands/init/recipes/nextjs.md b/src/commands/init/recipes/nextjs.md new file mode 100644 index 00000000..9f4fac98 --- /dev/null +++ b/src/commands/init/recipes/nextjs.md @@ -0,0 +1,62 @@ +## Next Steps: Next.js Integration + +### 1. Create middleware + +Create `middleware.ts` in your project root: + +```ts +import { clerkMiddleware } from "@clerk/nextjs/server"; + +export default clerkMiddleware(); + +export const config = { + matcher: [ + // Skip Next.js internals and all static files, unless found in search params + "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)", + // Always run for API routes + "/(api|trpc)(.*)", + ], +}; +``` + +### 2. Add ClerkProvider + +Wrap your app with `` inside `` in `app/layout.tsx`: + +```tsx +import { ClerkProvider } from "@clerk/nextjs"; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); +} +``` + +### 3. Add auth components + +Add sign-in and user profile components to your layout or pages: + +```tsx +import { Show, SignInButton, SignUpButton, UserButton } from "@clerk/nextjs"; + +export default function Header() { + return ( +
+ + + + + + + +
+ ); +} +``` + +Docs: https://clerk.com/docs/quickstarts/nextjs diff --git a/src/commands/init/recipes/nuxt.md b/src/commands/init/recipes/nuxt.md new file mode 100644 index 00000000..7f436f1a --- /dev/null +++ b/src/commands/init/recipes/nuxt.md @@ -0,0 +1,31 @@ +## Next Steps: Nuxt Integration + +### 1. Add Clerk module + +Add `@clerk/nuxt` to your modules in `nuxt.config.ts`: + +```ts +export default defineNuxtConfig({ + modules: ["@clerk/nuxt"], +}); +``` + +### 2. Add auth components + +Use `` to conditionally render content based on auth state: + +```vue + +``` + +Docs: https://clerk.com/docs/quickstarts/nuxt diff --git a/src/commands/init/recipes/react-router.md b/src/commands/init/recipes/react-router.md new file mode 100644 index 00000000..cbf39792 --- /dev/null +++ b/src/commands/init/recipes/react-router.md @@ -0,0 +1,62 @@ +## Next Steps: React Router Integration + +### 1. Enable middleware and add root auth loader + +Enable the `v8_middleware` future flag in `react-router.config.ts`: + +```ts +import type { Config } from "@react-router/dev/config"; + +export default { + future: { + v8_middleware: true, + }, +} satisfies Config; +``` + +### 2. Configure root route + +Add `clerkMiddleware`, `rootAuthLoader`, and `` in `app/root.tsx`: + +```tsx +import { clerkMiddleware, rootAuthLoader } from "@clerk/react-router/server"; +import { ClerkProvider } from "@clerk/react-router"; +import { Outlet } from "react-router"; +import type { Route } from "./+types/root"; + +export const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()]; + +export const loader = (args: Route.LoaderArgs) => rootAuthLoader(args); + +export default function App({ loaderData }: Route.ComponentProps) { + return ( + + + + ); +} +``` + +### 3. Add auth components + +Use Clerk components in your routes: + +```tsx +import { Show, SignInButton, SignUpButton, UserButton } from "@clerk/react-router"; + +export default function Header() { + return ( +
+ + + + + + + +
+ ); +} +``` + +Docs: https://clerk.com/docs/quickstarts/react-router diff --git a/src/commands/init/recipes/react.md b/src/commands/init/recipes/react.md new file mode 100644 index 00000000..72241dfd --- /dev/null +++ b/src/commands/init/recipes/react.md @@ -0,0 +1,39 @@ +## Next Steps: React Integration + +### 1. Add ClerkProvider + +Wrap your app with `` in your root component: + +```tsx +import { ClerkProvider } from "@clerk/react"; + +function App() { + return {/* your app */}; +} + +export default App; +``` + +### 2. Add auth components + +Add sign-in and user profile components: + +```tsx +import { Show, SignInButton, SignUpButton, UserButton } from "@clerk/react"; + +export default function Header() { + return ( +
+ + + + + + + +
+ ); +} +``` + +Docs: https://clerk.com/docs/quickstarts/react diff --git a/src/commands/init/recipes/tanstack-start.md b/src/commands/init/recipes/tanstack-start.md new file mode 100644 index 00000000..39859f77 --- /dev/null +++ b/src/commands/init/recipes/tanstack-start.md @@ -0,0 +1,67 @@ +## Next Steps: TanStack Start Integration + +### 1. Configure Clerk middleware + +Add `clerkMiddleware` to your server entry (`src/start.ts`): + +```ts +import { clerkMiddleware } from "@clerk/tanstack-react-start/server"; +import { createStart } from "@tanstack/react-start"; + +export const startInstance = createStart(() => ({ + requestMiddleware: [clerkMiddleware()], +})); +``` + +### 2. Add ClerkProvider to root + +Wrap your app with `` in `src/routes/__root.tsx`: + +```tsx +import { ClerkProvider } from "@clerk/tanstack-react-start"; +import { HeadContent, Scripts, createRootRoute } from "@tanstack/react-router"; + +export const Route = createRootRoute({ + component: RootDocument, +}); + +function RootDocument() { + return ( + + + + + + + + + + + + ); +} +``` + +### 3. Add auth components + +Use Clerk components in your routes: + +```tsx +import { Show, SignInButton, SignUpButton, UserButton } from "@clerk/tanstack-react-start"; + +export default function Home() { + return ( +
+ + + + + + + +
+ ); +} +``` + +Docs: https://clerk.com/docs/quickstarts/tanstack-start diff --git a/src/commands/init/recipes/vue.md b/src/commands/init/recipes/vue.md new file mode 100644 index 00000000..5b6f680a --- /dev/null +++ b/src/commands/init/recipes/vue.md @@ -0,0 +1,49 @@ +## Next Steps: Vue Integration + +### 1. Install Clerk plugin + +Add the Clerk plugin to your Vue app in `main.ts`: + +```ts +import { createApp } from "vue"; +import { clerkPlugin } from "@clerk/vue"; +import App from "./App.vue"; + +const PUBLISHABLE_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY; + +if (!PUBLISHABLE_KEY) { + throw new Error("Add your Clerk Publishable Key to the .env file"); +} + +const app = createApp(App); + +app.use(clerkPlugin, { + publishableKey: PUBLISHABLE_KEY, +}); + +app.mount("#app"); +``` + +### 2. Add auth components + +Use `` to conditionally render content based on auth state: + +```vue + + + +``` + +Docs: https://clerk.com/docs/quickstarts/vue diff --git a/src/commands/link/README.md b/src/commands/link/README.md index b779b9b2..a871409b 100644 --- a/src/commands/link/README.md +++ b/src/commands/link/README.md @@ -1,7 +1,7 @@ # Link Command Links the current git repository to a Clerk application, storing the app ID -and instance IDs in `~/.clerk/config.json`. The link is keyed by the +and instance IDs in the Clerk CLI config file. The link is keyed by the normalized git remote URL (e.g., `github.com/org/repo`), so it is automatically shared across all clones and worktrees of the same repository. @@ -20,9 +20,13 @@ clerk link --app app_abc123 # Link directly by app ID ## Agent Mode -When running in agent mode (`--mode agent` or piped stdin), outputs a structured -prompt describing how to perform the link operation instead of running the -interactive flow. +When running in agent mode (`--mode agent` or piped stdin): + +- **Without `--app`**: Lists available applications and suggests + `clerk link --app ` as a next step, since the interactive picker + cannot be used. +- **With `--app`**: Completes the link non-interactively (same as human mode + with `--app`). ## Flow @@ -32,7 +36,7 @@ interactive flow. 4. If `--app` is provided, uses that app ID directly 5. Otherwise, fetches the list of applications and presents a searchable picker (type to filter by name) 6. Fetches application details to retrieve instance IDs -7. Stores the profile in `~/.clerk/config.json` keyed by the normalized remote URL +7. Stores the profile in the Clerk CLI config file keyed by the normalized remote URL 8. Falls back to git-common-dir or the current directory path if no remote is configured ## API Endpoints diff --git a/src/commands/link/index.test.ts b/src/commands/link/index.test.ts index c8d00036..285a8596 100644 --- a/src/commands/link/index.test.ts +++ b/src/commands/link/index.test.ts @@ -14,6 +14,7 @@ mock.module("../../mode.ts", () => ({ _modeOverride !== undefined ? _modeOverride === "agent" : mockIsAgent(...args), isHuman: (...args: unknown[]) => _modeOverride !== undefined ? _modeOverride !== "agent" : !mockIsAgent(...args), + isJSON: () => (_modeOverride !== undefined ? _modeOverride === "agent" : mockIsAgent()), setMode: (m: string) => { _modeOverride = m; }, @@ -92,8 +93,6 @@ const mockApp = { describe("link", () => { let consoleSpy: ReturnType; - let errorSpy: ReturnType; - let exitSpy: ReturnType; afterEach(() => { _modeOverride = undefined; @@ -115,31 +114,50 @@ describe("link", () => { mockSearch.mockReset(); mockConfirm.mockReset(); consoleSpy?.mockRestore(); - errorSpy?.mockRestore(); - exitSpy?.mockRestore(); }); describe("agent mode", () => { - test("outputs prompt and returns", async () => { + test("outputs structured JSON for unauthenticated state", async () => { mockIsAgent.mockReturnValue(true); + mockGetToken.mockResolvedValue(null); consoleSpy = spyOn(console, "log").mockImplementation(() => {}); await link(); - expect(consoleSpy).toHaveBeenCalledTimes(1); - const output = consoleSpy.mock.calls[0][0] as string; - expect(output).toContain("linking a Clerk application"); + const jsonLine = consoleSpy.mock.calls.find((c: unknown[]) => { + try { + JSON.parse(c[0] as string); + return true; + } catch { + return false; + } + }); + expect(jsonLine).toBeDefined(); + const parsed = JSON.parse(jsonLine![0] as string); + expect(parsed.command).toBe("link"); + expect(parsed.checks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "authenticated", ok: false, fix: "clerk auth login" }), + ]), + ); }); test("does not trigger interactive prompts", async () => { mockIsAgent.mockReturnValue(true); + mockGetToken.mockResolvedValue("token"); + mockListApplications.mockResolvedValue([ + { + application_id: "app_a", + name: "App A", + instances: [{ instance_id: "ins_1", environment_type: "development" }], + }, + ]); consoleSpy = spyOn(console, "log").mockImplementation(() => {}); await link(); expect(mockSearch).not.toHaveBeenCalled(); - expect(mockGetToken).not.toHaveBeenCalled(); - expect(mockListApplications).not.toHaveBeenCalled(); + expect(mockConfirm).not.toHaveBeenCalled(); }); }); @@ -349,10 +367,11 @@ describe("link", () => { await link(); }); - test("exits when no apps found", async () => { + test("throws CliError when no apps found", async () => { mockIsAgent.mockReturnValue(false); mockGetToken.mockResolvedValue("token"); mockListApplications.mockResolvedValue([]); + consoleSpy = spyOn(console, "log").mockImplementation(() => {}); await expect(link()).rejects.toThrow("No applications found"); }); @@ -442,7 +461,7 @@ describe("link", () => { expect(storedProfile.instances.production).toBeUndefined(); }); - test("exits when no development instance", async () => { + test("throws CliError when no development instance", async () => { mockIsAgent.mockReturnValue(false); mockGetToken.mockResolvedValue("token"); mockFetchApplication.mockResolvedValue({ @@ -456,10 +475,9 @@ describe("link", () => { }, ], }); + consoleSpy = spyOn(console, "log").mockImplementation(() => {}); - await expect(link({ app: "app_123" })).rejects.toThrow( - "Application has no development instance", - ); + await expect(link({ app: "app_123" })).rejects.toThrow("no development instance"); }); test("logs confirmation message", async () => { @@ -470,8 +488,8 @@ describe("link", () => { await link({ app: "app_123" }); - const lastCall = consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0] as string; - expect(lastCall).toContain("Linked to"); + const output = capturedOutput(consoleSpy); + expect(output).toContain("Linked to"); }); }); @@ -494,7 +512,7 @@ describe("link", () => { expect(output).toContain("github.com/org/repo"); }); - test("skips silently with skipIfLinked after printing auto-link notice", async () => { + test("skips silently with skipIfLinked after printing already-linked check", async () => { mockIsAgent.mockReturnValue(false); mockGetGitNormalizedRemote.mockResolvedValue("github.com/org/repo"); mockResolveProfile.mockResolvedValue({ @@ -507,7 +525,7 @@ describe("link", () => { await link({ skipIfLinked: true }); const output = capturedOutput(consoleSpy); - expect(output).toContain("Auto-linked via git remote"); + expect(output).toContain("Already linked"); expect(mockConfirm).not.toHaveBeenCalled(); expect(mockGetToken).not.toHaveBeenCalled(); }); diff --git a/src/commands/link/index.ts b/src/commands/link/index.ts index 7f67ae3c..13910c93 100644 --- a/src/commands/link/index.ts +++ b/src/commands/link/index.ts @@ -1,31 +1,15 @@ import { basename } from "node:path"; import { search, confirm } from "@inquirer/prompts"; -import { isAgent } from "../../mode.js"; +import { isHuman } from "../../mode.js"; import { getToken } from "../../lib/credential-store.js"; import { login } from "../auth/login.js"; import { listApplications, fetchApplication, type Application } from "../../lib/plapi.js"; import { setProfile, resolveProfile, moveProfile } from "../../lib/config.js"; import { getGitRepoIdentifier, getGitRepoRoot, getGitNormalizedRemote } from "../../lib/git.js"; -import { dim, cyan } from "../../lib/color.js"; +import { dim } from "../../lib/color.js"; +import { createCommandOutput } from "../../lib/cli.js"; import { CliError } from "../../lib/errors.js"; -const AGENT_PROMPT = `You are linking a Clerk application to the current project directory. - -## Steps - -1. Ensure the user is authenticated. If not, run \`clerk auth login\` first. -2. Determine which application to link: - - If the user provides an app ID: \`clerk link --app \` - - Otherwise, list available applications with \`GET /v1/platform/applications\` and ask the user to select one. -3. The link is stored in ~/.clerk/config.json as a profile keyed by the git repository root (shared across worktrees). - -## API Endpoints - -| Method | Endpoint | Purpose | -|--------|----------|---------| -| GET | /v1/platform/applications | List all applications | -| GET | /v1/platform/applications/{appId} | Fetch application with instance details |`; - interface LinkOptions { app?: string; skipIfLinked?: boolean; @@ -36,10 +20,7 @@ function appLabel(app: Application): string { } export async function link(options: LinkOptions = {}): Promise { - if (isAgent()) { - console.log(AGENT_PROMPT); - return; - } + using out = createCommandOutput("link"); // Resolve git repo identifier — prefer normalized remote URL for cross-clone matching const cwd = process.cwd(); @@ -52,44 +33,58 @@ export async function link(options: LinkOptions = {}): Promise { // Check if already linked const existing = await resolveProfile(cwd); if (existing) { - // Print context-specific message - if (existing.resolvedVia === "remote") { - console.log(`Auto-linked via git remote (${dim(normalizedRemote ?? existing.path)})`); - } else { - console.log(`Already linked to ${cyan(existing.profile.appId)} in ${dim(existing.path)}`); - } + out.add("already_linked", true, `Already linked to ${existing.profile.appId}`); if (options.skipIfLinked) return; - // Offer upgrade when an old profile key can migrate to a remote URL - if (existing.availableRemote) { - console.log( - `We detected this is now a git repository with remote ${dim(existing.availableRemote)}.`, - ); - const upgrade = await confirm({ - message: - "Update the link to use the git remote? This shares it across clones and worktrees.", - default: true, - }); - if (upgrade) { - await moveProfile(existing.path, existing.availableRemote); - console.log(`\nLink updated to use git remote (${cyan(existing.availableRemote)})`); - return; + if (isHuman()) { + // Print context-specific message + if (existing.resolvedVia === "remote") { + console.log(` Auto-linked via git remote (${dim(normalizedRemote ?? existing.path)})`); + } + + // Offer upgrade when an old profile key can migrate to a remote URL + if (existing.availableRemote) { + console.log( + ` We detected this is now a git repository with remote ${dim(existing.availableRemote)}.`, + ); + const upgrade = await confirm({ + message: + "Update the link to use the git remote? This shares it across clones and worktrees.", + default: true, + }); + if (upgrade) { + await moveProfile(existing.path, existing.availableRemote); + out.add("upgraded", true, `Link updated to use git remote (${existing.availableRemote})`); + return; + } } - } - const relink = await confirm({ - message: "Re-link to a different application?", - default: false, - }); - if (!relink) return; + const relink = await confirm({ + message: "Re-link to a different application?", + default: false, + }); + if (!relink) return; + } else { + // Agent: already linked, suggest re-link with --app if needed + out.suggest(`clerk link --app `); + return; + } } - // Ensure authenticated + // Check auth const token = await getToken(); if (!token) { - console.log("Not logged in. Authenticating first..."); - await login(); + if (isHuman()) { + console.log(" Not logged in. Authenticating first..."); + await login(); + out.add("authenticated", true, "Logged in"); + } else { + out.add("authenticated", false, "Not logged in", "clerk auth login"); + return; + } + } else { + out.add("authenticated", true, "Logged in"); } // Determine which app to link @@ -101,35 +96,56 @@ export async function link(options: LinkOptions = {}): Promise { const apps = await listApplications(); if (apps.length === 0) { - throw new CliError("No applications found. Create one at https://dashboard.clerk.com first."); + if (isHuman()) { + throw new CliError("No applications found. Create one at https://dashboard.clerk.com"); + } + out.add( + "applications", + false, + "No applications found", + "Create one at https://dashboard.clerk.com", + ); + return; } - const choices = apps.map((a) => ({ - name: appLabel(a), - value: a.application_id, - })); - - const selectedId = await search({ - message: `Select a Clerk application to link ${dim(`(repo: ${basename(displayPath)})`)}`, - source: (term) => { - if (!term) return choices; - const lower = term.toLowerCase(); - return choices.filter((c) => c.name.toLowerCase().includes(lower)); - }, - }); - - const found = apps.find((a) => a.application_id === selectedId); - if (!found) { - throw new CliError("Selected application not found."); + if (isHuman()) { + const choices = apps.map((a) => ({ + name: appLabel(a), + value: a.application_id, + })); + + const selectedId = await search({ + message: `Select a Clerk application to link ${dim(`(repo: ${basename(displayPath)})`)}`, + source: (term) => { + if (!term) return choices; + const lower = term.toLowerCase(); + return choices.filter((c) => c.name.toLowerCase().includes(lower)); + }, + }); + + const found = apps.find((a) => a.application_id === selectedId); + if (!found) { + throw new CliError("Selected application not found."); + } + app = found; + } else { + // Agent can't pick interactively — list available apps with names + const appList = apps.map((a) => `${appLabel(a)}`).join(", "); + out.add("applications", true, `${apps.length} available: ${appList}`); + out.suggest("clerk link --app (pick one from above)"); + return; } - app = found; } const devInstance = app.instances.find((i) => i.environment_type === "development"); const prodInstance = app.instances.find((i) => i.environment_type === "production"); if (!devInstance) { - throw new CliError("Application has no development instance."); + if (isHuman()) { + throw new CliError("Application has no development instance."); + } + out.add("instance", false, "Application has no development instance"); + return; } // Store profile keyed by git repo (or cwd if not in a repo) @@ -143,5 +159,5 @@ export async function link(options: LinkOptions = {}): Promise { }); const label = app.name || app.application_id; - console.log(`\nLinked to ${cyan(label)} in ${dim(displayPath)}`); + out.add("linked", true, `Linked to ${label} in ${displayPath}`); } diff --git a/src/commands/unlink/README.md b/src/commands/unlink/README.md index aff5b25d..3de2e128 100644 --- a/src/commands/unlink/README.md +++ b/src/commands/unlink/README.md @@ -20,7 +20,7 @@ clerk unlink --yes 1. Resolves the current profile by checking the normalized remote URL, then git-common-dir, then walking up from the working directory 2. If no profile is found, exits with an error 3. Prompts for confirmation (unless `--yes` is passed) -4. Removes the profile entry from `~/.clerk/config.json` +4. Removes the profile entry from the Clerk CLI config file ## Agent Mode diff --git a/src/commands/unlink/index.test.ts b/src/commands/unlink/index.test.ts index c3e665db..e883d96b 100644 --- a/src/commands/unlink/index.test.ts +++ b/src/commands/unlink/index.test.ts @@ -9,6 +9,7 @@ mock.module("../../mode.ts", () => ({ _modeOverride !== undefined ? _modeOverride === "agent" : mockIsAgent(...args), isHuman: (...args: unknown[]) => _modeOverride !== undefined ? _modeOverride !== "agent" : mockIsHuman(...args), + isJSON: () => (_modeOverride !== undefined ? _modeOverride === "agent" : mockIsAgent()), setMode: (m: string) => { _modeOverride = m; }, @@ -44,8 +45,6 @@ const mockProfile = { describe("unlink", () => { let consoleSpy: ReturnType; - let errorSpy: ReturnType; - let exitSpy: ReturnType; afterEach(() => { _modeOverride = undefined; @@ -57,45 +56,69 @@ describe("unlink", () => { mockGetGitRepoRoot.mockResolvedValue("/repo"); mockConfirm.mockReset(); consoleSpy?.mockRestore(); - errorSpy?.mockRestore(); - exitSpy?.mockRestore(); }); describe("agent mode", () => { - test("outputs prompt and returns", async () => { + test("outputs structured JSON with check results", async () => { mockIsAgent.mockReturnValue(true); + mockResolveProfile.mockResolvedValue(mockProfile); + mockRemoveProfile.mockResolvedValue(undefined); consoleSpy = spyOn(console, "log").mockImplementation(() => {}); - await unlink(); + await unlink({ yes: true }); - expect(consoleSpy).toHaveBeenCalledTimes(1); - const output = consoleSpy.mock.calls[0][0] as string; - expect(output).toContain("unlinking a Clerk application"); + const jsonLine = consoleSpy.mock.calls.find((c: unknown[]) => { + try { + JSON.parse(c[0] as string); + return true; + } catch { + return false; + } + }); + expect(jsonLine).toBeDefined(); + const parsed = JSON.parse(jsonLine![0] as string); + expect(parsed.command).toBe("unlink"); + expect(parsed.checks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "linked", ok: true }), + expect.objectContaining({ name: "unlinked", ok: true }), + ]), + ); }); - test("does not trigger side effects", async () => { + test("does not trigger interactive prompts", async () => { mockIsAgent.mockReturnValue(true); + mockResolveProfile.mockResolvedValue(mockProfile); + mockRemoveProfile.mockResolvedValue(undefined); consoleSpy = spyOn(console, "log").mockImplementation(() => {}); - await unlink(); + await unlink({ yes: true }); - expect(mockResolveProfile).not.toHaveBeenCalled(); - expect(mockRemoveProfile).not.toHaveBeenCalled(); expect(mockConfirm).not.toHaveBeenCalled(); }); }); describe("not linked", () => { - test("exits when directory is not linked", async () => { + test("throws in human mode when not linked", async () => { mockIsAgent.mockReturnValue(false); + mockIsHuman.mockReturnValue(true); mockResolveProfile.mockResolvedValue(undefined); - errorSpy = spyOn(console, "error").mockImplementation(() => {}); - exitSpy = spyOn(process, "exit").mockImplementation(() => { - throw new Error("exit"); - }); + consoleSpy = spyOn(console, "log").mockImplementation(() => {}); await expect(unlink()).rejects.toThrow("not linked"); }); + + test("reports not linked and returns in agent mode", async () => { + mockIsAgent.mockReturnValue(true); + mockIsHuman.mockReturnValue(false); + mockResolveProfile.mockResolvedValue(undefined); + consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + + await unlink(); + + const output = capturedOutput(consoleSpy); + expect(output).toContain("not linked"); + }); }); describe("confirmation", () => { @@ -128,7 +151,7 @@ describe("unlink", () => { expect(mockRemoveProfile).toHaveBeenCalledWith(process.cwd()); }); - test("aborts when user declines", async () => { + test("throws UserAbortError when user declines", async () => { mockIsAgent.mockReturnValue(false); mockIsHuman.mockReturnValue(true); mockResolveProfile.mockResolvedValue(mockProfile); @@ -136,6 +159,7 @@ describe("unlink", () => { consoleSpy = spyOn(console, "log").mockImplementation(() => {}); await expect(unlink()).rejects.toThrow("User aborted"); + expect(mockRemoveProfile).not.toHaveBeenCalled(); }); }); diff --git a/src/commands/unlink/index.ts b/src/commands/unlink/index.ts index 50e6c060..8df32ac8 100644 --- a/src/commands/unlink/index.ts +++ b/src/commands/unlink/index.ts @@ -1,56 +1,42 @@ import { confirm } from "@inquirer/prompts"; -import { isAgent, isHuman } from "../../mode.js"; +import { isHuman } from "../../mode.js"; import { resolveProfile, removeProfile } from "../../lib/config.js"; import { getGitRepoRoot } from "../../lib/git.js"; -import { dim, cyan } from "../../lib/color.js"; +import { createCommandOutput } from "../../lib/cli.js"; import { CliError, throwUserAbort } from "../../lib/errors.js"; -const AGENT_PROMPT = `You are unlinking a Clerk application from the current project directory. - -## Steps - -1. Resolve the current profile for the working directory using the config file at ~/.clerk/config.json. -2. If no profile is found, inform the user that the directory is not linked. -3. Remove the profile entry from ~/.clerk/config.json. - -## CLI Usage - -\`\`\` -clerk unlink # Interactive confirmation before unlinking -clerk unlink --yes # Skip confirmation -\`\`\``; - interface UnlinkOptions { yes?: boolean; } export async function unlink(options: UnlinkOptions = {}): Promise { - if (isAgent()) { - console.log(AGENT_PROMPT); - return; - } + using out = createCommandOutput("unlink"); const cwd = process.cwd(); const existing = await resolveProfile(cwd); if (!existing) { - throw new CliError("This directory is not linked to a Clerk application."); + if (isHuman()) { + throw new CliError("This directory is not linked to a Clerk application."); + } + out.add("linked", false, "This directory is not linked to a Clerk application"); + return; } const label = existing.profile.appId; const repoRoot = await getGitRepoRoot(); const displayPath = repoRoot ?? existing.path; + out.add("linked", true, `Linked to ${label} in ${displayPath}`); + if (isHuman() && !options.yes) { const ok = await confirm({ message: `Unlink ${label} from ${displayPath}?`, default: false, }); - if (!ok) { - throwUserAbort(); - } + if (!ok) throwUserAbort(); } await removeProfile(existing.path); - console.log(`\nUnlinked ${cyan(label)} from ${dim(displayPath)}`); + out.add("unlinked", true, `Unlinked ${label} from ${displayPath}`); } diff --git a/src/lib/cli.test.ts b/src/lib/cli.test.ts new file mode 100644 index 00000000..a9d228c2 --- /dev/null +++ b/src/lib/cli.test.ts @@ -0,0 +1,231 @@ +import { test, expect, beforeEach, describe, spyOn } from "bun:test"; +import { setMode, setJsonFlag, isJSON } from "../mode"; +import { createCommandOutput } from "./cli"; + +describe("createCommandOutput", () => { + let logs: string[]; + + beforeEach(() => { + logs = []; + spyOn(console, "log").mockImplementation((...args: unknown[]) => { + logs.push(args.join(" ")); + }); + }); + + describe("agent mode", () => { + beforeEach(() => { + setMode("agent"); + setJsonFlag(false); + }); + + test("emits JSON with passing checks on dispose", () => { + { + using out = createCommandOutput("init"); + out.add("authenticated", true, "Logged in"); + out.add("linked", true, "Application linked"); + out.add("framework", true, "Detected Next.js"); + } + + expect(logs).toHaveLength(1); + const parsed = JSON.parse(logs[0]!); + expect(parsed).toEqual({ + command: "init", + checks: [ + { name: "authenticated", ok: true, detail: "Logged in" }, + { name: "linked", ok: true, detail: "Application linked" }, + { name: "framework", ok: true, detail: "Detected Next.js" }, + ], + }); + }); + + test("emits JSON with failing checks and fix suggestions", () => { + { + using out = createCommandOutput("deploy"); + out.add("authenticated", false, "Not authenticated", "clerk auth login"); + out.add("linked", false, "Not linked", "clerk link"); + } + + const parsed = JSON.parse(logs[0]!); + expect(parsed).toEqual({ + command: "deploy", + checks: [ + { + name: "authenticated", + ok: false, + detail: "Not authenticated", + fix: "clerk auth login", + }, + { name: "linked", ok: false, detail: "Not linked", fix: "clerk link" }, + ], + next: ["clerk auth login", "clerk link"], + }); + }); + + test("includes explicit suggestions in next steps", () => { + { + using out = createCommandOutput("link"); + out.add("authenticated", true, "Logged in"); + out.add("applications", true, "3 available: My App (app_abc123)"); + out.suggest("clerk link --app (pick one from above)"); + } + + const parsed = JSON.parse(logs[0]!); + expect(parsed).toEqual({ + command: "link", + checks: [ + { name: "authenticated", ok: true, detail: "Logged in" }, + { name: "applications", ok: true, detail: "3 available: My App (app_abc123)" }, + ], + next: ["clerk link --app (pick one from above)"], + }); + }); + + test("combines fix suggestions from failed checks with explicit suggestions", () => { + { + using out = createCommandOutput("init"); + out.add("authenticated", false, "Not authenticated", "clerk auth login"); + out.suggest("clerk link"); + } + + const parsed = JSON.parse(logs[0]!); + expect(parsed).toEqual({ + command: "init", + checks: [ + { + name: "authenticated", + ok: false, + detail: "Not authenticated", + fix: "clerk auth login", + }, + ], + next: ["clerk auth login", "clerk link"], + }); + }); + + test("includes metadata in JSON output", () => { + { + using out = createCommandOutput("init"); + out.add("framework", true, "Detected Next.js"); + out.meta("recipe", "Add ClerkProvider to layout.tsx"); + } + + const parsed = JSON.parse(logs[0]!); + expect(parsed).toEqual({ + command: "init", + checks: [{ name: "framework", ok: true, detail: "Detected Next.js" }], + meta: { recipe: "Add ClerkProvider to layout.tsx" }, + }); + }); + + test("does not print check lines immediately", () => { + const out = createCommandOutput("deploy"); + out.add("authenticated", true, "Logged in"); + + // No output yet — only on dispose + expect(logs).toHaveLength(0); + + out[Symbol.dispose](); + expect(logs).toHaveLength(1); + }); + + test("omits next when no fixes or suggestions", () => { + { + using out = createCommandOutput("unlink"); + out.add("linked", true, "Linked to app_abc123 in /path/to/repo"); + out.add("unlinked", true, "Unlinked app_abc123 from /path/to/repo"); + } + + const parsed = JSON.parse(logs[0]!); + expect(parsed.next).toBeUndefined(); + }); + }); + + describe("human mode", () => { + beforeEach(() => { + setMode("human"); + setJsonFlag(false); + }); + + test("prints check lines immediately on add", () => { + const out = createCommandOutput("init"); + out.add("authenticated", true, "Logged in"); + + expect(logs).toMatchInlineSnapshot(` + [ + " \x1B[32m✓\x1B[0m authenticated: Logged in", + ] + `); + + out.add("linked", false, "Not linked", "clerk link"); + + expect(logs).toMatchInlineSnapshot(` + [ + " \x1B[32m✓\x1B[0m authenticated: Logged in", + " \x1B[33m✗\x1B[0m linked: Not linked (run: clerk link)", + ] + `); + + out[Symbol.dispose](); + + // No additional output on dispose in human mode + expect(logs).toHaveLength(2); + }); + + test("does not emit JSON on dispose", () => { + { + using out = createCommandOutput("init"); + out.add("authenticated", true, "Logged in"); + out.meta("recipe", "Add ClerkProvider to layout.tsx"); + out.suggest("clerk env pull"); + } + + // Only the add() line, no JSON + expect(logs).toHaveLength(1); + }); + }); + + describe("isJSON", () => { + beforeEach(() => { + setMode("human"); + setJsonFlag(false); + }); + + test("returns true when in agent mode", () => { + setMode("agent"); + expect(isJSON()).toBe(true); + }); + + test("returns true when json flag is set", () => { + setMode("human"); + setJsonFlag(true); + expect(isJSON()).toBe(true); + }); + + test("returns false in human mode without json flag", () => { + setMode("human"); + setJsonFlag(false); + expect(isJSON()).toBe(false); + }); + }); + + describe("json flag mode", () => { + beforeEach(() => { + setMode("human"); + setJsonFlag(true); + }); + + test("emits JSON on dispose even in human mode when json flag is set", () => { + { + using out = createCommandOutput("init"); + out.add("authenticated", true, "Logged in"); + } + + expect(logs).toHaveLength(1); + const parsed = JSON.parse(logs[0]!); + expect(parsed).toEqual({ + command: "init", + checks: [{ name: "authenticated", ok: true, detail: "Logged in" }], + }); + }); + }); +}); diff --git a/src/lib/cli.ts b/src/lib/cli.ts new file mode 100644 index 00000000..72354649 --- /dev/null +++ b/src/lib/cli.ts @@ -0,0 +1,86 @@ +/** + * Shared helpers for unified human/agent command output. + * + * Uses `using` (explicit resource management) so that agent output + * is automatically flushed when the command scope exits. + */ + +import { isJSON } from "../mode.js"; +import { green, yellow } from "./color.js"; + +export interface CommandOutput extends Disposable { + /** Record a check result. Printed immediately in human mode. */ + add(name: string, ok: boolean, detail: string, fix?: string): void; + /** Suggest a next-step command (only shown to agents). */ + suggest(command: string): void; + /** Attach arbitrary metadata to the JSON output (agent/json mode only). */ + meta(key: string, value: unknown): void; +} + +/** + * Creates a check/fix tracker that auto-renders output on dispose. + * + * - Human mode: each `add()` call prints a ✓/✗ line immediately. + * - JSON output mode: all checks are batched and emitted as JSON when the + * resource is disposed (via `using`). Triggered by agent mode or --json flag. + * + * @example + * ```ts + * async function myCommand() { + * using out = createCommandOutput("my-command"); + * out.add("auth", true, "Logged in"); + * out.add("linked", false, "Not linked", "clerk link"); + * } + * ``` + */ +export function createCommandOutput(command: string): CommandOutput { + const checks: { + name: string; + ok: boolean; + detail: string; + fix?: string; + }[] = []; + const suggestions: string[] = []; + const metadata: Record = {}; + + return { + add(name: string, ok: boolean, detail: string, fix?: string) { + checks.push({ name, ok, detail, fix }); + if (!isJSON()) { + const icon = ok ? green("✓") : yellow("✗"); + const fixHint = !ok && fix ? ` (run: ${fix})` : ""; + console.log(` ${icon} ${name}: ${detail}${fixHint}`); + } + }, + + suggest(command: string) { + suggestions.push(command); + }, + + meta(key: string, value: unknown) { + metadata[key] = value; + }, + + [Symbol.dispose]() { + if (!isJSON()) return; + + // Collect fixes from failed checks + explicit suggestions + const fixes = checks.filter((c) => !c.ok && c.fix).map((c) => c.fix!); + const next = [...fixes, ...suggestions]; + + const data: Record = { + command, + checks: checks.map((c) => ({ + name: c.name, + ok: c.ok, + detail: c.detail, + ...(c.fix ? { fix: c.fix } : {}), + })), + }; + if (next.length > 0) data.next = next; + if (Object.keys(metadata).length > 0) data.meta = metadata; + + console.log(JSON.stringify(data, null, 2)); + }, + }; +} diff --git a/src/lib/framework.ts b/src/lib/framework.ts index 4a7c73d0..1d67f7d9 100644 --- a/src/lib/framework.ts +++ b/src/lib/framework.ts @@ -13,16 +13,31 @@ export interface FrameworkInfo { } // Order matters: more specific frameworks first (e.g. next before react, nuxt before vue) -const FRAMEWORK_MAP: FrameworkInfo[] = [ +const FRAMEWORK_MAP = [ { dep: "next", name: "Next.js", sdk: "@clerk/nextjs", envVar: "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY", }, - { dep: "expo", name: "Expo", sdk: "@clerk/expo", envVar: "EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY" }, - { dep: "astro", name: "Astro", sdk: "@clerk/astro", envVar: "PUBLIC_CLERK_PUBLISHABLE_KEY" }, - { dep: "nuxt", name: "Nuxt", sdk: "@clerk/nuxt", envVar: "NUXT_PUBLIC_CLERK_PUBLISHABLE_KEY" }, + { + dep: "expo", + name: "Expo", + sdk: "@clerk/expo", + envVar: "EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY", + }, + { + dep: "astro", + name: "Astro", + sdk: "@clerk/astro", + envVar: "PUBLIC_CLERK_PUBLISHABLE_KEY", + }, + { + dep: "nuxt", + name: "Nuxt", + sdk: "@clerk/nuxt", + envVar: "NUXT_PUBLIC_CLERK_PUBLISHABLE_KEY", + }, { dep: "@tanstack/react-start", name: "TanStack Start", @@ -35,12 +50,39 @@ const FRAMEWORK_MAP: FrameworkInfo[] = [ sdk: "@clerk/react-router", envVar: "VITE_CLERK_PUBLISHABLE_KEY", }, - { dep: "fastify", name: "Fastify", sdk: "@clerk/fastify", envVar: "CLERK_PUBLISHABLE_KEY" }, - { dep: "express", name: "Express", sdk: "@clerk/express", envVar: "CLERK_PUBLISHABLE_KEY" }, - { dep: "vue", name: "Vue", sdk: "@clerk/vue", envVar: "VITE_CLERK_PUBLISHABLE_KEY" }, - { dep: "react", name: "React", sdk: "@clerk/clerk-react", envVar: "VITE_CLERK_PUBLISHABLE_KEY" }, - { dep: "vite", name: "Vite", sdk: "@clerk/clerk-react", envVar: "VITE_CLERK_PUBLISHABLE_KEY" }, -]; + { + dep: "fastify", + name: "Fastify", + sdk: "@clerk/fastify", + envVar: "CLERK_PUBLISHABLE_KEY", + }, + { + dep: "express", + name: "Express", + sdk: "@clerk/express", + envVar: "CLERK_PUBLISHABLE_KEY", + }, + { + dep: "vue", + name: "Vue", + sdk: "@clerk/vue", + envVar: "VITE_CLERK_PUBLISHABLE_KEY", + }, + { + dep: "react", + name: "React", + sdk: "@clerk/clerk-react", + envVar: "VITE_CLERK_PUBLISHABLE_KEY", + }, + { + dep: "vite", + name: "Vite", + sdk: "@clerk/clerk-react", + envVar: "VITE_CLERK_PUBLISHABLE_KEY", + }, +] as const satisfies readonly FrameworkInfo[]; + +export type FrameworkDep = (typeof FRAMEWORK_MAP)[number]["dep"]; const FALLBACK_KEY = "CLERK_PUBLISHABLE_KEY"; @@ -56,7 +98,7 @@ async function readDeps(cwd: string): Promise | null> { } } -export async function detectFramework(cwd: string): Promise { +export async function detectFramework(cwd: string) { const allDeps = await readDeps(cwd); if (!allDeps) return null; diff --git a/src/mode.ts b/src/mode.ts index e188a657..4f3a214d 100644 --- a/src/mode.ts +++ b/src/mode.ts @@ -29,3 +29,14 @@ export function isHuman(): boolean { export function isAgent(): boolean { return getMode() === "agent"; } + +let jsonFlag = false; + +export function setJsonFlag(flag: boolean) { + jsonFlag = flag; +} + +/** Returns true when output should be structured JSON (agent mode OR --json flag). */ +export function isJSON(): boolean { + return isAgent() || jsonFlag; +} diff --git a/src/test/integration/agent-mode.test.ts b/src/test/integration/agent-mode.test.ts index d126167b..6439766e 100644 --- a/src/test/integration/agent-mode.test.ts +++ b/src/test/integration/agent-mode.test.ts @@ -4,27 +4,39 @@ */ import { test, expect } from "bun:test"; -import { useIntegrationTestHarness, http, clerk } from "../lib/setup.ts"; +import { useIntegrationTestHarness, http, clerk, mockState } from "../lib/setup.ts"; useIntegrationTestHarness(); -test("init outputs structured agent prompt without API calls", async () => { +test("init outputs structured JSON without API calls", async () => { const { stdout } = await clerk("--mode", "agent", "init"); - expect(stdout).toContain("integrating Clerk authentication"); - expect(stdout).toContain("clerk auth login"); + const parsed = JSON.parse(stdout.trim()); + expect(parsed.command).toBe("init"); + expect(parsed.checks).toEqual( + expect.arrayContaining([expect.objectContaining({ name: "authenticated", ok: true })]), + ); expect(http.requests.length).toBe(0); }); -test("link outputs structured agent prompt without API calls", async () => { +test("link outputs structured JSON for unauthenticated state without API calls", async () => { + mockState.storedToken = null; const { stdout } = await clerk("--mode", "agent", "link"); - expect(stdout).toContain("linking a Clerk application"); - expect(stdout).toContain("## Steps"); + const parsed = JSON.parse(stdout.trim()); + expect(parsed.command).toBe("link"); + expect(parsed.checks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "authenticated", ok: false, fix: "clerk auth login" }), + ]), + ); expect(http.requests.length).toBe(0); }); -test("unlink outputs structured agent prompt without API calls", async () => { +test("unlink outputs structured JSON without API calls", async () => { const { stdout } = await clerk("--mode", "agent", "unlink"); - expect(stdout).toContain("unlinking a Clerk application"); - expect(stdout).toContain("## Steps"); + const parsed = JSON.parse(stdout.trim()); + expect(parsed.command).toBe("unlink"); + expect(parsed.checks).toEqual( + expect.arrayContaining([expect.objectContaining({ name: "linked", ok: false })]), + ); expect(http.requests.length).toBe(0); }); diff --git a/src/test/lib/setup.ts b/src/test/lib/setup.ts index 43726c45..a533a7b2 100644 --- a/src/test/lib/setup.ts +++ b/src/test/lib/setup.ts @@ -66,6 +66,7 @@ mock.module( ); let _mode: "human" | "agent" = "human"; +let _jsonFlag = false; mock.module( "../../mode.ts", () => @@ -76,6 +77,10 @@ mock.module( }, isHuman: () => _mode === "human", isAgent: () => _mode === "agent", + setJsonFlag: (flag: boolean) => { + _jsonFlag = flag; + }, + isJSON: () => _mode === "agent" || _jsonFlag, }) satisfies typeof import("../../mode.ts"), ); @@ -473,6 +478,7 @@ export async function setupTest(): Promise { mockState.gitNormalizedRemote = "github.com/test/project"; mockState.gitRepoRoot = "/repo"; mockState.gitRepoIdentifier = "/repo/.git"; + _jsonFlag = false; resetPromptQueues(); http.reset(); process.stdin.isTTY = true; diff --git a/src/text.d.ts b/src/text.d.ts new file mode 100644 index 00000000..c94d67b1 --- /dev/null +++ b/src/text.d.ts @@ -0,0 +1,4 @@ +declare module "*.md" { + const content: string; + export default content; +}