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
2 changes: 1 addition & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@
"ignore": ["@clerk/cli-core"],
"snapshot": {
"useCalculatedVersion": true,
"prereleaseTemplate": "{tag}.v{datetime}"
"prereleaseTemplate": "{tag}.{commit}"
}
}
9 changes: 9 additions & 0 deletions .changeset/roomy-arrhinceratops.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"clerk": patch
---

Expand `--verbose` debug output across the CLI and surface silent environment fallbacks.

- Every outbound HTTP call (platform API, backend API, OAuth, npm registry) now logs its URL, method, status, and response body on error under `--verbose`.
- New debug coverage for the credential store, config file I/O, environment resolution, auth callback server, git detection, framework detection, autolink, and package-manager runner probing.
- Warn without `--verbose` when the saved environment is not available in the current binary, instead of silently falling back to production.
78 changes: 78 additions & 0 deletions .claude/rules/debug-logging.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
---
description: Debug logging conventions — when to add log.debug(), format, and the loggedFetch helper
paths:
- "packages/cli-core/src/**/*.ts"
alwaysApply: false
---

`log.debug()` is the CLI's `--verbose` channel. It exists for one job: give a Clerk engineer (or an AI agent helping them) enough context to diagnose a failed command by re-running it with `--verbose`. Well-placed debug logs make the difference between "Failed to list applications (500)" and a complete trail from config file → environment resolution → credential source → request URL → response body.

## When to add a debug log

Instrument **boundaries** where information crosses in or out of the CLI's process:

- **HTTP requests** — URL, method, status, response body on error
- **File I/O** — the path being read, written, or checked
- **External tool execution** — the command, exit code, and output on failure
- **Decisions with multiple sources** — env var vs. config file vs. hardcoded default (which won?)
- **Cache read/write state transitions** — hit/miss, stale, refreshed

Skip: pure in-memory computation, loop internals, prompt rendering, anything a caller can deduce from the logged inputs/outputs.

## Format

```ts
log.debug(`<namespace>: <message>`);
```

- `<namespace>` — tag the subsystem. Existing tags: `plapi`, `bapi`, `oauth`, `update-check`, `credentials`, `config`, `env`, `auth-server`, `git`, `autolink`, `framework`, `runners`. Add new ones sparingly.
- `<message>` — one line. Put the primary identifier (URL, path, commit) inline, not on a separate line.

Examples:

```ts
log.debug(`plapi: GET ${url}`);
log.debug(`plapi: ${response.status} GET ${url} — ${body}`);
log.debug(`credentials: found token in keyring (account=${account})`);
log.debug(`git: toplevel=${toplevel}, remote=${remote}`);
log.debug(`framework: detected "next" via dependency in package.json`);
```

Do not use `log.withTag()` for debug output. `withTag()` produces `[tag] msg` brackets which visually compete with the dim styling of debug lines. Reserve `withTag()` for `info`/`warn`/`error` output in complex flows where scoped context helps humans scan the stream.

## HTTP calls go through `loggedFetch`

All outbound HTTP in library code uses `loggedFetch` from `src/lib/fetch.ts`. It emits the `namespace: METHOD url` log before the request and the `namespace: status METHOD url — body` log on non-ok responses. The caller keeps ownership of error construction and body parsing:

```ts
import { loggedFetch } from "../lib/fetch.ts";

const response = await loggedFetch(url, {
tag: "plapi",
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) {
const body = await response.text();
throw new PlapiError(response.status, body, url.toString());
}
return response.json();
```

**Never call `fetch()` directly in library code.** Tests are exempt. If a client has many call sites with the same auth + error pattern (e.g. plapi's six endpoints), factor a local wrapper that calls `loggedFetch` — don't duplicate the pattern six times and don't add per-call-site `log.debug` lines.

## Noise control

Debug logs only fire with `--verbose`, but when the user opts in they should be **useful**, not spammy. If a line would fire more than ~5 times per command invocation with identical content, either:

1. **Cache the underlying call** so the log fires once. See `git.ts` `getGitRepoInfo()` (module-level cache).
2. **Gate behind a module-level `let xLogged = false`** flag that flips on first emit. See `environment.ts` `profilesSourceLogged`.

Per-request logs (each HTTP call, each credential lookup) are fine as-is — they're diagnostic even when identical, because timing between them matters.

## One log per event

When a call is already logged inside `loggedFetch` (or any other primitive), don't log it again in the caller. Callers add context the primitive doesn't have — e.g. which retry attempt, which config source, which environment resolution branch — not duplicate what was already emitted.

## When to emit more than debug

If a code path is silently taking a non-obvious fallback that would confuse users (e.g. "saved environment not available, falling back to production"), emit `log.warn()` too — not just `log.debug()`. Users shouldn't need `--verbose` to learn that their configured state was ignored.
2 changes: 2 additions & 0 deletions .claude/rules/logging.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ Adjust the relative path to `lib/log.ts` based on the file's location under `pac
log.debug(`Fetching instance ${instanceId}…`);
```

See [`.claude/rules/debug-logging.md`](./debug-logging.md) for the full rule: namespace format, the `loggedFetch` helper for HTTP calls, and noise-control patterns.

## Tagged loggers

`log.withTag()` adds scoped context in complex flows:
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,7 @@ jobs:
version: ${{ needs.snapshot.outputs.version }}
ref: ${{ needs.snapshot.outputs.sha }}
artifact-prefix: clerk-snapshot
secrets: inherit
Comment thread
wyattjoh marked this conversation as resolved.

snapshot-sign-macos:
needs: [snapshot, snapshot-build]
Expand Down
18 changes: 16 additions & 2 deletions packages/cli-core/src/cli-program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,13 @@ import { doctor } from "./commands/doctor/index.ts";
import { switchEnv } from "./commands/switch-env/index.ts";
import { openDashboard } from "./commands/open/index.ts";
import { getEnvironment } from "./lib/config.ts";
import { setCurrentEnv, isValidEnv, getCurrentEnvName } from "./lib/environment.ts";
import {
setCurrentEnv,
isValidEnv,
getCurrentEnvName,
getAvailableEnvs,
getPlapiBaseUrl,
} from "./lib/environment.ts";
import { completion, SUPPORTED_SHELLS } from "./commands/completion/index.ts";
import { FRAMEWORK_NAMES } from "./lib/framework.ts";
import {
Expand Down Expand Up @@ -67,7 +73,15 @@ export function createProgram() {
// Initialize the active environment from persisted config
const envName = await getEnvironment();
if (envName && isValidEnv(envName)) {
setCurrentEnv(envName);
setCurrentEnv(envName); // logs env + platformApiUrl
} else {
if (envName) {
log.warn(
`Saved environment "${envName}" is not available in this binary. Falling back to production.`,
);
log.warn(`Available environments: ${getAvailableEnvs().join(", ")}`);
}
log.debug(`env: active environment is "production" (platformApiUrl=${getPlapiBaseUrl()})`);
}
Comment on lines +76 to 85
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fallback branch doesn’t actually set production as active env.

When envName is invalid, this path only logs fallback but never updates the active environment state. Since environment state is module-scoped and mutated via setCurrentEnv(), a reused process can keep a stale non-production env from a previous invocation.

Suggested fix
-    if (envName && isValidEnv(envName)) {
-      setCurrentEnv(envName); // logs env + platformApiUrl
-    } else {
+    if (envName && isValidEnv(envName)) {
+      setCurrentEnv(envName); // logs env + platformApiUrl
+    } else {
       if (envName) {
         log.warn(
           `Saved environment "${envName}" is not available in this binary. Falling back to production.`,
         );
         log.warn(`Available environments: ${getAvailableEnvs().join(", ")}`);
       }
-      log.debug(`env: active environment is "production" (platformApiUrl=${getPlapiBaseUrl()})`);
+      setCurrentEnv("production"); // logs env + platformApiUrl
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
setCurrentEnv(envName); // logs env + platformApiUrl
} else {
if (envName) {
log.warn(
`Saved environment "${envName}" is not available in this binary. Falling back to production.`,
);
log.warn(`Available environments: ${getAvailableEnvs().join(", ")}`);
}
log.debug(`env: active environment is "production" (platformApiUrl=${getPlapiBaseUrl()})`);
}
setCurrentEnv(envName); // logs env + platformApiUrl
} else {
if (envName) {
log.warn(
`Saved environment "${envName}" is not available in this binary. Falling back to production.`,
);
log.warn(`Available environments: ${getAvailableEnvs().join(", ")}`);
}
setCurrentEnv("production"); // logs env + platformApiUrl
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli-core/src/cli-program.ts` around lines 76 - 85, The fallback
branch logs that it’s falling back to production but never updates the
module-scoped environment, leaving a stale env active; call
setCurrentEnv("production") when envName is invalid (inside the else path) so
the active environment state is updated, then keep the existing log.debug that
uses getPlapiBaseUrl() and list available envs via getAvailableEnvs() to
preserve diagnostic messages.


// Print environment banner to stderr when not on production,
Expand Down
4 changes: 3 additions & 1 deletion packages/cli-core/src/commands/api/bapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import { getBapiBaseUrl } from "../../lib/environment.ts";
import { BapiError } from "../../lib/errors.ts";
import { loggedFetch } from "../../lib/fetch.ts";

export interface BapiResponse {
status: number;
Expand Down Expand Up @@ -38,7 +39,8 @@ export async function bapiRequest(options: {
headers["Content-Type"] = "application/json";
}

const response = await fetch(url, {
const response = await loggedFetch(url, {
tag: "bapi",
method: options.method,
headers,
body: options.body,
Expand Down
61 changes: 31 additions & 30 deletions packages/cli-core/src/commands/config/push.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,11 +171,11 @@ describe("config push", () => {
let capturedMethod = "";
let capturedBody = "";
stubFetch(async (_input, init) => {
if (init?.method) {
if (init?.method && init.method !== "GET") {
capturedMethod = init.method;
capturedBody = init.body as string;
}
const body = init?.method ? mockResponse : currentConfig;
const body = init?.method && init.method !== "GET" ? mockResponse : currentConfig;
return new Response(JSON.stringify(body), { status: 200 });
});

Expand All @@ -194,7 +194,7 @@ describe("config push", () => {
let capturedUrl = "";
stubFetch(async (input, init) => {
const url = input.toString();
if (init?.method) {
if (init?.method && init.method !== "GET") {
capturedUrl = url;
return new Response(JSON.stringify(mockResponse), { status: 200 });
}
Expand All @@ -221,8 +221,8 @@ describe("config push", () => {
test("patch reads config from --file", async () => {
let capturedBody = "";
stubFetch(async (_input, init) => {
if (init?.method) capturedBody = init.body as string;
const body = init?.method ? mockResponse : currentConfig;
if (init?.method && init.method !== "GET") capturedBody = init.body as string;
const body = init?.method && init.method !== "GET" ? mockResponse : currentConfig;
return new Response(JSON.stringify(body), { status: 200 });
});

Expand Down Expand Up @@ -266,8 +266,8 @@ describe("config push", () => {
test("put sends PUT method", async () => {
let capturedMethod = "";
stubFetch(async (_input, init) => {
if (init?.method) capturedMethod = init.method;
const body = init?.method ? mockResponse : currentConfig;
if (init?.method && init.method !== "GET") capturedMethod = init.method;
const body = init?.method && init.method !== "GET" ? mockResponse : currentConfig;
return new Response(JSON.stringify(body), { status: 200 });
});

Expand Down Expand Up @@ -297,8 +297,8 @@ describe("config push", () => {
test("put strips config_version from payload before sending", async () => {
let capturedBody = "";
stubFetch(async (_input, init) => {
if (init?.method) capturedBody = init.body as string;
const body = init?.method ? mockResponse : currentConfig;
if (init?.method && init.method !== "GET") capturedBody = init.body as string;
const body = init?.method && init.method !== "GET" ? mockResponse : currentConfig;
return new Response(JSON.stringify(body), { status: 200 });
});

Expand All @@ -318,8 +318,8 @@ describe("config push", () => {
test("patch strips config_version from payload before sending", async () => {
let capturedBody = "";
stubFetch(async (_input, init) => {
if (init?.method) capturedBody = init.body as string;
const body = init?.method ? mockResponse : currentConfig;
if (init?.method && init.method !== "GET") capturedBody = init.body as string;
const body = init?.method && init.method !== "GET" ? mockResponse : currentConfig;
return new Response(JSON.stringify(body), { status: 200 });
});

Expand All @@ -341,8 +341,8 @@ describe("config push", () => {
test("patch sends ?destructive=true when --destructive is set", async () => {
let capturedUrl = "";
stubFetch(async (input, init) => {
if (init?.method) capturedUrl = input.toString();
const body = init?.method ? mockResponse : currentConfig;
if (init?.method && init.method !== "GET") capturedUrl = input.toString();
const body = init?.method && init.method !== "GET" ? mockResponse : currentConfig;
return new Response(JSON.stringify(body), { status: 200 });
});

Expand All @@ -363,8 +363,8 @@ describe("config push", () => {
test("put sends ?destructive=true when --destructive is set", async () => {
let capturedUrl = "";
stubFetch(async (input, init) => {
if (init?.method) capturedUrl = input.toString();
const body = init?.method ? mockResponse : currentConfig;
if (init?.method && init.method !== "GET") capturedUrl = input.toString();
const body = init?.method && init.method !== "GET" ? mockResponse : currentConfig;
return new Response(JSON.stringify(body), { status: 200 });
});

Expand All @@ -385,8 +385,8 @@ describe("config push", () => {
test("does not send ?destructive=true by default", async () => {
let capturedUrl = "";
stubFetch(async (input, init) => {
if (init?.method) capturedUrl = input.toString();
const body = init?.method ? mockResponse : currentConfig;
if (init?.method && init.method !== "GET") capturedUrl = input.toString();
const body = init?.method && init.method !== "GET" ? mockResponse : currentConfig;
return new Response(JSON.stringify(body), { status: 200 });
});

Expand All @@ -405,8 +405,8 @@ describe("config push", () => {
test("patch skips API call when payload matches current config", async () => {
let mutatingCallMade = false;
stubFetch(async (_input, init) => {
if (init?.method) mutatingCallMade = true;
const body = init?.method ? mockResponse : currentConfig;
if (init?.method && init.method !== "GET") mutatingCallMade = true;
const body = init?.method && init.method !== "GET" ? mockResponse : currentConfig;
return new Response(JSON.stringify(body), { status: 200 });
});

Expand All @@ -425,7 +425,7 @@ describe("config push", () => {
test("put skips API call when payload matches current config", async () => {
let mutatingCallMade = false;
stubFetch(async (_input, init) => {
if (init?.method) mutatingCallMade = true;
if (init?.method && init.method !== "GET") mutatingCallMade = true;
return new Response(JSON.stringify(currentConfig), { status: 200 });
});

Expand All @@ -447,7 +447,7 @@ describe("config push", () => {
let mutatingCallMade = false;
const configWithVersion = { ...currentConfig, config_version: 42 };
stubFetch(async (_input, init) => {
if (init?.method) mutatingCallMade = true;
if (init?.method && init.method !== "GET") mutatingCallMade = true;
return new Response(JSON.stringify(configWithVersion), { status: 200 });
});

Expand All @@ -468,8 +468,8 @@ describe("config push", () => {
test("targets development instance by default", async () => {
let requestedUrl = "";
stubFetch(async (input, init) => {
if (init?.method) requestedUrl = input.toString();
const body = init?.method ? mockResponse : currentConfig;
if (init?.method && init.method !== "GET") requestedUrl = input.toString();
const body = init?.method && init.method !== "GET" ? mockResponse : currentConfig;
return new Response(JSON.stringify(body), { status: 200 });
});

Expand All @@ -486,8 +486,8 @@ describe("config push", () => {
test("--instance prod targets production instance", async () => {
let requestedUrl = "";
stubFetch(async (input, init) => {
if (init?.method) requestedUrl = input.toString();
const body = init?.method ? mockResponse : currentConfig;
if (init?.method && init.method !== "GET") requestedUrl = input.toString();
const body = init?.method && init.method !== "GET" ? mockResponse : currentConfig;
return new Response(JSON.stringify(body), { status: 200 });
});

Expand All @@ -504,8 +504,8 @@ describe("config push", () => {
test("--instance with literal ID passes through", async () => {
let requestedUrl = "";
stubFetch(async (input, init) => {
if (init?.method) requestedUrl = input.toString();
const body = init?.method ? mockResponse : currentConfig;
if (init?.method && init.method !== "GET") requestedUrl = input.toString();
const body = init?.method && init.method !== "GET" ? mockResponse : currentConfig;
return new Response(JSON.stringify(body), { status: 200 });
});

Expand Down Expand Up @@ -562,7 +562,8 @@ describe("config push", () => {

test("handles API errors gracefully", async () => {
stubFetch(async (_input, init) => {
if (!init?.method) return new Response(JSON.stringify(currentConfig), { status: 200 });
if (!init?.method || init.method === "GET")
return new Response(JSON.stringify(currentConfig), { status: 200 });
return new Response("Bad Request", { status: 400 });
});

Expand Down Expand Up @@ -591,8 +592,8 @@ describe("config push", () => {
test("--json takes priority over --file", async () => {
let capturedBody = "";
stubFetch(async (_input, init) => {
if (init?.method) capturedBody = init.body as string;
const body = init?.method ? mockResponse : currentConfig;
if (init?.method && init.method !== "GET") capturedBody = init.body as string;
const body = init?.method && init.method !== "GET" ? mockResponse : currentConfig;
return new Response(JSON.stringify(body), { status: 200 });
});

Expand Down
Loading
Loading