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
9 changes: 9 additions & 0 deletions .changeset/webhooks-listen-v1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"clerk": minor
---

Add the `clerk webhooks` command group (V1): a PLAPI-free local webhooks toolkit.

- `clerk webhooks listen` — open a standalone Svix relay tunnel and forward deliveries to a local handler. No auth, no linked project, no backend. `--forward-to` is required. Without `--token`, the banner warns that the auto-generated relay token isn't a guaranteed-stable handle and prints the exact `--token` to pin next time; `--token <c_…>` pins an explicit, shareable URL. Flags: `--forward-to` (required), `--token`, `--headers`, `--json`. When `--forward-to` is missing, the usage error prints a runnable example beneath it.
- `clerk webhooks verify` — verify a webhook signature offline (HMAC-SHA256), from a saved `listen` event line (`--delivery`) or the four explicit header values. No network calls.
- `clerk webhooks token` — generate a valid relay token (`c_` + 10 base62 chars) for `listen --token`. Prints the bare token to stdout so it pipes: `clerk webhooks listen --token "$(clerk webhooks token)"`.
68 changes: 68 additions & 0 deletions .claude/rules/command-registration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
---
description: Command registration conventions — every command group registers via register<Name>(program) from its index.ts, listed in the registrants array
paths:
- "packages/cli-core/src/cli-program.ts"
- "packages/cli-core/src/commands/*/index.ts"
alwaysApply: false
---

Every command group is wired into the root program through a **registrant function**, never inline in `createProgram()`.

## The pattern

1. Each command group exports `register<Name>(program: Program): void` from `packages/cli-core/src/commands/<name>/index.ts`. It builds the whole `program.command("<name>")` subtree (options, arguments, `.setExamples()`, subcommands) and wires each `.action()` to the handler functions in sibling files.
2. `cli-program.ts` imports that function and adds it to the `registrants: CommandRegistrant[]` array. `createProgram()` only configures the root program + global hooks, then loops `for (const register of registrants) register(program)`.

**Do not** build a command tree inline inside `createProgram()`. If you're adding a `program.command(...)` (or `webhooks`-style group) directly in `cli-program.ts`, stop — move it to a `register<Name>` in the group's `index.ts` and append the function to `registrants` instead.

## `index.ts` shape

```ts
import type { Program } from "../../cli-program.ts";
import { list } from "./list.ts";
import { create } from "./create.ts";

export function registerApps(program: Program): void {
const apps = program.command("apps").description("Manage your Clerk applications");

apps
.command("list")
.description("List your Clerk applications")
.option("--json", "Output as JSON")
.setExamples([{ command: "clerk apps list", description: "List all applications" }])
.action(list);

apps.command("create").argument("<name>", "Application name").action(create);
}
```

- Import the `Program` type from `../../cli-program.ts` (type-only — no runtime cycle, this is the established pattern).
- Keep handler _logic_ in sibling files (`list.ts`, `create.ts`, …); `index.ts` is wiring only. A handler-map object (e.g. `const handlers = { list, create }`) is fine when actions need typed `Parameters<typeof handlers.x>[0]` casts.

## `cli-program.ts` shape

```ts
import { registerApps } from "./commands/apps/index.ts";
// …
const registrants: CommandRegistrant[] = [
registerInit,
registerApps,
// … one entry per command group, in display order …
registerExtras,
];

export function createProgram(): Program {
const program = new Command() /* … global options … */ as Program;
program.hook("preAction" /* … */);
for (const register of registrants) register(program);
return program;
}
```

Helpers used by only one group (e.g. `createOption`, `parseIntegerOption`, `getAuthToken`) belong in that group's `index.ts`, not imported into `cli-program.ts`.

## Groups with global options, an optional group-level hook, and subcommands

Build the group exactly as above and attach its concerns inside the same `register<Name>`: parent `.option(...)` flags inherited via `optsWithGlobals()`, an optional group `.hook("preAction", …)` for shared gating (e.g. auth), and one `.command(...)` per subcommand. See `commands/users/index.ts` (`registerUsers`) for a multi-subcommand group with a handler-map and options inherited via `optsWithGlobals()`. A group only needs a `preAction` gate when its subcommands require shared setup; auth-free groups like `commands/webhooks/index.ts` (`registerWebhooks`) — `token` (relay token generator), `listen` (relay tunnel), and `verify` (offline HMAC) — omit it entirely.

Related: [commands.md](./commands.md) (per-command directory + README + agent-mode rules) and [completion.md](./completion.md) (keep `.choices()` / `__complete.ts` in sync when adding commands or options).
2 changes: 2 additions & 0 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
"files": [
"packages/cli-core/src/cli.ts",
"packages/cli-core/src/cli-program.ts",
"packages/cli-core/src/lib/signals.ts",
"packages/cli-core/src/commands/webhooks/listen.ts",
Comment thread
rafa-thayto marked this conversation as resolved.
"scripts/*"
],
"rules": {
Expand Down
23 changes: 22 additions & 1 deletion packages/cli-core/src/cli-program.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { test, expect, describe } from "bun:test";
import { createProgram, formatApiBody } from "./cli-program.ts";
import { createProgram, formatApiBody, outputJsonError } from "./cli-program.ts";
import { ApiError } from "./lib/errors.ts";
import { useCaptureLog } from "./test/lib/stubs.ts";

test("registers users as a top-level command", () => {
const program = createProgram();
Expand Down Expand Up @@ -346,3 +347,23 @@ describe("formatApiBody", () => {
expect(result).toBe("Plan limitation");
});
});

describe("outputJsonError", () => {
const captured = useCaptureLog();

const parse = () => JSON.parse(captured.err.trim()) as { error: Record<string, unknown> };

test("includes raw {command, description} examples in the payload", () => {
outputJsonError("usage_error", "--forward-to <url> is required.", undefined, undefined, [
{ command: "clerk webhooks listen --forward-to <url>", description: "Forward events" },
]);
expect(parse().error.examples).toEqual([
{ command: "clerk webhooks listen --forward-to <url>", description: "Forward events" },
]);
});

test("omits the examples key when there are none", () => {
outputJsonError("usage_error", "boom");
expect(parse().error).not.toHaveProperty("examples");
});
});
16 changes: 13 additions & 3 deletions packages/cli-core/src/cli-program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { registerSwitchEnv } from "./commands/switch-env/index.ts";
import { registerCompletion } from "./commands/completion/index.ts";
import { registerUpdate } from "./commands/update/index.ts";
import { registerDeploy } from "./commands/deploy/index.ts";
import { registerWebhooks } from "./commands/webhooks/index.ts";
import { getEnvironment } from "./lib/config.ts";
import {
setCurrentEnv,
Expand All @@ -37,7 +38,7 @@ import {
isPromptExitError,
throwUsageError,
} from "./lib/errors.ts";
import { clerkHelpConfig } from "./lib/help.ts";
import { clerkHelpConfig, formatExamplesBlock, type Example } from "./lib/help.ts";
import { isAgent } from "./mode.ts";
import { log } from "./lib/log.ts";
import { maybeNotifyUpdate, getCurrentVersion } from "./lib/update-check.ts";
Expand Down Expand Up @@ -69,6 +70,7 @@ const registrants: CommandRegistrant[] = [
registerCompletion,
registerUpdate,
registerDeploy,
registerWebhooks,
registerExtras,
];

Expand Down Expand Up @@ -234,11 +236,14 @@ export async function runProgram(

if (error instanceof CliError) {
if (isAgent() && error.code) {
outputJsonError(error.code, error.message, error.docsUrl);
outputJsonError(error.code, error.message, error.docsUrl, undefined, error.examples);
} else {
if (error.message) {
log.error(error.message);
}
if (error.examples?.length) {
log.info(`\n${formatExamplesBlock(error.examples)}`);
}
if (error.docsUrl) {
log.info(`\nFor more information, see: ${error.docsUrl}`);
}
Expand Down Expand Up @@ -303,23 +308,28 @@ interface ApiErrorEntry {
}

/** Output a structured JSON error to stderr for agent/CI consumption. */
function outputJsonError(
export function outputJsonError(
code: string,
message: string,
docsUrl?: string,
errors?: ApiErrorEntry[],
examples?: Example[],
): void {
const payload: {
error: {
code: string;
message: string;
docsUrl?: string;
errors?: ApiErrorEntry[];
// Raw {command, description} pairs (not the ANSI-formatted human block) so
// an agent can read the runnable fix and retry.
examples?: Example[];
};
} = {
error: { code, message },
};
if (docsUrl) payload.error.docsUrl = docsUrl;
if (errors?.length) payload.error.errors = errors;
if (examples?.length) payload.error.examples = examples;
log.raw(JSON.stringify(payload));
}
6 changes: 4 additions & 2 deletions packages/cli-core/src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
#!/usr/bin/env bun
import { createProgram, runProgram } from "./cli-program.ts";
import { EXIT_CODE } from "./lib/errors.ts";
process.on("SIGINT", () => process.exit(EXIT_CODE.SIGINT));
import { CLI_SIGINT_HANDLER } from "./lib/signals.ts";
// Named handler (not an inline arrow) so `webhooks listen` can removeListener it
// to install its own graceful-drain SIGINT handling.
process.on("SIGINT", CLI_SIGINT_HANDLER);

// Fast path for shell completion — intercept before Commander parses
// to avoid validation errors on partial input from Tab presses.
Expand Down
87 changes: 87 additions & 0 deletions packages/cli-core/src/commands/webhooks/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# `clerk webhooks` (V1)

The PLAPI-free slice of the webhooks toolkit: a local relay tunnel plus offline
signature verification. Neither subcommand calls the Clerk API, requires auth, or
needs a linked project.

> **No Clerk API calls.** `listen` talks only to the Svix relay
> (`wss://api.relay.svix.com`); `verify` is pure local HMAC. There is no
> PLAPI/BAPI dependency in this command group.

## The flow

```sh
clerk webhooks token # 1. mint a stable token
clerk webhooks listen --token "$(clerk webhooks token)" --forward-to … # 2. stream to your app
clerk webhooks verify --secret whsec_... --delivery @event.json # 3. verify a delivery
```

## `clerk webhooks token`

Generate a valid relay token (`c_` + 10 base62 chars) for `listen --token`. The
bare token prints to **stdout** so it pipes cleanly; human mode adds a usage hint
on stderr (which never pollutes the pipe).

```sh
clerk webhooks token # → c_AbCd123456
clerk webhooks listen --token "$(clerk webhooks token)" # generate + pin in one step
clerk webhooks token --json # → {"token":"c_AbCd123456"}
```

Why it exists: the `--token` format is exact (`c_` + **10** base62 chars), so this
removes the guesswork of hand-writing one.

## `clerk webhooks listen`

Open a standalone Svix relay tunnel, print a stable inbox URL, and forward each
delivery to a local handler.

```sh
clerk webhooks listen --forward-to http://localhost:3000/api/webhooks
```

| Option | Description |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `--forward-to <url>` | **Required.** Local URL to POST deliveries to. |
| `--token <c_token>` | Pin the relay token so the inbox URL stays fixed across machines. `c_` + 10 base62 chars (gen with `webhooks token`). |
| `--header <k:v>` | Extra header for the forwarded request. Repeat the flag to send multiple headers. `svix-*` headers can't be overridden. |
| `--json` | Emit NDJSON: one `ready` line then one `event` line per delivery (pipe into a file for `webhooks verify --delivery`). |

**Pin your URL.** Without `--token`, the relay token is generated for you and
persisted locally — but it isn't a guaranteed-stable handle (it can differ on a
new machine, a cleared config, or a rare token collision). When you run `listen`
**without** `--token`, the banner warns you and prints the exact `--token` to pass
next time. For a fixed, shareable URL, always pin it:

```sh
clerk webhooks listen --token "$(clerk webhooks token)" --forward-to http://localhost:3000/api/webhooks
```

**No verification.** Without the backend there is no per-endpoint signing secret,
so deliveries are forwarded as-is. The original `svix-*` headers are preserved on
the forwarded request, so your handler can still verify against the signing secret
of the dashboard endpoint you point at the inbox URL.

**Ready line schema (`--json`):**
`{ "type": "ready", "relay_url": "https://webhooks.clerk.com/in/c_AbCd123456/", "forward_to": "http://localhost:3000/api/webhooks" }` — emitted once, then one
`event` line per delivery (and a `{ "type": "reconnecting" }` line if the relay
connection drops).
Comment thread
rafa-thayto marked this conversation as resolved.

## `clerk webhooks verify`

Verify a Svix webhook signature locally — HMAC-SHA256 over
`{id}.{timestamp}.{payload}`, constant-time matched against every `v1,<base64>`
entry in the header (any match wins, covering the 24h rotation grace window).

```sh
# From a saved `listen` event line:
clerk webhooks verify --secret whsec_... --delivery @event.json

# From the four explicit header values:
clerk webhooks verify --secret whsec_... --payload @body.json \
--id msg_2xyz --timestamp 1717935000 --signature v1,abc...
```

`--secret` is always required. `--payload`/`--delivery` take `@file` or `-` for
stdin (inline values get mangled by shells). Explicit flags override `--delivery`
fields.
Loading