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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .claude/rules/commands.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
description: Command authoring conventions — directory structure, READMEs, agent mode
paths:
- "packages/cli-core/src/commands/**"
alwaysApply: false
---

Every CLI command lives in its own directory under `src/commands/<name>/`. Each directory must contain a `README.md` that documents:

- What the command does
- Usage and options
- Clerk API endpoints the command calls (method, path, description)
- Whether the command (or parts of it) is mocked/stubbed — call this out prominently with a blockquote at the top of the README if so

When adding a new command, create its directory and README. When modifying a command's behavior, options, or API calls, update its README to match.

## Agent mode

When creating or modifying a command, evaluate whether it needs an agent mode. Commands with interactive prompts (menus, wizards, multi-step flows) should check `isAgent()` from `src/mode.ts` and, when in agent mode, output a structured prompt that an AI agent can follow instead of running the interactive flow. Commands that are already non-interactive (e.g., single API calls, browser-based OAuth) typically don't need agent mode.

## Root README

`README.md` at the project root contains the CLI help output. When commands are added, removed, or their options change, update the help output in `README.md` to stay in sync. You can regenerate it by running `bun run src/cli.ts --help`.
14 changes: 7 additions & 7 deletions .claude/rules/e2e.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ E2E_HAR_DIR=<path> # Directory to write HAR files per fixture
Preferred (secrets resolved from 1Password, no plaintext on disk):

```sh
bun run test:e2e:op # Run all fixture tests (concurrency 2)
bun run test:e2e:op -- --concurrency 4 # Run with 4 concurrent workers
bun run test:e2e:op # Run all fixture tests (concurrency 4)
bun run test:e2e:op -- --concurrency 1 # Serialize
bun run test:e2e:op -- --filter react # Only files matching "react"
bun run test:e2e:op -- --debug # Verbose helper logging (CLERK_E2E_DEBUG=1)
bun run test:e2e:op -- --har # Capture HAR files to test/e2e/.har
Expand All @@ -65,7 +65,7 @@ bun run e2e:refresh-fixtures -- --only nextjs-app-router # Refresh one fixture

Each test file runs as a separate `bun test` subprocess to avoid shared process state (env vars, module singletons). The runner supports:

- `--concurrency <n>` (default 2): how many test files run in parallel
- `--concurrency <n>` (default 4): how many test files run in parallel
- `--filter <string>`: only run files whose path contains the string
- Automatic single retry on failure (handles transient FAPI throttling, Playwright timeouts)

Expand Down Expand Up @@ -124,7 +124,7 @@ In CI, use `bunx playwright install chromium --with-deps` to include system-leve

## Concurrency

Fixture files run in parallel (concurrency controlled by the runner, default 2). Each fixture uses an isolated temp directory and `CLERK_CONFIG_DIR`, so there is no shared mutable state. Do not use `test.concurrent` within individual fixture files.
Fixture files run in parallel (concurrency controlled by the runner, default 4). Each fixture uses an isolated temp directory and `CLERK_CONFIG_DIR`, so there is no shared mutable state. Do not use `test.concurrent` within individual fixture files.

Within each test file, `useFixture()` runs `setupFixture()` once in `beforeAll` and shares the result with both the build test and browser test. This avoids duplicating the expensive setup.

Expand All @@ -139,7 +139,7 @@ Helper functions are in `test/e2e/lib/`:

- `fixture-setup.ts` - `setupFixture`
- `fixture-test.ts` - `useFixture`, `runFixtureTest`, `runBrowserTest`
- `dev-server.ts` - `getAvailablePort`, `startDevServer`, `killDevServer`, `buildDevCommand`
- `dev-server.ts` - `startDevServer` (allocates a port internally and retries on collision), `killDevServer`, `buildDevCommand`
- `test-user.ts` - `createTestUser`, `deleteTestUser`
- `logger.ts` - `log`, `debug` (shared logging; set `CLERK_E2E_DEBUG=1` for verbose output)
- `types.ts` - `FixtureConfig`
Expand All @@ -151,5 +151,5 @@ E2E tests run in the `test-e2e` job in `.github/workflows/ci.yml`. Key details:
- Only runs for PRs from the same repository (skipped for external forks)
- Runs on `blacksmith-8vcpu-ubuntu-2404` with a 30-minute timeout
- Requires Node.js 22 (for Playwright) alongside Bun
- Secrets `E2E_APP_ID`, `CLERK_PLATFORM_API_KEY` are injected from GitHub Actions secrets
- Points at the staging API (`CLERK_PLATFORM_API_URL`, `CLERK_BACKEND_API_URL` set to `https://api.clerkstage.dev`)
- Secrets `CLERK_CLI_TEST_APP_ID`, `CLERK_PLATFORM_API_KEY` are injected from GitHub Actions secrets
- Targets the production Clerk API (no `CLERK_PLATFORM_API_URL` / `CLERK_BACKEND_API_URL` overrides are set, so the defaults in `packages/cli-core/src/lib/environment.ts` apply). The local `bun run test:e2e:op` flow likewise resolves secrets from the `Clerk CLI - E2E Production Secrets` 1Password item. Test users are created with the `+clerk_test` email suffix and torn down at the end of each fixture run.
64 changes: 64 additions & 0 deletions .claude/rules/errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
---
description: Error handling conventions — CliError, throwUsageError, throwUserAbort, withApiContext
paths:
- "packages/cli-core/src/commands/**"
- "packages/cli-core/src/lib/**"
alwaysApply: false
---

All error classes and helpers live in `src/lib/errors.ts`. The global error handler in `src/cli.ts` catches thrown errors and formats them for the user. **Never call `console.error` + `process.exit` directly in commands** — throw an error instead and let the global handler deal with output and exit codes.

## Known failures — `CliError`

For user-facing errors (missing config, invalid input, resource not found), throw a `CliError`:

```ts
import { CliError } from "../../lib/errors.ts";

throw new CliError("No Clerk project linked. Run `clerk link` first.");

// With a docs URL (automatically gets .md appended in agent mode for Clerk URLs):
throw new CliError("Not authenticated.", {
docsUrl: "https://clerk.com/docs/guides/development/clerk-environment-variables",
});
```

## Usage/validation errors — `throwUsageError`

For invalid arguments or options, use `throwUsageError` (exits with code 2):

```ts
import { throwUsageError } from "../../lib/errors.ts";

if (!secretKey) {
throwUsageError("No secret key found. Set CLERK_SECRET_KEY or use --secret-key.");
}
```

## User cancellation — `throwUserAbort`

When the user cancels a prompt or confirmation, call `throwUserAbort()`. The global handler exits cleanly with no error output:

```ts
import { throwUserAbort } from "../../lib/errors.ts";

const confirmed = await confirm({ message: "Proceed?" });
if (!confirmed) throwUserAbort();
```

## API errors — `withApiContext`

Wrap API calls with `withApiContext` to attach a human-readable context string. The global handler extracts the first error message from the response body and prints it with the context prefix:

```ts
import { withApiContext } from "../../lib/errors.ts";

const config = await withApiContext(
fetchInstanceConfig(appId, instanceId),
"Failed to fetch config",
);
```

## API error classes

`BapiError` and `PlapiError` (both extend `ApiError`) are thrown by the API helpers in `src/commands/api/bapi.ts` and `src/lib/plapi.ts` respectively. Don't construct these in commands — they're thrown automatically by the fetch wrappers. Use `withApiContext` to add context when calling those helpers.
29 changes: 29 additions & 0 deletions .claude/rules/testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
description: Unit test conventions using bun:test
paths:
- "**/*.test.ts"
- "**/*.test.tsx"
alwaysApply: false
---

Use `bun:test` for all unit and integration tests.

```ts
import { test, expect } from "bun:test";

test("hello world", () => {
expect(1).toBe(1);
});
```

Run the unit and integration test suite with:

```sh
bun run test
```

This runs `bun test src/commands/ src/lib/ src/mode.test.ts src/test/integration/` — it excludes e2e fixtures, which require separate setup. See `rules/e2e.md` for e2e instructions.

Prefer `spyOn()` for mocking, and always restore spies in `afterAll` with `mockRestore()`.

`mock.module()` is acceptable only when registered at file top, before any consumer of the mocked module is loaded (the integration harness at `packages/cli-core/src/test/integration/lib/harness.ts` and `packages/cli-core/src/lib/credential-store.test.ts` both follow this pattern). In Bun 1.x, `mock.module()` registrations are process-lifetime and will pollute the module registry for any later test file that imports the same module via a non-mocked path, so do not call `mock.module()` from inside `beforeEach`/`describe`/`test`, and do not introduce it in test files that will run alongside files importing the real module.
26 changes: 26 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
version: 2
updates:
# Root workspace dependencies (Bun workspaces under packages/*).
#
# Fixture projects under test/e2e/fixtures/*/package.json are pinned
# snapshots of scaffolded framework output, regenerated via
# `bun run e2e:refresh-fixtures`. Letting Dependabot bump them would
# defeat the point of testing against specific framework versions.
#
# Two layers of protection:
# 1. `directory: "/"` only configures the root, and Dependabot does
# not auto-discover manifests in unlisted paths.
# 2. `exclude-paths` is a defensive backstop in case the configured
# directory ever expands (e.g. to a glob via `directories`).
- package-ecosystem: "bun"
directory: "/"
exclude-paths:
- "test/e2e/fixtures/**"
schedule:
interval: "weekly"
open-pull-requests-limit: 5

- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ bun run test:e2e # Run E2E tests with env vars already set (used by CI; see

Locally, prefer `bun run test:e2e:op` so secrets are injected from 1Password in-memory and never written to disk. `bun run test:e2e` is for CI or for cases where the required env vars are already exported.

CI runs `bun run format:check` (fails if unformatted), `bun run lint`, `bun test`, and `bun run test:e2e` on every PR to `main`. E2E tests only run for PRs from the same repository (not external forks) and target a staging Clerk application.
CI runs `bun run format:check` (fails if unformatted), `bun run lint`, `bun test`, and `bun run test:e2e` on every PR to `main`. E2E tests only run for PRs from the same repository (not external forks) and target the production Clerk API with a dedicated test application.

## Versioning

Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ After modifying files, run these commands to match what CI enforces on pull requ
```sh
bun run format # Format with oxfmt (writes changes)
bun run lint # Lint with oxlint
bun test # Run unit tests
bun run test # Run unit tests
bun run test:e2e:op # Run E2E tests with secrets from 1Password (preferred locally)
bun run test:e2e # Run E2E tests with env vars already set (CI / non-1Password setups)
```
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"build": "bun run --filter @clerk/cli-core build",
"dev": "bun run --cwd packages/cli-core dev",
"test": "bun run --filter @clerk/cli-core test",
"test:e2e": "bun run scripts/run-e2e.ts --concurrency 4",
"test:e2e": "bun run scripts/run-e2e.ts",
"test:e2e:op": "bun run scripts/run-e2e-op.ts",
"test:all": "bun run test && bun run test:e2e",
"e2e:refresh-fixtures": "bun run scripts/refresh-e2e-fixtures.ts",
Expand Down
2 changes: 1 addition & 1 deletion packages/cli-core/src/commands/api/bapi.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { test, expect, describe, beforeEach, afterEach } from "bun:test";
import { stubFetch } from "../../test/stubs.ts";
import { stubFetch } from "../../test/lib/stubs.ts";
import { bapiRequest } from "./bapi.ts";
import { BapiError } from "../../lib/errors.ts";

Expand Down
2 changes: 1 addition & 1 deletion packages/cli-core/src/commands/api/catalog.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { test, expect, describe, beforeEach, afterEach, spyOn } from "bun:test";
import { stubFetch } from "../../test/stubs.ts";
import { stubFetch } from "../../test/lib/stubs.ts";
import { mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
Expand Down
2 changes: 1 addition & 1 deletion packages/cli-core/src/commands/api/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
configStubs,
promptsStubs,
stubFetch,
} from "../../test/stubs.ts";
} from "../../test/lib/stubs.ts";

let mockStoredToken: string | null = null;
mock.module("../../lib/credential-store.ts", () => ({
Expand Down
2 changes: 1 addition & 1 deletion packages/cli-core/src/commands/api/interactive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { test, expect, describe, beforeEach, afterEach, spyOn, mock } from "bun:
import { mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { promptsStubs, stubFetch } from "../../test/stubs.ts";
import { promptsStubs, stubFetch } from "../../test/lib/stubs.ts";

let _mode = "human";
mock.module("../../mode.ts", () => ({
Expand Down
2 changes: 1 addition & 1 deletion packages/cli-core/src/commands/api/ls.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { parseSpec, _setCacheDir } from "./catalog.ts";
import { stubFetch } from "../../test/stubs.ts";
import { stubFetch } from "../../test/lib/stubs.ts";
import { apiLs } from "./ls.ts";

const MINIMAL_SPEC = `
Expand Down
2 changes: 1 addition & 1 deletion packages/cli-core/src/commands/apps/list.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun:test";
import { capturedOutput } from "../../test/stubs.ts";
import { capturedOutput } from "../../test/lib/stubs.ts";

const mockListApplications = mock();
mock.module("../../lib/plapi.ts", () => ({
Expand Down
2 changes: 1 addition & 1 deletion packages/cli-core/src/commands/auth/login.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { test, expect, describe, afterEach, mock, spyOn } from "bun:test";
import { credentialStoreStubs, configStubs } from "../../test/stubs.ts";
import { credentialStoreStubs, configStubs } from "../../test/lib/stubs.ts";

const mockGetToken = mock();
const mockStoreToken = mock();
Expand Down
2 changes: 1 addition & 1 deletion packages/cli-core/src/commands/auth/logout.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { test, expect, describe, afterEach, mock, spyOn } from "bun:test";
import { credentialStoreStubs, configStubs } from "../../test/stubs.ts";
import { credentialStoreStubs, configStubs } from "../../test/lib/stubs.ts";

const mockDeleteToken = mock();
const mockClearAuth = mock();
Expand Down
2 changes: 1 addition & 1 deletion packages/cli-core/src/commands/config/pull.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { _setConfigDir, setProfile } from "../../lib/config.ts";
import { credentialStoreStubs, gitStubs, stubFetch } from "../../test/stubs.ts";
import { credentialStoreStubs, gitStubs, stubFetch } from "../../test/lib/stubs.ts";

mock.module("../../lib/credential-store.ts", () => credentialStoreStubs);
mock.module("../../lib/git.ts", () => gitStubs);
Expand Down
2 changes: 1 addition & 1 deletion packages/cli-core/src/commands/config/push.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { _setConfigDir, setProfile } from "../../lib/config.ts";
import { credentialStoreStubs, gitStubs, promptsStubs, stubFetch } from "../../test/stubs.ts";
import { credentialStoreStubs, gitStubs, promptsStubs, stubFetch } from "../../test/lib/stubs.ts";
import { printDiff, hasConfigChanges } from "./push.ts";

mock.module("../../lib/credential-store.ts", () => credentialStoreStubs);
Expand Down
2 changes: 1 addition & 1 deletion packages/cli-core/src/commands/config/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { _setConfigDir, setProfile } from "../../lib/config.ts";
import { credentialStoreStubs, gitStubs, stubFetch } from "../../test/stubs.ts";
import { credentialStoreStubs, gitStubs, stubFetch } from "../../test/lib/stubs.ts";

mock.module("../../lib/credential-store.ts", () => credentialStoreStubs);
mock.module("../../lib/git.ts", () => gitStubs);
Expand Down
2 changes: 1 addition & 1 deletion packages/cli-core/src/commands/deploy/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { test, expect, describe, afterEach, mock, spyOn } from "bun:test";
import { capturedOutput, promptsStubs } from "../../test/stubs.ts";
import { capturedOutput, promptsStubs } from "../../test/lib/stubs.ts";

const mockIsAgent = mock();
let _modeOverride: string | undefined;
Expand Down
2 changes: 1 addition & 1 deletion packages/cli-core/src/commands/doctor/context.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { test, expect, describe, mock, beforeEach, afterEach } from "bun:test";
import { credentialStoreStubs, configStubs, gitStubs, stubFetch } from "../../test/stubs.ts";
import { credentialStoreStubs, configStubs, gitStubs, stubFetch } from "../../test/lib/stubs.ts";
import type { Application } from "../../lib/plapi.ts";

const mockGetToken = mock();
Expand Down
2 changes: 1 addition & 1 deletion packages/cli-core/src/commands/doctor/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ 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 { gitStubs, tokenExchangeStubs, stubFetch } from "../../test/lib/stubs.ts";
import type { CheckResult, CheckStatus, DoctorContext, ResolvedProfile } from "./types.ts";
import type { Application } from "../../lib/plapi.ts";

Expand Down
2 changes: 1 addition & 1 deletion packages/cli-core/src/commands/env/pull.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { test, expect, describe, beforeEach, afterEach, spyOn, mock } from "bun:
import { mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { credentialStoreStubs, gitStubs, configStubs, stubFetch } from "../../test/stubs.ts";
import { credentialStoreStubs, gitStubs, configStubs, stubFetch } from "../../test/lib/stubs.ts";

mock.module("../../lib/credential-store.ts", () => credentialStoreStubs);
mock.module("../../lib/git.ts", () => gitStubs);
Expand Down
2 changes: 1 addition & 1 deletion packages/cli-core/src/commands/link/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
autolinkStubs,
gitStubs,
promptsStubs,
} from "../../test/stubs.ts";
} from "../../test/lib/stubs.ts";

const mockIsAgent = mock();
let _modeOverride: string | undefined;
Expand Down
2 changes: 1 addition & 1 deletion packages/cli-core/src/commands/switch-env/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { test, expect, describe, afterEach, mock, spyOn } from "bun:test";
import { configStubs, credentialStoreStubs } from "../../test/stubs.ts";
import { configStubs, credentialStoreStubs } from "../../test/lib/stubs.ts";

const mockSetEnvironment = mock();
const mockGetToken = mock();
Expand Down
2 changes: 1 addition & 1 deletion packages/cli-core/src/commands/unlink/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { test, expect, describe, afterEach, mock, spyOn } from "bun:test";
import { capturedOutput, configStubs, gitStubs, promptsStubs } from "../../test/stubs.ts";
import { capturedOutput, configStubs, gitStubs, promptsStubs } from "../../test/lib/stubs.ts";

const mockIsAgent = mock();
const mockIsHuman = mock();
Expand Down
2 changes: 1 addition & 1 deletion packages/cli-core/src/commands/whoami/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { test, expect, describe, afterEach, mock, spyOn } from "bun:test";
import { credentialStoreStubs, tokenExchangeStubs } from "../../test/stubs.ts";
import { credentialStoreStubs, tokenExchangeStubs } from "../../test/lib/stubs.ts";

const mockGetToken = mock();
const mockFetchUserInfo = mock();
Expand Down
2 changes: 1 addition & 1 deletion packages/cli-core/src/lib/autolink.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun:
import { mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { credentialStoreStubs, gitStubs, stubFetch } from "../test/stubs.ts";
import { credentialStoreStubs, gitStubs, stubFetch } from "../test/lib/stubs.ts";

mock.module("./credential-store.ts", () => credentialStoreStubs);

Expand Down
3 changes: 1 addition & 2 deletions packages/cli-core/src/lib/credential-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,7 @@ process.env.CLERK_CONFIG_DIR = tempDir;

// Import constants from the source module to avoid duplication
const { KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT } = await import("./credential-store.ts");
const credFile = () =>
join(process.env.CLERK_CONFIG_DIR ?? join(require("os").homedir(), ".clerk"), "credentials");
const credFile = () => join(tempDir, "credentials");

let keyringModule: typeof import("@napi-rs/keyring") | null;
try {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli-core/src/lib/plapi.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test";
import { credentialStoreStubs, stubFetch } from "../test/stubs.ts";
import { credentialStoreStubs, stubFetch } from "../test/lib/stubs.ts";

const mockGetToken = mock();
mock.module("./credential-store.ts", () => ({
Expand Down
Loading
Loading