From 60cd77a68d67c6d76f59ee636d388e49571fe9f9 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 20 Apr 2026 09:45:14 -0600 Subject: [PATCH 01/10] feat(debug): add verbose debug output for network, config, and credentials When running with --verbose, log.debug() calls now surface: - plapi: every outgoing request URL and method, plus status + response body on error - credentials: which keyring account or fallback file is used for each token lookup - config: the config file path on every read/write - env: the active environment name and resolved platformApiUrl on startup --- .changeset/roomy-arrhinceratops.md | 5 ++ packages/cli-core/src/lib/config.ts | 11 ++-- packages/cli-core/src/lib/credential-store.ts | 54 ++++++++++++++----- packages/cli-core/src/lib/environment.ts | 4 ++ packages/cli-core/src/lib/plapi.ts | 19 ++++++- 5 files changed, 76 insertions(+), 17 deletions(-) create mode 100644 .changeset/roomy-arrhinceratops.md diff --git a/.changeset/roomy-arrhinceratops.md b/.changeset/roomy-arrhinceratops.md new file mode 100644 index 00000000..6c3cfa58 --- /dev/null +++ b/.changeset/roomy-arrhinceratops.md @@ -0,0 +1,5 @@ +--- +"clerk": patch +--- + +Add verbose debug output for network requests, config file access, and credential store lookups. diff --git a/packages/cli-core/src/lib/config.ts b/packages/cli-core/src/lib/config.ts index c5995240..c32820a9 100644 --- a/packages/cli-core/src/lib/config.ts +++ b/packages/cli-core/src/lib/config.ts @@ -9,6 +9,7 @@ import { CONFIG_FILE } from "./constants.ts"; import { getCurrentEnvName } from "./environment.ts"; import { getGitRepoIdentifier, getGitNormalizedRemote } from "./git.ts"; import { CliError, ERROR_CODE } from "./errors.ts"; +import { log } from "./log.ts"; let overrideConfigFile: string | undefined; @@ -69,7 +70,9 @@ function migrateRawConfig(raw: Record): ClerkConfig { } export async function readConfig(): Promise { - const file = Bun.file(configFile()); + const path = configFile(); + log.debug(`config: reading ${path}`); + const file = Bun.file(path); if (!(await file.exists())) return defaultConfig(); try { const raw = (await file.json()) as Record; @@ -80,8 +83,10 @@ export async function readConfig(): Promise { } export async function writeConfig(config: ClerkConfig): Promise { - await mkdir(dirname(configFile()), { recursive: true }); - await Bun.write(configFile(), JSON.stringify(config, null, 2) + "\n"); + const path = configFile(); + log.debug(`config: writing ${path}`); + await mkdir(dirname(path), { recursive: true }); + await Bun.write(path, JSON.stringify(config, null, 2) + "\n"); } export async function getAuth(): Promise { diff --git a/packages/cli-core/src/lib/credential-store.ts b/packages/cli-core/src/lib/credential-store.ts index 61ffc4f1..ebf3b9fd 100644 --- a/packages/cli-core/src/lib/credential-store.ts +++ b/packages/cli-core/src/lib/credential-store.ts @@ -11,6 +11,7 @@ import { dirname } from "node:path"; import { mkdir, chmod, unlink } from "node:fs/promises"; import { CREDENTIALS_FILE } from "./constants.ts"; import { getCurrentEnvName } from "./environment.ts"; +import { log } from "./log.ts"; export const KEYCHAIN_SERVICE = "clerk-cli"; export const KEYCHAIN_ACCOUNT = "oauth-access-token"; @@ -43,22 +44,35 @@ async function getKeyring(): Promise { async function keyringStore(token: string): Promise { const mod = await getKeyring(); if (!mod) return false; + const account = keychainAccount(); + log.debug( + `credentials: storing token in keyring (service=${KEYCHAIN_SERVICE}, account=${account})`, + ); try { - const entry = new mod.Entry(KEYCHAIN_SERVICE, keychainAccount()); + const entry = new mod.Entry(KEYCHAIN_SERVICE, account); entry.setPassword(token); return true; } catch { + log.debug("credentials: failed to store token in keyring"); return false; } } async function keyringGet(): Promise { const mod = await getKeyring(); - if (!mod) return null; + if (!mod) { + log.debug("credentials: keyring not available"); + return null; + } + const account = keychainAccount(); + log.debug(`credentials: checking keyring (service=${KEYCHAIN_SERVICE}, account=${account})`); try { - const entry = new mod.Entry(KEYCHAIN_SERVICE, keychainAccount()); - return entry.getPassword(); + const entry = new mod.Entry(KEYCHAIN_SERVICE, account); + const token = entry.getPassword(); + log.debug(`credentials: ${token ? "found token in keyring" : "no token in keyring"}`); + return token; } catch { + log.debug("credentials: keyring lookup failed"); return null; } } @@ -66,8 +80,12 @@ async function keyringGet(): Promise { async function keyringDelete(): Promise { const mod = await getKeyring(); if (!mod) return false; + const account = keychainAccount(); + log.debug( + `credentials: deleting token from keyring (service=${KEYCHAIN_SERVICE}, account=${account})`, + ); try { - const entry = new mod.Entry(KEYCHAIN_SERVICE, keychainAccount()); + const entry = new mod.Entry(KEYCHAIN_SERVICE, account); entry.deletePassword(); return true; } catch { @@ -76,22 +94,32 @@ async function keyringDelete(): Promise { } async function fileStore(token: string): Promise { - const file = credentialsFile(); - await mkdir(dirname(file), { recursive: true }); - await Bun.write(file, token); - await chmod(file, 0o600); + const path = credentialsFile(); + log.debug(`credentials: storing token in file ${path}`); + await mkdir(dirname(path), { recursive: true }); + await Bun.write(path, token); + await chmod(path, 0o600); } async function fileGet(): Promise { - const file = Bun.file(credentialsFile()); - if (!(await file.exists())) return null; + const path = credentialsFile(); + log.debug(`credentials: checking file ${path}`); + const file = Bun.file(path); + if (!(await file.exists())) { + log.debug("credentials: credentials file not found"); + return null; + } const content = await file.text(); - return content.trim() || null; + const token = content.trim() || null; + log.debug(`credentials: ${token ? "found token in file" : "credentials file is empty"}`); + return token; } async function fileDelete(): Promise { + const path = credentialsFile(); try { - await unlink(credentialsFile()); + log.debug(`credentials: deleting credentials file ${path}`); + await unlink(path); } catch { // File doesn't exist, nothing to delete } diff --git a/packages/cli-core/src/lib/environment.ts b/packages/cli-core/src/lib/environment.ts index 537880c8..787e5e95 100644 --- a/packages/cli-core/src/lib/environment.ts +++ b/packages/cli-core/src/lib/environment.ts @@ -11,6 +11,7 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; +import { log } from "./log.ts"; export interface EnvProfileConfig { oauthClientId: string; @@ -67,6 +68,9 @@ export function setCurrentEnv(name: string): void { throw new Error(`Unknown environment "${name}". Available environments: ${available}`); } currentEnvName = name; + const profile = profiles[name]!; + const platformApiUrl = process.env.CLERK_PLATFORM_API_URL ?? profile.platformApiUrl; + log.debug(`env: active environment is "${name}" (platformApiUrl=${platformApiUrl})`); } /** Get the name of the active environment. Defaults to "production". */ diff --git a/packages/cli-core/src/lib/plapi.ts b/packages/cli-core/src/lib/plapi.ts index bfc8c8ff..d97d729d 100644 --- a/packages/cli-core/src/lib/plapi.ts +++ b/packages/cli-core/src/lib/plapi.ts @@ -6,6 +6,7 @@ import { getPlapiBaseUrl } from "./environment.ts"; import { getToken } from "./credential-store.ts"; import { CliError, PlapiError, ERROR_CODE } from "./errors.ts"; +import { log } from "./log.ts"; /** * Validate that a key has the expected prefix and suggest the correct key type @@ -32,12 +33,16 @@ export async function getAuthToken(): Promise { const key = process.env.CLERK_PLATFORM_API_KEY; if (key) { validateKeyPrefix(key, "ak_"); + log.debug("plapi: using CLERK_PLATFORM_API_KEY for auth"); return key; } // Fall back to OAuth access token from `clerk auth login` const oauthToken = await getToken(); - if (oauthToken) return oauthToken; + if (oauthToken) { + log.debug("plapi: using OAuth token from credential store for auth"); + return oauthToken; + } throw new CliError("Not authenticated. Run `clerk auth login` or set CLERK_PLATFORM_API_KEY", { code: ERROR_CODE.AUTH_REQUIRED, @@ -60,6 +65,7 @@ export async function fetchInstanceConfigSchema( url.searchParams.append("keys", key); } } + log.debug(`plapi: GET ${url}`); const response = await fetch(url, { headers: { Authorization: `Bearer ${token}`, @@ -69,6 +75,7 @@ export async function fetchInstanceConfigSchema( if (!response.ok) { const body = await response.text(); + log.debug(`plapi: ${response.status} GET ${url} — ${body}`); throw new PlapiError(response.status, body, url.toString()); } @@ -90,6 +97,7 @@ export async function fetchInstanceConfig( url.searchParams.append("keys", key); } } + log.debug(`plapi: GET ${url}`); const response = await fetch(url, { headers: { Authorization: `Bearer ${token}`, @@ -99,6 +107,7 @@ export async function fetchInstanceConfig( if (!response.ok) { const body = await response.text(); + log.debug(`plapi: ${response.status} GET ${url} — ${body}`); throw new PlapiError(response.status, body, url.toString()); } @@ -122,6 +131,7 @@ export async function fetchApplication(applicationId: string): Promise { const token = await getAuthToken(); const url = new URL("/v1/platform/applications", getPlapiBaseUrl()); + log.debug(`plapi: POST ${url}`); const response = await fetch(url, { method: "POST", headers: { @@ -199,6 +213,7 @@ export async function createApplication(name: string): Promise { if (!response.ok) { const body = await response.text(); + log.debug(`plapi: ${response.status} POST ${url} — ${body}`); throw new PlapiError(response.status, body, url.toString()); } @@ -208,6 +223,7 @@ export async function createApplication(name: string): Promise { export async function listApplications(): Promise { const token = await getAuthToken(); const url = new URL("/v1/platform/applications", getPlapiBaseUrl()); + log.debug(`plapi: GET ${url}`); const response = await fetch(url, { headers: { Authorization: `Bearer ${token}`, @@ -217,6 +233,7 @@ export async function listApplications(): Promise { if (!response.ok) { const body = await response.text(); + log.debug(`plapi: ${response.status} GET ${url} — ${body}`); throw new PlapiError(response.status, body, url.toString()); } From c401b7798693ac34f31a5681a3f3a28362e3a5a1 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 20 Apr 2026 10:01:47 -0600 Subject: [PATCH 02/10] feat(debug): add env fallback warning and richer auth debug context - Warn (without --verbose) when the saved environment is not available in the current binary and the CLI silently falls back to production; this surfaces the root cause of 403s on snapshot builds that lack non-production profiles - Always log the active environment in verbose mode, including the production default case where setCurrentEnv is never called - Include env name and target URL in the getAuthToken() debug lines so credential source and request target are visible in one place - Log the profiles source (compile-time, file, or defaults) once per process so it is clear why certain environments are unavailable --- packages/cli-core/src/cli-program.ts | 18 ++++++++++++++-- packages/cli-core/src/lib/environment.ts | 26 ++++++++++++++++++++++-- packages/cli-core/src/lib/plapi.ts | 10 ++++++--- 3 files changed, 47 insertions(+), 7 deletions(-) diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 12390679..4fb7cdbd 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -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 { @@ -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()})`); } // Print environment banner to stderr when not on production, diff --git a/packages/cli-core/src/lib/environment.ts b/packages/cli-core/src/lib/environment.ts index 787e5e95..7ed056c3 100644 --- a/packages/cli-core/src/lib/environment.ts +++ b/packages/cli-core/src/lib/environment.ts @@ -40,7 +40,12 @@ function loadFileProfiles(): Record | undefined { for (const path of candidates) { try { const content = readFileSync(path, "utf-8"); - return JSON.parse(content); + const profiles = JSON.parse(content) as Record; + if (!profilesSourceLogged) { + profilesSourceLogged = true; + log.debug(`env: profiles from ${path} (${Object.keys(profiles).join(", ")})`); + } + return profiles; } catch { continue; } @@ -50,12 +55,29 @@ function loadFileProfiles(): Record | undefined { function getProfiles(): Record { if (typeof CLI_ENV_PROFILES !== "undefined" && CLI_ENV_PROFILES) { + if (!profilesSourceLogged) { + profilesSourceLogged = true; + log.debug( + `env: profiles from compile-time CLI_ENV_PROFILES (${Object.keys(CLI_ENV_PROFILES).join(", ")})`, + ); + } return CLI_ENV_PROFILES; } - return loadFileProfiles() ?? DEFAULT_PROFILES; + const fileProfiles = loadFileProfiles(); + if (fileProfiles) { + return fileProfiles; + } + if (!profilesSourceLogged) { + profilesSourceLogged = true; + log.debug( + `env: profiles from defaults — no CLI_ENV_PROFILES and no .env-profiles.json (${Object.keys(DEFAULT_PROFILES).join(", ")})`, + ); + } + return DEFAULT_PROFILES; } let currentEnvName: string | undefined; +let profilesSourceLogged = false; /** * Set the active environment. Called during CLI initialization from config, diff --git a/packages/cli-core/src/lib/plapi.ts b/packages/cli-core/src/lib/plapi.ts index d97d729d..191a3758 100644 --- a/packages/cli-core/src/lib/plapi.ts +++ b/packages/cli-core/src/lib/plapi.ts @@ -3,7 +3,7 @@ * Thin HTTP wrapper for Clerk's Platform API endpoints. */ -import { getPlapiBaseUrl } from "./environment.ts"; +import { getPlapiBaseUrl, getCurrentEnvName } from "./environment.ts"; import { getToken } from "./credential-store.ts"; import { CliError, PlapiError, ERROR_CODE } from "./errors.ts"; import { log } from "./log.ts"; @@ -33,14 +33,18 @@ export async function getAuthToken(): Promise { const key = process.env.CLERK_PLATFORM_API_KEY; if (key) { validateKeyPrefix(key, "ak_"); - log.debug("plapi: using CLERK_PLATFORM_API_KEY for auth"); + log.debug( + `plapi: using CLERK_PLATFORM_API_KEY for auth (env=${getCurrentEnvName()}, target=${getPlapiBaseUrl()})`, + ); return key; } // Fall back to OAuth access token from `clerk auth login` const oauthToken = await getToken(); if (oauthToken) { - log.debug("plapi: using OAuth token from credential store for auth"); + log.debug( + `plapi: using OAuth token from credential store for auth (env=${getCurrentEnvName()}, target=${getPlapiBaseUrl()})`, + ); return oauthToken; } From 8b1492a005b6a2beedda050b05d2f53826f06c83 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 20 Apr 2026 10:04:25 -0600 Subject: [PATCH 03/10] perf(releaser): publish platform packages in parallel Convert run/isPublished/publish in scripts/lib/npm.ts from Bun.spawnSync to async Bun.spawn, then publish the 8 platform packages concurrently via Promise.all. The wrapper package is still published after all platform packages complete. --- .changeset/tired-pillows-divide.md | 2 ++ scripts/check-release.ts | 2 +- scripts/lib/npm.ts | 33 +++++++++++++++--------------- scripts/releaser.ts | 30 ++++++++++++++------------- 4 files changed, 35 insertions(+), 32 deletions(-) create mode 100644 .changeset/tired-pillows-divide.md diff --git a/.changeset/tired-pillows-divide.md b/.changeset/tired-pillows-divide.md new file mode 100644 index 00000000..a845151c --- /dev/null +++ b/.changeset/tired-pillows-divide.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/scripts/check-release.ts b/scripts/check-release.ts index aeb3597d..0659fbb1 100644 --- a/scripts/check-release.ts +++ b/scripts/check-release.ts @@ -7,7 +7,7 @@ const GITHUB_OUTPUT = process.env.GITHUB_OUTPUT; const pkg = await Bun.file(WRAPPER_PKG_PATH).json(); const version: string = pkg.version; -const published = isPublished("clerk", version); +const published = await isPublished("clerk", version); if (published) { console.log(`Version ${version} is already published — skipping stable release.`); diff --git a/scripts/lib/npm.ts b/scripts/lib/npm.ts index c67c21c7..a14a9be9 100644 --- a/scripts/lib/npm.ts +++ b/scripts/lib/npm.ts @@ -1,15 +1,16 @@ /** - * Run a command synchronously, throwing on non-zero exit. + * Run a command asynchronously, throwing on non-zero exit. */ -export function run(cmd: string[], opts?: { cwd?: string }): void { - const result = Bun.spawnSync(cmd, { +export async function run(cmd: string[], opts?: { cwd?: string }): Promise { + const proc = Bun.spawn(cmd, { cwd: opts?.cwd, - stdio: ["ignore", "pipe", "pipe"], + stdio: ["ignore", "ignore", "pipe"], }); - if (result.exitCode !== 0) { - const stderr = result.stderr.toString().trim(); + const exitCode = await proc.exited; + if (exitCode !== 0) { + const stderr = await new Response(proc.stderr).text(); throw new Error( - `${cmd.join(" ")} failed (exit ${result.exitCode})${stderr ? `: ${stderr}` : ""}`, + `${cmd.join(" ")} failed (exit ${exitCode})${stderr.trim() ? `: ${stderr.trim()}` : ""}`, ); } } @@ -18,27 +19,25 @@ export function run(cmd: string[], opts?: { cwd?: string }): void { * Check if a package version is published on npm. * Distinguishes "not found" (E404) from real errors (network, auth). */ -export function isPublished(name: string, version: string): boolean { - const result = Bun.spawnSync(["npm", "view", `${name}@${version}`, "version"], { +export async function isPublished(name: string, version: string): Promise { + const proc = Bun.spawn(["npm", "view", `${name}@${version}`, "version"], { stdio: ["ignore", "pipe", "pipe"], }); - - if (result.exitCode === 0) return true; - - const stderr = result.stderr.toString(); + const exitCode = await proc.exited; + if (exitCode === 0) return true; + const stderr = await new Response(proc.stderr).text(); if (stderr.includes("E404") || stderr.includes("is not in this registry")) { return false; } - - throw new Error(`npm view ${name}@${version} failed (exit ${result.exitCode}): ${stderr.trim()}`); + throw new Error(`npm view ${name}@${version} failed (exit ${exitCode}): ${stderr.trim()}`); } /** * Publish a package directory to npm. */ -export function publish(dir: string, opts: { dryRun: boolean; tag?: string }): void { +export async function publish(dir: string, opts: { dryRun: boolean; tag?: string }): Promise { const flags = ["npm", "publish", "--access", "public", "--provenance", "--ignore-scripts"]; if (opts.tag) flags.push("--tag", opts.tag); if (opts.dryRun) flags.push("--dry-run"); - run(flags, { cwd: dir }); + await run(flags, { cwd: dir }); } diff --git a/scripts/releaser.ts b/scripts/releaser.ts index f72ebc19..a42ea0d1 100644 --- a/scripts/releaser.ts +++ b/scripts/releaser.ts @@ -67,16 +67,18 @@ console.log( await rm(DIST_DIR, { recursive: true, force: true }); -for (const target of targets) { - const name = packageName(target.name); - if (isPublished(name, version)) { - console.log(`Skipping ${name}@${version} (already published)`); - continue; - } - console.log(`Publishing ${name}@${version}...`); - const dir = await generatePlatformPackage(target, version); - publish(dir, { dryRun, tag }); -} +await Promise.all( + targets.map(async (target) => { + const name = packageName(target.name); + if (await isPublished(name, version)) { + console.log(`Skipping ${name}@${version} (already published)`); + return; + } + console.log(`Publishing ${name}@${version}...`); + const dir = await generatePlatformPackage(target, version); + await publish(dir, { dryRun, tag }); + }), +); // Build wrapper package.json for publishing: add optionalDependencies from targets and remove private flag. // This mutation is intentional — the repo omits optionalDependencies while the published package includes them. @@ -92,7 +94,7 @@ try { await Bun.write(WRAPPER_PKG_PATH, JSON.stringify(wrapperPkg, null, 2) + "\n"); const wrapperName = "clerk"; - if (isPublished(wrapperName, version)) { + if (await isPublished(wrapperName, version)) { console.log(`Skipping ${wrapperName}@${version} (already published)`); } else { console.log(`Publishing ${wrapperName}@${version}...`); @@ -122,9 +124,9 @@ if (!tag && !dryRun) { stdio: ["ignore", "pipe", "pipe"], }); if (localTagCheck.exitCode !== 0) { - run(["git", "tag", tagName]); + await run(["git", "tag", tagName]); } - run(["git", "push", "origin", tagName]); + await run(["git", "push", "origin", tagName]); } const releaseViewCheck = Bun.spawnSync(["gh", "release", "view", tagName], { @@ -134,7 +136,7 @@ if (!tag && !dryRun) { console.log(`GitHub Release for ${tagName} already exists, skipping.`); } else { console.log(`Creating GitHub Release for ${tagName}...`); - run(["gh", "release", "create", tagName, "--generate-notes"]); + await run(["gh", "release", "create", tagName, "--generate-notes"]); } } From 5a504c65138fc36fcd6c436c41ebc90b58ca0dde Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 20 Apr 2026 10:19:43 -0600 Subject: [PATCH 04/10] fix(releaser): await wrapper publish so package.json is not restored mid-publish The wrapper publish() call was never awaited. This was harmless when publish was synchronous but became a bug after the parallel publish refactor made it async: the finally block restored the original package.json (with private: true and no optionalDependencies) before npm publish could read the mutated file, so the wrapper clerk package silently failed to publish. Platform packages were unaffected since they use a generated package.json on disk. --- scripts/releaser.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/releaser.ts b/scripts/releaser.ts index a42ea0d1..88daa947 100644 --- a/scripts/releaser.ts +++ b/scripts/releaser.ts @@ -98,7 +98,7 @@ try { console.log(`Skipping ${wrapperName}@${version} (already published)`); } else { console.log(`Publishing ${wrapperName}@${version}...`); - publish(join(import.meta.dir, "../packages/cli"), { dryRun, tag }); + await publish(join(import.meta.dir, "../packages/cli"), { dryRun, tag }); } } finally { await Bun.write(WRAPPER_PKG_PATH, wrapperRaw); From 767545da20f44733a78b42d7d9cc0af392c30f13 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 20 Apr 2026 10:28:30 -0600 Subject: [PATCH 05/10] feat(snapshot): use commit SHA in version and bail on duplicate Switch the changeset prereleaseTemplate from {tag}.v{datetime} to {tag}.{commit}, and rewrite the full 40-char SHA changesets emits to the 7-char short form so versions stay readable (e.g. 0.8.6-snapshot.a1b2c3d). Because commit-based versions are deterministic per commit, re-running !snapshot on the same commit would produce a version npm rejects as a duplicate. Bail out of the snapshot job up front by calling isPublished("clerk", version); the build/sign/test/publish jobs all depend on this step so they skip entirely instead of burning ~30 minutes of runner time for nothing. To re-publish the same commit intentionally, use a different tag name (e.g. `!snapshot retry`), which produces `0.8.6-retry.a1b2c3d`. --- .changeset/config.json | 2 +- scripts/snapshot.ts | 32 +++++++++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/.changeset/config.json b/.changeset/config.json index 825aad52..16e9c274 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -9,6 +9,6 @@ "ignore": ["@clerk/cli-core"], "snapshot": { "useCalculatedVersion": true, - "prereleaseTemplate": "{tag}.v{datetime}" + "prereleaseTemplate": "{tag}.{commit}" } } diff --git a/scripts/snapshot.ts b/scripts/snapshot.ts index 11832cad..6fb5cbb2 100644 --- a/scripts/snapshot.ts +++ b/scripts/snapshot.ts @@ -1,5 +1,6 @@ import { join } from "node:path"; import { parseArgs } from "node:util"; +import { isPublished } from "./lib/npm.ts"; const CHANGESET_CONFIG = join(import.meta.dir, "../.changeset/config.json"); const WRAPPER_PKG = join(import.meta.dir, "../packages/cli/package.json"); @@ -50,7 +51,36 @@ try { } const pkg = await Bun.file(WRAPPER_PKG).json(); - console.log(`Snapshot version: ${pkg.version}`); + + // Changesets substitutes `{commit}` in `prereleaseTemplate` with the full + // 40-char SHA from `git rev-parse HEAD`. Rewrite it to the short SHA so the + // published version string stays readable (e.g. `0.8.6-snapshot.a1b2c3d`). + const shortShaResult = Bun.spawnSync(["git", "rev-parse", "--short", "HEAD"], { + stdio: ["ignore", "pipe", "pipe"], + }); + if (shortShaResult.exitCode !== 0) { + throw new Error( + `git rev-parse --short HEAD failed: ${shortShaResult.stderr.toString().trim()}`, + ); + } + const shortSha = shortShaResult.stdout.toString().trim(); + const originalVersion: string = pkg.version; + const finalVersion = originalVersion.replace(/[a-f0-9]{40}$/, shortSha); + if (finalVersion !== originalVersion) { + pkg.version = finalVersion; + await Bun.write(WRAPPER_PKG, JSON.stringify(pkg, null, 2) + "\n"); + } + + // Bail early if this commit has already been snapshotted. The build, sign, + // and test jobs would otherwise run for several minutes only for npm to + // reject the final publish as a duplicate. + if (await isPublished("clerk", finalVersion)) { + throw new Error( + `clerk@${finalVersion} is already published. Push a new commit or re-run with a different tag (e.g. \`!snapshot retry\`).`, + ); + } + + console.log(`Snapshot version: ${finalVersion}`); } finally { // Restore config Bun.spawnSync(["git", "checkout", "HEAD", "--", CHANGESET_CONFIG], { From 640414d1fa407d1dd93798b79c8cba088631cc9e Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 20 Apr 2026 10:34:31 -0600 Subject: [PATCH 06/10] fix(ci): inherit secrets in snapshot-build so CLI_ENV_PROFILES is injected The snapshot-build job was the only build-binaries.yml caller missing secrets: inherit, so secrets.ENV_PROFILES was undefined in the reusable workflow. build.ts then skipped the --define CLI_ENV_PROFILES=... step and the resulting binary fell back to hardcoded defaults (production only), which is why `switch-env staging` errors with "Unknown environment" on snapshot installs even though canary works. Matches the pattern already in place for the stable build and canary-build jobs. --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 245818b3..8e49ed6f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -403,6 +403,7 @@ jobs: version: ${{ needs.snapshot.outputs.version }} ref: ${{ needs.snapshot.outputs.sha }} artifact-prefix: clerk-snapshot + secrets: inherit snapshot-sign-macos: needs: [snapshot, snapshot-build] From 26f58b0571548fbcd5762bea74652cddb7877dc4 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 20 Apr 2026 11:10:32 -0600 Subject: [PATCH 07/10] refactor(log): centralize HTTP debug logging in loggedFetch helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add lib/fetch.ts exporting loggedFetch(url, { tag, ...init }) which emits a ": METHOD url" debug line before the request and, on non-ok responses, a ": status METHOD url — body" debug line (body read from a cloned response so the caller retains ownership). Migrate every call site that was duplicating this pattern: - plapi.ts: the six endpoint functions now go through a local plapiFetch wrapper that adds Bearer auth + Accept headers on top of loggedFetch. Each public function shrinks to ~4 lines. - commands/api/bapi.ts: the single bapiRequest swaps fetch for loggedFetch with tag="bapi". - lib/token-exchange.ts: both OAuth calls use tag="oauth". - lib/update-check.ts: fetchLatestVersion uses tag="update-check". Mock fixup: several test files were detecting mutating requests via `init?.method ? mutating : fetched`. plapiFetch now always sets method explicitly (including "GET"), so the truthy check no longer distinguishes GET from PATCH/PUT. Update the predicates to check `init.method !== "GET"` explicitly. Document the pattern in .claude/rules/debug-logging.md (new) with a pointer from the existing logging.md rule. Future HTTP call sites must go through loggedFetch; inline log.debug duplicates at call sites are no longer needed or desired. --- .claude/rules/debug-logging.md | 78 +++++++++++ .claude/rules/logging.md | 2 + packages/cli-core/src/commands/api/bapi.ts | 4 +- .../cli-core/src/commands/config/push.test.ts | 61 +++++---- packages/cli-core/src/lib/fetch.ts | 27 ++++ packages/cli-core/src/lib/plapi.ts | 128 +++++------------- packages/cli-core/src/lib/token-exchange.ts | 7 +- packages/cli-core/src/lib/update-check.ts | 4 +- .../integration/config-management.test.ts | 2 +- .../src/test/integration/config-put.test.ts | 6 +- .../src/test/integration/dry-run.test.ts | 3 +- 11 files changed, 188 insertions(+), 134 deletions(-) create mode 100644 .claude/rules/debug-logging.md create mode 100644 packages/cli-core/src/lib/fetch.ts diff --git a/.claude/rules/debug-logging.md b/.claude/rules/debug-logging.md new file mode 100644 index 00000000..2b7a2a90 --- /dev/null +++ b/.claude/rules/debug-logging.md @@ -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(`: `); +``` + +- `` — tag the subsystem. Existing tags: `plapi`, `bapi`, `oauth`, `update-check`, `credentials`, `config`, `env`, `auth-server`, `git`, `autolink`, `framework`, `runners`. Add new ones sparingly. +- `` — 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. diff --git a/.claude/rules/logging.md b/.claude/rules/logging.md index 5e767c9a..b24b845c 100644 --- a/.claude/rules/logging.md +++ b/.claude/rules/logging.md @@ -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: diff --git a/packages/cli-core/src/commands/api/bapi.ts b/packages/cli-core/src/commands/api/bapi.ts index 21551b00..35e865d1 100644 --- a/packages/cli-core/src/commands/api/bapi.ts +++ b/packages/cli-core/src/commands/api/bapi.ts @@ -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; @@ -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, diff --git a/packages/cli-core/src/commands/config/push.test.ts b/packages/cli-core/src/commands/config/push.test.ts index ba50dd07..f03d024a 100644 --- a/packages/cli-core/src/commands/config/push.test.ts +++ b/packages/cli-core/src/commands/config/push.test.ts @@ -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 }); }); @@ -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 }); } @@ -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 }); }); @@ -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 }); }); @@ -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 }); }); @@ -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 }); }); @@ -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 }); }); @@ -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 }); }); @@ -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 }); }); @@ -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 }); }); @@ -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 }); }); @@ -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 }); }); @@ -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 }); }); @@ -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 }); }); @@ -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 }); }); @@ -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 }); }); @@ -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 }); }); diff --git a/packages/cli-core/src/lib/fetch.ts b/packages/cli-core/src/lib/fetch.ts new file mode 100644 index 00000000..7f9821de --- /dev/null +++ b/packages/cli-core/src/lib/fetch.ts @@ -0,0 +1,27 @@ +/** + * fetch() wrapper that emits consistent debug logs for the request and, + * on a non-ok response, the response body. The caller still owns error + * construction and body parsing. + * + * All outbound HTTP calls in library code must go through this helper so + * that `--verbose` surfaces the URL, method, and server response body for + * every network error. See `.claude/rules/debug-logging.md`. + */ + +import { log } from "./log.ts"; + +export type LoggedFetchInit = RequestInit & { tag: string }; + +export async function loggedFetch(url: URL | string, options: LoggedFetchInit): Promise { + const { tag, ...init } = options; + const method = init.method ?? "GET"; + const urlStr = url.toString(); + log.debug(`${tag}: ${method} ${urlStr}`); + const response = await fetch(url, init); + if (!response.ok) { + // Clone so the caller can still consume the body for error construction. + const body = await response.clone().text(); + log.debug(`${tag}: ${response.status} ${method} ${urlStr} — ${body}`); + } + return response; +} diff --git a/packages/cli-core/src/lib/plapi.ts b/packages/cli-core/src/lib/plapi.ts index 191a3758..84336002 100644 --- a/packages/cli-core/src/lib/plapi.ts +++ b/packages/cli-core/src/lib/plapi.ts @@ -6,6 +6,7 @@ import { getPlapiBaseUrl, getCurrentEnvName } from "./environment.ts"; import { getToken } from "./credential-store.ts"; import { CliError, PlapiError, ERROR_CODE } from "./errors.ts"; +import { loggedFetch } from "./fetch.ts"; import { log } from "./log.ts"; /** @@ -54,12 +55,36 @@ export async function getAuthToken(): Promise { }); } +/** + * Local wrapper that adds the standard Bearer auth + Accept headers and + * throws PlapiError on non-ok responses. Debug logging is centralized in + * `loggedFetch` — don't add inline `log.debug` calls here or in callers. + */ +async function plapiFetch(method: string, url: URL, init?: { body?: string }): Promise { + const token = await getAuthToken(); + const headers: Record = { + Authorization: `Bearer ${token}`, + Accept: "application/json", + }; + if (init?.body) headers["Content-Type"] = "application/json"; + const response = await loggedFetch(url, { + tag: "plapi", + method, + headers, + body: init?.body, + }); + if (!response.ok) { + const body = await response.text(); + throw new PlapiError(response.status, body, url.toString()); + } + return response; +} + export async function fetchInstanceConfigSchema( applicationId: string, instanceId: string, keys?: string[], ): Promise> { - const token = await getAuthToken(); const url = new URL( `/v1/platform/applications/${applicationId}/instances/${instanceId}/config/schema`, getPlapiBaseUrl(), @@ -69,20 +94,7 @@ export async function fetchInstanceConfigSchema( url.searchParams.append("keys", key); } } - log.debug(`plapi: GET ${url}`); - const response = await fetch(url, { - headers: { - Authorization: `Bearer ${token}`, - Accept: "application/json", - }, - }); - - if (!response.ok) { - const body = await response.text(); - log.debug(`plapi: ${response.status} GET ${url} — ${body}`); - throw new PlapiError(response.status, body, url.toString()); - } - + const response = await plapiFetch("GET", url); return response.json() as Promise>; } @@ -91,7 +103,6 @@ export async function fetchInstanceConfig( instanceId: string, keys?: string[], ): Promise> { - const token = await getAuthToken(); const url = new URL( `/v1/platform/applications/${applicationId}/instances/${instanceId}/config`, getPlapiBaseUrl(), @@ -101,20 +112,7 @@ export async function fetchInstanceConfig( url.searchParams.append("keys", key); } } - log.debug(`plapi: GET ${url}`); - const response = await fetch(url, { - headers: { - Authorization: `Bearer ${token}`, - Accept: "application/json", - }, - }); - - if (!response.ok) { - const body = await response.text(); - log.debug(`plapi: ${response.status} GET ${url} — ${body}`); - throw new PlapiError(response.status, body, url.toString()); - } - + const response = await plapiFetch("GET", url); return response.json() as Promise>; } @@ -132,23 +130,9 @@ export interface Application { } export async function fetchApplication(applicationId: string): Promise { - const token = await getAuthToken(); const url = new URL(`/v1/platform/applications/${applicationId}`, getPlapiBaseUrl()); url.searchParams.set("include_secret_keys", "true"); - log.debug(`plapi: GET ${url}`); - const response = await fetch(url, { - headers: { - Authorization: `Bearer ${token}`, - Accept: "application/json", - }, - }); - - if (!response.ok) { - const body = await response.text(); - log.debug(`plapi: ${response.status} GET ${url} — ${body}`); - throw new PlapiError(response.status, body, url.toString()); - } - + const response = await plapiFetch("GET", url); return response.json() as Promise; } @@ -159,7 +143,6 @@ async function sendInstanceConfig( config: Record, options?: { destructive?: boolean }, ): Promise> { - const token = await getAuthToken(); const url = new URL( `/v1/platform/applications/${applicationId}/instances/${instanceId}/config`, getPlapiBaseUrl(), @@ -167,23 +150,7 @@ async function sendInstanceConfig( if (options?.destructive) { url.searchParams.set("destructive", "true"); } - log.debug(`plapi: ${method} ${url}`); - const response = await fetch(url, { - method, - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify(config), - }); - - if (!response.ok) { - const body = await response.text(); - log.debug(`plapi: ${response.status} ${method} ${url} — ${body}`); - throw new PlapiError(response.status, body, url.toString()); - } - + const response = await plapiFetch(method, url, { body: JSON.stringify(config) }); return response.json() as Promise>; } @@ -202,44 +169,13 @@ export const patchInstanceConfig = ( ) => sendInstanceConfig("PATCH", applicationId, instanceId, config, options); export async function createApplication(name: string): Promise { - const token = await getAuthToken(); const url = new URL("/v1/platform/applications", getPlapiBaseUrl()); - log.debug(`plapi: POST ${url}`); - const response = await fetch(url, { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify({ name }), - }); - - if (!response.ok) { - const body = await response.text(); - log.debug(`plapi: ${response.status} POST ${url} — ${body}`); - throw new PlapiError(response.status, body, url.toString()); - } - + const response = await plapiFetch("POST", url, { body: JSON.stringify({ name }) }); return response.json() as Promise; } export async function listApplications(): Promise { - const token = await getAuthToken(); const url = new URL("/v1/platform/applications", getPlapiBaseUrl()); - log.debug(`plapi: GET ${url}`); - const response = await fetch(url, { - headers: { - Authorization: `Bearer ${token}`, - Accept: "application/json", - }, - }); - - if (!response.ok) { - const body = await response.text(); - log.debug(`plapi: ${response.status} GET ${url} — ${body}`); - throw new PlapiError(response.status, body, url.toString()); - } - + const response = await plapiFetch("GET", url); return response.json() as Promise; } diff --git a/packages/cli-core/src/lib/token-exchange.ts b/packages/cli-core/src/lib/token-exchange.ts index dee88fee..ca4339af 100644 --- a/packages/cli-core/src/lib/token-exchange.ts +++ b/packages/cli-core/src/lib/token-exchange.ts @@ -11,6 +11,7 @@ import { getOAuthConfig } from "./environment.ts"; import { ApiError, withApiContext } from "./errors.ts"; +import { loggedFetch } from "./fetch.ts"; interface TokenResponse { access_token: string; @@ -40,7 +41,8 @@ export async function exchangeCodeForToken(params: { return withApiContext( (async () => { - const response = await fetch(oauth.tokenUrl, { + const response = await loggedFetch(oauth.tokenUrl, { + tag: "oauth", method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: body.toString(), @@ -61,7 +63,8 @@ export async function fetchUserInfo(accessToken: string): Promise { const oauth = getOAuthConfig(); return withApiContext( (async () => { - const response = await fetch(oauth.userinfoUrl, { + const response = await loggedFetch(oauth.userinfoUrl, { + tag: "oauth", headers: { Authorization: `Bearer ${accessToken}` }, }); diff --git a/packages/cli-core/src/lib/update-check.ts b/packages/cli-core/src/lib/update-check.ts index dbea10d2..3ce3e49b 100644 --- a/packages/cli-core/src/lib/update-check.ts +++ b/packages/cli-core/src/lib/update-check.ts @@ -8,6 +8,7 @@ import { UPDATE_PACKAGE_NAME, UPDATE_CACHE_FILE, } from "./constants.ts"; +import { loggedFetch } from "./fetch.ts"; import { log } from "./log.ts"; // ── Types ───────────────────────────────────────────────────────────────────── @@ -109,7 +110,8 @@ export async function fetchLatestVersion(distTag: string, timeoutMs = 1500): Pro const timer = setTimeout(() => controller.abort(), timeoutMs); try { - const res = await fetch(url, { + const res = await loggedFetch(url, { + tag: "update-check", signal: controller.signal, headers: { Accept: "application/vnd.npm.install-v1+json" }, }); diff --git a/packages/cli-core/src/test/integration/config-management.test.ts b/packages/cli-core/src/test/integration/config-management.test.ts index fac6cb77..bda78f66 100644 --- a/packages/cli-core/src/test/integration/config-management.test.ts +++ b/packages/cli-core/src/test/integration/config-management.test.ts @@ -49,7 +49,7 @@ test.each([{ mode: "human" }, { mode: "agent" }])( // Patch config — GET returns current (different) config so changes are detected const updatedConfig = { session: { lifetime: 86400 }, sign_up: { mode: "public" } }; http.stub(async (_url, init) => { - const body = init?.method ? updatedConfig : MOCK_CONFIG; + const body = init?.method && init.method !== "GET" ? updatedConfig : MOCK_CONFIG; return new Response(JSON.stringify(body), { status: 200 }); }); diff --git a/packages/cli-core/src/test/integration/config-put.test.ts b/packages/cli-core/src/test/integration/config-put.test.ts index a8618993..965da4ff 100644 --- a/packages/cli-core/src/test/integration/config-put.test.ts +++ b/packages/cli-core/src/test/integration/config-put.test.ts @@ -38,7 +38,8 @@ test.each([{ mode: "human" }, { mode: "agent" }])( // GET returns a different config so hasConfigChanges detects changes http.stub(async (_url, init) => { - const body = init?.method ? fullConfig : { session: { lifetime: 3600 } }; + const body = + init?.method && init.method !== "GET" ? fullConfig : { session: { lifetime: 3600 } }; return new Response(JSON.stringify(body), { status: 200 }); }); @@ -63,7 +64,8 @@ test("config put requires confirmation in human mode without --yes", async () => // GET returns different config so changes are detected http.stub(async (_url, init) => { - const body = init?.method ? fullConfig : { session: { lifetime: 604800 } }; + const body = + init?.method && init.method !== "GET" ? fullConfig : { session: { lifetime: 604800 } }; return new Response(JSON.stringify(body), { status: 200 }); }); diff --git a/packages/cli-core/src/test/integration/dry-run.test.ts b/packages/cli-core/src/test/integration/dry-run.test.ts index fcd18eaf..1c52f59f 100644 --- a/packages/cli-core/src/test/integration/dry-run.test.ts +++ b/packages/cli-core/src/test/integration/dry-run.test.ts @@ -68,7 +68,8 @@ test.each([{ mode: "human" }, { mode: "agent" }])( const updatedConfig = { session: { lifetime: 3600 } }; // GET returns different config so hasConfigChanges detects changes http.stub(async (_url, init) => { - const body = init?.method ? updatedConfig : { session: { lifetime: 604800 } }; + const body = + init?.method && init.method !== "GET" ? updatedConfig : { session: { lifetime: 604800 } }; return new Response(JSON.stringify(body), { status: 200 }); }); From 4a6a7252b551b776071036a2c562441f51c7bb5d Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 20 Apr 2026 11:12:54 -0600 Subject: [PATCH 08/10] feat(log): add debug coverage for auth-server, git, autolink, framework, runners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply the debug-logging rule to the remaining non-HTTP subsystems so --verbose tells the full story when a command fails outside the network path. - auth-server: listening port, callback outcome (success, state mismatch, no code, OAuth error, timeout) - git: cached single-line summary of getGitRepoInfo (toplevel, commonDir, remote) on success; "not a git repository" on failure - autolink: detected-key count + sources, match result (app id and name), no-match branch. Downgrade the listApplications failure log from log.error to log.debug — autolink is a best-effort fallback and shouldn't surface errors to the user; the caller proceeds to the interactive picker. - framework: which dep matched or why nothing matched - runners: detected runners on PATH; yarn dlx probe outcome --- packages/cli-core/src/lib/auth-server.ts | 8 ++++++++ packages/cli-core/src/lib/autolink.ts | 20 +++++++++++++++++--- packages/cli-core/src/lib/framework.ts | 12 ++++++++++-- packages/cli-core/src/lib/git.ts | 6 ++++++ packages/cli-core/src/lib/runners.ts | 16 +++++++++++++--- 5 files changed, 54 insertions(+), 8 deletions(-) diff --git a/packages/cli-core/src/lib/auth-server.ts b/packages/cli-core/src/lib/auth-server.ts index b168226c..1064a53d 100644 --- a/packages/cli-core/src/lib/auth-server.ts +++ b/packages/cli-core/src/lib/auth-server.ts @@ -4,6 +4,7 @@ */ import { AUTH_TIMEOUT_MS, CALLBACK_PATH } from "./constants.ts"; +import { log } from "./log.ts"; function escapeHtml(str: string): string { return str @@ -85,6 +86,7 @@ export function startAuthServer(expectedState: string): AuthServerResult { }); const timeout = setTimeout(() => { + log.debug(`auth-server: timed out after ${AUTH_TIMEOUT_MS}ms`); rejectCallback(new Error("Authentication timed out. Please try again.")); server.stop(); }, AUTH_TIMEOUT_MS); @@ -102,6 +104,7 @@ export function startAuthServer(expectedState: string): AuthServerResult { if (error) { const description = url.searchParams.get("error_description") || error; + log.debug(`auth-server: OAuth error in callback — ${error}: ${description}`); rejectCallback(new Error(`OAuth error: ${description}`)); clearTimeout(timeout); setTimeout(() => server.stop(), 100); @@ -111,6 +114,7 @@ export function startAuthServer(expectedState: string): AuthServerResult { } if (state !== expectedState) { + log.debug(`auth-server: state mismatch (expected=${expectedState}, got=${state})`); rejectCallback(new Error("Invalid state parameter. Possible CSRF attack.")); clearTimeout(timeout); setTimeout(() => server.stop(), 100); @@ -121,6 +125,7 @@ export function startAuthServer(expectedState: string): AuthServerResult { } if (!code) { + log.debug("auth-server: callback received with no authorization code"); rejectCallback(new Error("No authorization code received.")); clearTimeout(timeout); setTimeout(() => server.stop(), 100); @@ -130,6 +135,7 @@ export function startAuthServer(expectedState: string): AuthServerResult { }); } + log.debug("auth-server: callback received with valid code and state"); resolveCallback({ code }); clearTimeout(timeout); setTimeout(() => server.stop(), 100); @@ -146,6 +152,8 @@ export function startAuthServer(expectedState: string): AuthServerResult { }, }); + log.debug(`auth-server: listening on 127.0.0.1:${server.port} for ${CALLBACK_PATH}`); + return { port: server.port!, waitForCallback: () => callbackPromise, diff --git a/packages/cli-core/src/lib/autolink.ts b/packages/cli-core/src/lib/autolink.ts index 5c59b24a..e2ba6c88 100644 --- a/packages/cli-core/src/lib/autolink.ts +++ b/packages/cli-core/src/lib/autolink.ts @@ -80,20 +80,34 @@ export async function autolink( cwd: string, ): Promise<{ path: string; profile: Profile } | undefined> { const detectedKeys = await findClerkKeys(cwd); - if (detectedKeys.length === 0) return undefined; + if (detectedKeys.length === 0) { + log.debug("autolink: no clerk publishable keys found in env or .env files"); + return undefined; + } + log.debug( + `autolink: found ${detectedKeys.length} key(s) (sources: ${detectedKeys.map((k) => k.source).join(", ")})`, + ); let apps: Application[]; try { apps = await listApplications(); } catch (err) { - log.error(`Failed to list applications: ${err}`); + // Autolink is a best-effort fallback — swallow the failure at debug level + // so callers can gracefully proceed to the interactive picker. + log.debug(`autolink: listApplications failed — ${err}`); return undefined; } if (!Array.isArray(apps)) return undefined; const match = matchKeyToApp(detectedKeys, apps); - if (!match) return undefined; + if (!match) { + log.debug(`autolink: no app matched any detected key (checked ${apps.length} app(s))`); + return undefined; + } + log.debug( + `autolink: matched key from ${match.source} → app ${match.app.application_id} (${match.app.name ?? "unnamed"})`, + ); const normalizedRemote = await getGitNormalizedRemote(); const repoId = await getGitRepoIdentifier(); diff --git a/packages/cli-core/src/lib/framework.ts b/packages/cli-core/src/lib/framework.ts index c8f564d1..f2b5ed86 100644 --- a/packages/cli-core/src/lib/framework.ts +++ b/packages/cli-core/src/lib/framework.ts @@ -4,6 +4,7 @@ */ import { join } from "node:path"; +import { log } from "./log.ts"; export interface FrameworkInfo { dep: string; @@ -145,12 +146,19 @@ export async function readDeps(cwd: string): Promise | nu export async function detectFramework(cwd: string): Promise { const allDeps = await readDeps(cwd); - if (!allDeps) return null; + if (!allDeps) { + log.debug(`framework: no package.json at ${cwd} or unable to parse`); + return null; + } for (const fw of FRAMEWORK_MAP) { - if (fw.dep in allDeps) return fw; + if (fw.dep in allDeps) { + log.debug(`framework: detected "${fw.name}" via dependency "${fw.dep}"`); + return fw; + } } + log.debug(`framework: no match in ${cwd}/package.json dependencies`); return null; } diff --git a/packages/cli-core/src/lib/git.ts b/packages/cli-core/src/lib/git.ts index 38a8ed18..b54c0715 100644 --- a/packages/cli-core/src/lib/git.ts +++ b/packages/cli-core/src/lib/git.ts @@ -1,4 +1,5 @@ import { resolve } from "node:path"; +import { log } from "./log.ts"; const $ = Bun.$; @@ -15,6 +16,7 @@ async function getGitRepoInfo(): Promise { const result = await $`git rev-parse --show-toplevel --git-common-dir`.quiet().nothrow(); if (result.exitCode !== 0) { + log.debug("git: not a git repository (git rev-parse failed)"); cached = undefined; return undefined; } @@ -23,6 +25,7 @@ async function getGitRepoInfo(): Promise { const toplevel = lines[0]; const commonDir = lines[1]; if (!toplevel || !commonDir) { + log.debug("git: rev-parse returned no toplevel/commonDir"); cached = undefined; return undefined; } @@ -36,6 +39,9 @@ async function getGitRepoInfo(): Promise { commonDir: resolve(toplevel, commonDir), normalizedRemote: rawRemote ? normalizeGitRemoteUrl(rawRemote) : undefined, }; + log.debug( + `git: toplevel=${cached.toplevel}, commonDir=${cached.commonDir}, remote=${cached.normalizedRemote ?? ""}`, + ); return cached; } diff --git a/packages/cli-core/src/lib/runners.ts b/packages/cli-core/src/lib/runners.ts index 09c2db80..db147744 100644 --- a/packages/cli-core/src/lib/runners.ts +++ b/packages/cli-core/src/lib/runners.ts @@ -16,6 +16,7 @@ import type { ProjectContext } from "../commands/init/frameworks/types.js"; import type { NonEmptyArray } from "./helpers/arrays.js"; +import { log } from "./log.ts"; /** * One way to invoke an npm-published binary without installing it globally. @@ -66,8 +67,13 @@ function yarnSupportsDlx(): boolean { stdout: "ignore", stderr: "ignore", }); - return proc.exitCode === 0; - } catch { + const supported = proc.exitCode === 0; + if (!supported) { + log.debug(`runners: yarn dlx --help exited ${proc.exitCode} (likely Yarn Classic)`); + } + return supported; + } catch (err) { + log.debug(`runners: yarn dlx probe threw — ${err}`); return false; } } @@ -79,11 +85,15 @@ function yarnSupportsDlx(): boolean { * probe so Yarn Classic (v1, no `dlx`) is not advertised. */ export function detectAvailableRunners(): Runner[] { - return KNOWN_RUNNERS.filter((r) => { + const available = KNOWN_RUNNERS.filter((r) => { if (Bun.which(r.binary) === null) return false; if (r.id === "yarn") return yarnSupportsDlx(); return true; }); + log.debug( + `runners: available on PATH = ${available.length > 0 ? available.map((r) => r.id).join(", ") : ""}`, + ); + return available; } /** From f9a6f37aa9295aa9161b0d60704041359299942b Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 20 Apr 2026 11:18:31 -0600 Subject: [PATCH 09/10] fix(review): address pr feedback on #183 - Delete the redundant empty changeset (.changeset/tired-pillows-divide.md). roomy-arrhinceratops.md already bumps clerk for this branch, and scripts/** is changeset-exempt, so the empty file was pure noise. - Make the short-sha substitution regex in scripts/snapshot.ts word-bounded (\b[a-f0-9]{40}\b) so it stays correct if prereleaseTemplate is later changed to append a suffix after the commit SHA. - Hoist profilesSourceLogged next to currentEnvName in lib/environment.ts so both module-level flags sit above the functions that read them, without relying on let-hoisting. --- .changeset/tired-pillows-divide.md | 2 -- packages/cli-core/src/lib/environment.ts | 6 +++--- scripts/snapshot.ts | 2 +- 3 files changed, 4 insertions(+), 6 deletions(-) delete mode 100644 .changeset/tired-pillows-divide.md diff --git a/.changeset/tired-pillows-divide.md b/.changeset/tired-pillows-divide.md deleted file mode 100644 index a845151c..00000000 --- a/.changeset/tired-pillows-divide.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/packages/cli-core/src/lib/environment.ts b/packages/cli-core/src/lib/environment.ts index 7ed056c3..91643a28 100644 --- a/packages/cli-core/src/lib/environment.ts +++ b/packages/cli-core/src/lib/environment.ts @@ -31,6 +31,9 @@ const DEFAULT_PROFILES: Record = { }, }; +let currentEnvName: string | undefined; +let profilesSourceLogged = false; + function loadFileProfiles(): Record | undefined { // Try repo root (cwd) first, then fall back to path relative to this source file const candidates = [ @@ -76,9 +79,6 @@ function getProfiles(): Record { return DEFAULT_PROFILES; } -let currentEnvName: string | undefined; -let profilesSourceLogged = false; - /** * Set the active environment. Called during CLI initialization from config, * or by the switch-env command. diff --git a/scripts/snapshot.ts b/scripts/snapshot.ts index 6fb5cbb2..60bb3913 100644 --- a/scripts/snapshot.ts +++ b/scripts/snapshot.ts @@ -65,7 +65,7 @@ try { } const shortSha = shortShaResult.stdout.toString().trim(); const originalVersion: string = pkg.version; - const finalVersion = originalVersion.replace(/[a-f0-9]{40}$/, shortSha); + const finalVersion = originalVersion.replace(/\b[a-f0-9]{40}\b/, shortSha); if (finalVersion !== originalVersion) { pkg.version = finalVersion; await Bun.write(WRAPPER_PKG, JSON.stringify(pkg, null, 2) + "\n"); From b4d7ed0b8e30668c97aea25d9b7e5e67e72b0f15 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 20 Apr 2026 11:18:37 -0600 Subject: [PATCH 10/10] docs(changeset): expand summary to cover env fallback warning and broader coverage The original summary only named the four files touched by the first commit (plapi, credential-store, config, environment). The branch has since grown to include an info-level fallback warning, HTTP-wide loggedFetch migration, and debug coverage for the auth server, git helpers, autolink, framework detection, and runner probing. Rewrite the summary so the changelog entry reflects what actually ships. --- .changeset/roomy-arrhinceratops.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.changeset/roomy-arrhinceratops.md b/.changeset/roomy-arrhinceratops.md index 6c3cfa58..ceab2c70 100644 --- a/.changeset/roomy-arrhinceratops.md +++ b/.changeset/roomy-arrhinceratops.md @@ -2,4 +2,8 @@ "clerk": patch --- -Add verbose debug output for network requests, config file access, and credential store lookups. +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.