From 29ebb17d10fed4a909f1d0c590b25cf7193fe99a Mon Sep 17 00:00:00 2001 From: Rasul Kireev Date: Tue, 30 Jun 2026 16:31:39 +0000 Subject: [PATCH 1/2] feat(cli): add debug diagnostics --- README.md | 12 +++ src/auth.ts | 158 +++++++++++++++------------- src/commands.ts | 2 + src/diagnostics.ts | 216 ++++++++++++++++++++++++++++++++++++++ src/index.ts | 8 +- src/mcp.ts | 74 +++++++------ tests/diagnostics.test.ts | 131 +++++++++++++++++++++++ 7 files changed, 495 insertions(+), 106 deletions(-) create mode 100644 src/diagnostics.ts create mode 100644 tests/diagnostics.test.ts diff --git a/README.md b/README.md index 913328c..8b870af 100644 --- a/README.md +++ b/README.md @@ -115,8 +115,20 @@ readwise reader-export-documents --since-updated "2024-06-01T00:00:00Z" |------|-------------| | `--json` | Output raw JSON (for piping to `jq`, scripts, etc.) | | `--refresh` | Force-refresh the command list from the server | +| `--debug` | Print sanitized diagnostic timings to stderr | | `--help` | Show all commands or command-specific options | +## Troubleshooting + +For support diagnostics, run a command with `--debug` or set `READWISE_CLI_DEBUG=1`: + +```bash +readwise --debug reader-search-documents --query "machine learning" --limit 3 +READWISE_CLI_DEBUG=1 readwise --version +``` + +Debug output is written to stderr as structured lines with a `run_id`, CLI version, Node version, platform, phase timings, and sanitized error details. Tokens and tool arguments are not logged. + ## Configuration Manage CLI settings with the `config` command. Settings are stored in `~/.readwise-cli.json` under the `config` key. diff --git a/src/auth.ts b/src/auth.ts index 1caff2d..7c1a1bf 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -3,6 +3,7 @@ import { randomBytes, createHash } from "node:crypto"; import { URL } from "node:url"; import open from "open"; import { loadConfig, saveConfig, type Config } from "./config.js"; +import { diagnostics } from "./diagnostics.js"; const DISCOVERY_URL = "https://readwise.io/o/.well-known/oauth-authorization-server"; const REDIRECT_URI = "http://localhost:6274/callback"; @@ -20,27 +21,31 @@ interface OAuthMetadata { } async function discover(): Promise { - const res = await fetch(DISCOVERY_URL, { signal: AbortSignal.timeout(HTTP_TIMEOUT_MS) }); - if (!res.ok) throw new Error(`OAuth discovery failed: ${res.status} ${res.statusText}`); - return (await res.json()) as OAuthMetadata; + return await diagnostics.phase("oauth.discover", async () => { + const res = await fetch(DISCOVERY_URL, { signal: AbortSignal.timeout(HTTP_TIMEOUT_MS) }); + if (!res.ok) throw new Error(`OAuth discovery failed: ${res.status} ${res.statusText}`); + return (await res.json()) as OAuthMetadata; + }); } async function registerClient(registrationEndpoint: string): Promise<{ client_id: string; client_secret: string }> { - const res = await fetch(registrationEndpoint, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - client_name: "readwise-cli", - redirect_uris: [REDIRECT_URI], - grant_types: ["authorization_code", "refresh_token"], - token_endpoint_auth_method: "client_secret_basic", - }), + return await diagnostics.phase("oauth.registerClient", async () => { + const res = await fetch(registrationEndpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + client_name: "readwise-cli", + redirect_uris: [REDIRECT_URI], + grant_types: ["authorization_code", "refresh_token"], + token_endpoint_auth_method: "client_secret_basic", + }), + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`Client registration failed: ${res.status} ${body}`); + } + return (await res.json()) as { client_id: string; client_secret: string }; }); - if (!res.ok) { - const body = await res.text(); - throw new Error(`Client registration failed: ${res.status} ${body}`); - } - return (await res.json()) as { client_id: string; client_secret: string }; } function generatePKCE(): { verifier: string; challenge: string } { @@ -116,24 +121,26 @@ async function exchangeToken( clientSecret: string, codeVerifier: string, ): Promise<{ access_token: string; refresh_token: string; expires_in: number }> { - const res = await fetch(tokenEndpoint, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`, - }, - body: new URLSearchParams({ - grant_type: "authorization_code", - code, - redirect_uri: REDIRECT_URI, - code_verifier: codeVerifier, - }), + return await diagnostics.phase("oauth.exchangeToken", async () => { + const res = await fetch(tokenEndpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`, + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: REDIRECT_URI, + code_verifier: codeVerifier, + }), + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`Token exchange failed: ${res.status} ${body}`); + } + return (await res.json()) as { access_token: string; refresh_token: string; expires_in: number }; }); - if (!res.ok) { - const body = await res.text(); - throw new Error(`Token exchange failed: ${res.status} ${body}`); - } - return (await res.json()) as { access_token: string; refresh_token: string; expires_in: number }; } export async function login(): Promise { @@ -215,52 +222,57 @@ export async function logout(): Promise { } export async function ensureValidToken(): Promise<{ token: string; authType: "oauth" | "token" }> { - const config = await loadConfig(); + return await diagnostics.phase("ensureValidToken", async () => { + const config = await loadConfig(); - if (!config.access_token) { - throw new Error("Not logged in. Run `readwise-cli login` or `readwise-cli login-with-token ` first."); - } + if (!config.access_token) { + throw new Error("Not logged in. Run `readwise-cli login` or `readwise-cli login-with-token ` first."); + } - const authType = config.auth_type ?? "oauth"; + const authType = config.auth_type ?? "oauth"; - // Access tokens don't expire and don't need refresh - if (authType === "token") { - return { token: config.access_token, authType }; - } - - // Refresh if expired or expiring within 60s - if (config.expires_at && Date.now() > config.expires_at - 60_000) { - if (!config.refresh_token || !config.client_id || !config.client_secret) { - throw new Error("Cannot refresh token — missing credentials. Run `readwise-cli login` again."); + // Access tokens don't expire and don't need refresh + if (authType === "token") { + return { token: config.access_token, authType }; } - console.error("Refreshing access token..."); - const metadata = await discover(); + // Refresh if expired or expiring within 60s + if (config.expires_at && Date.now() > config.expires_at - 60_000) { + const refreshToken = config.refresh_token; + const clientId = config.client_id; + const clientSecret = config.client_secret; + if (!refreshToken || !clientId || !clientSecret) { + throw new Error("Cannot refresh token — missing credentials. Run `readwise-cli login` again."); + } - const res = await fetch(metadata.token_endpoint, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Authorization: `Basic ${Buffer.from(`${config.client_id}:${config.client_secret}`).toString("base64")}`, - }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: config.refresh_token, - }), - signal: AbortSignal.timeout(HTTP_TIMEOUT_MS), - }); + console.error("Refreshing access token..."); + const metadata = await discover(); + + const res = await diagnostics.phase("oauth.refreshToken", () => fetch(metadata.token_endpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`, + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + }), + signal: AbortSignal.timeout(HTTP_TIMEOUT_MS), + })); + + if (!res.ok) { + const body = await res.text(); + throw new Error(`Token refresh failed: ${res.status} ${body}. Run \`readwise-cli login\` again.`); + } - if (!res.ok) { - const body = await res.text(); - throw new Error(`Token refresh failed: ${res.status} ${body}. Run \`readwise-cli login\` again.`); + const tokens = (await res.json()) as { access_token: string; refresh_token?: string; expires_in: number }; + config.access_token = tokens.access_token; + if (tokens.refresh_token) config.refresh_token = tokens.refresh_token; + config.expires_at = Date.now() + tokens.expires_in * 1000; + await saveConfig(config); } - const tokens = (await res.json()) as { access_token: string; refresh_token?: string; expires_in: number }; - config.access_token = tokens.access_token; - if (tokens.refresh_token) config.refresh_token = tokens.refresh_token; - config.expires_at = Date.now() + tokens.expires_in * 1000; - await saveConfig(config); - } - - return { token: config.access_token, authType }; + return { token: config.access_token, authType }; + }); } diff --git a/src/commands.ts b/src/commands.ts index b126e82..85bc77b 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,6 +1,7 @@ import { Command } from "commander"; import { ensureValidToken } from "./auth.js"; import { callTool } from "./mcp.js"; +import { diagnostics } from "./diagnostics.js"; import type { ToolDef, SchemaProperty } from "./config.js"; export function toolNameToCommand(name: string): string { @@ -150,6 +151,7 @@ export function registerTools(program: Command, tools: ToolDef[]): void { const result = await callTool(token, authType, tool.name, args); displayResult(result, program.opts().json || false); } catch (err) { + diagnostics.error("command.action", err, { command: toolNameToCommand(tool.name), tool: tool.name }); process.stderr.write(`\x1b[31m${(err as Error).message}\x1b[0m\n`); process.exitCode = 1; } diff --git a/src/diagnostics.ts b/src/diagnostics.ts new file mode 100644 index 0000000..6ed5c2f --- /dev/null +++ b/src/diagnostics.ts @@ -0,0 +1,216 @@ +import { randomUUID } from "node:crypto"; +import { performance } from "node:perf_hooks"; +import { VERSION } from "./version.js"; + +const DEBUG_ENV_VALUES = new Set(["1", "true", "yes", "on"]); +const DEBUG_PREFIX = "[readwise-cli debug] "; +const SENSITIVE_KEYS = [ + "access_token", + "refresh_token", + "client_secret", + "code_verifier", + "authorization", + "password", + "token", +]; +const SENSITIVE_TEXT_KEYS = [ + ...SENSITIVE_KEYS, + "code", +]; + +export interface SanitizedError { + name: string; + message: string; + code?: string; + status?: number; + statusText?: string; + type?: string; + cause?: SanitizedError; +} + +interface DiagnosticsOptions { + enabled: boolean; + runId: string; + cliVersion: string; + nodeVersion: string; + platform: string; + arch: string; + write: (line: string) => void; + now: () => number; +} + +type DebugFields = Record; + +export function isDebugRequested( + argv: string[] = process.argv, + env: Record = process.env, +): boolean { + return argv.includes("--debug") || DEBUG_ENV_VALUES.has((env.READWISE_CLI_DEBUG ?? "").toLowerCase()); +} + +export function sanitizeText(value: string): string { + const keyAlternation = SENSITIVE_TEXT_KEYS.join("|"); + let sanitized = value; + + sanitized = sanitized.replace( + /(Authorization\s*:\s*(?:Bearer|Token|Basic)\s+)[^,\s)]+/gi, + "$1[REDACTED]", + ); + sanitized = sanitized.replace( + new RegExp(`\\b(${keyAlternation})=([^&\\s,;]+)`, "gi"), + "$1=[REDACTED]", + ); + sanitized = sanitized.replace( + new RegExp(`"(${keyAlternation})"\\s*:\\s*"[^"]*"`, "gi"), + '"$1":"[REDACTED]"', + ); + + return sanitized; +} + +export function sanitizeError(error: unknown, depth = 0): SanitizedError { + const record = typeof error === "object" && error !== null ? error as Record : undefined; + const name = error instanceof Error + ? error.name + : typeof record?.name === "string" + ? record.name + : record + ? "Object" + : typeof error; + const rawMessage = error instanceof Error + ? error.message + : typeof record?.message === "string" + ? record.message + : String(error); + const sanitized: SanitizedError = { + name, + message: sanitizeText(rawMessage), + }; + + if (typeof record?.code === "string" || typeof record?.code === "number") { + sanitized.code = sanitizeText(String(record.code)); + } + if (typeof record?.status === "number") { + sanitized.status = record.status; + } + if (typeof record?.statusText === "string") { + sanitized.statusText = sanitizeText(record.statusText); + } + if (typeof record?.type === "string") { + sanitized.type = sanitizeText(record.type); + } + if (depth === 0 && record?.cause !== undefined) { + sanitized.cause = sanitizeError(record.cause, depth + 1); + } + + return sanitized; +} + +function sanitizeDebugValue(key: string, value: unknown, depth = 0): unknown { + if (value === undefined) return undefined; + if (SENSITIVE_KEYS.includes(key.toLowerCase())) return "[REDACTED]"; + if (typeof value === "string") return sanitizeText(value); + if (typeof value === "number" || typeof value === "boolean" || value === null) return value; + if (depth >= 3) return "[REDACTED]"; + if (Array.isArray(value)) { + return value.map((item) => sanitizeDebugValue(key, item, depth + 1)); + } + if (typeof value === "object") { + const sanitized: DebugFields = {}; + for (const [childKey, childValue] of Object.entries(value)) { + const redacted = sanitizeDebugValue(childKey, childValue, depth + 1); + if (redacted !== undefined) sanitized[childKey] = redacted; + } + return sanitized; + } + return String(value); +} + +function sanitizeFields(fields: DebugFields): DebugFields { + const sanitized: DebugFields = {}; + for (const [key, value] of Object.entries(fields)) { + const redacted = sanitizeDebugValue(key, value); + if (redacted !== undefined) sanitized[key] = redacted; + } + return sanitized; +} + +export function createDiagnostics(options: DiagnosticsOptions) { + let started = false; + + const emit = (event: string, fields: DebugFields = {}) => { + if (!options.enabled) return; + const payload = { + event, + run_id: options.runId, + ...sanitizeFields(fields), + }; + options.write(`${DEBUG_PREFIX}${JSON.stringify(payload)}\n`); + }; + + return { + enabled: options.enabled, + runId: options.runId, + + start(): void { + if (started) return; + started = true; + emit("cli_start", { + cli_version: options.cliVersion, + node_version: options.nodeVersion, + platform: options.platform, + arch: options.arch, + }); + }, + + log(event: string, fields: DebugFields = {}): void { + emit(event, fields); + }, + + error(phase: string, error: unknown, fields: DebugFields = {}): void { + if (!options.enabled) return; + emit("error", { + phase, + ...fields, + error: sanitizeError(error), + }); + }, + + async phase(phase: string, operation: () => Promise | T, fields: DebugFields = {}): Promise { + if (!options.enabled) { + return await operation(); + } + + const startedAt = options.now(); + emit("phase_start", { phase, ...fields }); + try { + const result = await operation(); + emit("phase_end", { + phase, + ...fields, + duration_ms: Math.round(Math.max(0, options.now() - startedAt)), + }); + return result; + } catch (error) { + emit("phase_error", { + phase, + ...fields, + duration_ms: Math.round(Math.max(0, options.now() - startedAt)), + error: sanitizeError(error), + }); + throw error; + } + }, + }; +} + +export const diagnostics = createDiagnostics({ + enabled: isDebugRequested(), + runId: randomUUID(), + cliVersion: VERSION, + nodeVersion: process.version, + platform: process.platform, + arch: process.arch, + write: (line) => process.stderr.write(line), + now: () => performance.now(), +}); diff --git a/src/index.ts b/src/index.ts index b528ecc..113c424 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import { login, loginWithToken, ensureValidToken, logout } from "./auth.js"; import { getTools } from "./mcp.js"; import { registerTools, toolNameToCommand } from "./commands.js"; import { loadConfig, saveConfig, getConfigValue, setConfigValue, getAllConfigEntries, filterReadOnlyTools } from "./config.js"; +import { diagnostics } from "./diagnostics.js"; import { VERSION } from "./version.js"; import { registerSkillsCommands } from "./skills.js"; @@ -53,6 +54,7 @@ program .name("readwise") .version(VERSION) .description("Command-line interface for Readwise and Reader") + .option("--debug", "Print sanitized diagnostic timings to stderr") .option("--json", "Output raw JSON (machine-readable)") .option("--refresh", "Force-refresh the tool cache"); @@ -63,6 +65,7 @@ program try { await login(); } catch (err) { + diagnostics.error("login", err); process.stderr.write(`\x1b[31m${(err as Error).message}\x1b[0m\n`); process.exitCode = 1; } @@ -84,6 +87,7 @@ program } await loginWithToken(token); } catch (err) { + diagnostics.error("login-with-token", err); process.stderr.write(`\x1b[31m${(err as Error).message}\x1b[0m\n`); process.exitCode = 1; } @@ -155,7 +159,9 @@ configCmd }); async function main() { - const config = await loadConfig(); + diagnostics.start(); + + const config = await diagnostics.phase("config.load", () => loadConfig()); const forceRefresh = process.argv.includes("--refresh"); const positionalArgs = process.argv.slice(2).filter((a) => !a.startsWith("--")); const hasSubcommand = positionalArgs.length > 0; diff --git a/src/mcp.ts b/src/mcp.ts index bfba69e..b5af102 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -1,6 +1,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { loadConfig, saveConfig, isCacheValid, TOOLS_CACHE_VERSION, type ToolDef } from "./config.js"; +import { diagnostics } from "./diagnostics.js"; import { VERSION } from "./version.js"; const MCP_URL = "https://mcp2.readwise.io/mcp"; @@ -24,35 +25,38 @@ function createTransport(token: string, authType: "oauth" | "token"): Streamable } export async function getTools(token: string, authType: "oauth" | "token", forceRefresh = false): Promise { - if (!forceRefresh) { - const config = await loadConfig(); - if (isCacheValid(config)) { - return config.tools_cache!.tools; + return await diagnostics.phase("getTools", async () => { + if (!forceRefresh) { + const config = await loadConfig(); + if (isCacheValid(config)) { + diagnostics.log("tools_cache_hit", { tool_count: config.tools_cache!.tools.length }); + return config.tools_cache!.tools; + } } - } - const client = new Client({ name: "readwise", version: VERSION }); - const transport = createTransport(token, authType); + const client = new Client({ name: "readwise", version: VERSION }); + const transport = createTransport(token, authType); - try { - await client.connect(transport, { timeout: MCP_TIMEOUT_MS }); - const result = await client.listTools({}, { timeout: MCP_TIMEOUT_MS }); + try { + await diagnostics.phase("mcp.connect", () => client.connect(transport, { timeout: MCP_TIMEOUT_MS })); + const result = await diagnostics.phase("mcp.listTools", () => client.listTools({}, { timeout: MCP_TIMEOUT_MS })); - const tools = result.tools as ToolDef[]; + const tools = result.tools as ToolDef[]; - // Cache - const config = await loadConfig(); - config.tools_cache = { - tools, - fetched_at: Date.now(), - version: TOOLS_CACHE_VERSION, - }; - await saveConfig(config); + // Cache + const config = await loadConfig(); + config.tools_cache = { + tools, + fetched_at: Date.now(), + version: TOOLS_CACHE_VERSION, + }; + await saveConfig(config); - return tools; - } finally { - await client.close(); - } + return tools; + } finally { + await diagnostics.phase("client.close", () => client.close()); + } + }); } export async function callTool( @@ -61,14 +65,20 @@ export async function callTool( name: string, args: Record, ): Promise<{ content: Array<{ type: string; text?: string }>; structuredContent?: Record; isError?: boolean }> { - const client = new Client({ name: "readwise", version: VERSION }); - const transport = createTransport(token, authType); + return await diagnostics.phase("callTool", async () => { + const client = new Client({ name: "readwise", version: VERSION }); + const transport = createTransport(token, authType); - try { - await client.connect(transport, { timeout: MCP_TIMEOUT_MS }); - const result = await client.callTool({ name, arguments: args }, undefined, { timeout: MCP_TIMEOUT_MS }); - return result as { content: Array<{ type: string; text?: string }>; structuredContent?: Record; isError?: boolean }; - } finally { - await client.close(); - } + try { + await diagnostics.phase("mcp.connect", () => client.connect(transport, { timeout: MCP_TIMEOUT_MS }), { tool: name }); + const result = await diagnostics.phase( + "mcp.callTool", + () => client.callTool({ name, arguments: args }, undefined, { timeout: MCP_TIMEOUT_MS }), + { tool: name }, + ); + return result as { content: Array<{ type: string; text?: string }>; structuredContent?: Record; isError?: boolean }; + } finally { + await diagnostics.phase("client.close", () => client.close(), { tool: name }); + } + }, { tool: name }); } diff --git a/tests/diagnostics.test.ts b/tests/diagnostics.test.ts new file mode 100644 index 0000000..f96e6ae --- /dev/null +++ b/tests/diagnostics.test.ts @@ -0,0 +1,131 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createDiagnostics, isDebugRequested, sanitizeError } from "../src/diagnostics.js"; + +function parseDebugLines(lines: string[]): Array> { + return lines.map((line) => { + assert.equal(line.startsWith("[readwise-cli debug] "), true); + return JSON.parse(line.replace("[readwise-cli debug] ", "")); + }); +} + +test("debug mode can be enabled with --debug or READWISE_CLI_DEBUG", () => { + assert.equal(isDebugRequested(["node", "readwise"], {}), false); + assert.equal(isDebugRequested(["node", "readwise", "--debug"], {}), true); + assert.equal(isDebugRequested(["node", "readwise"], { READWISE_CLI_DEBUG: "1" }), true); + assert.equal(isDebugRequested(["node", "readwise"], { READWISE_CLI_DEBUG: "true" }), true); + assert.equal(isDebugRequested(["node", "readwise"], { READWISE_CLI_DEBUG: "0" }), false); +}); + +test("diagnostics emits CLI metadata and phase timing to stderr", async () => { + const lines: string[] = []; + let now = 100; + const diagnostics = createDiagnostics({ + enabled: true, + runId: "test-run-id", + cliVersion: "0.5.6", + nodeVersion: "v22.0.0", + platform: "darwin", + arch: "arm64", + write: (line) => lines.push(line), + now: () => now, + }); + + diagnostics.start(); + const result = await diagnostics.phase("ensureValidToken", async () => { + now = 128.43; + return "ok"; + }); + + assert.equal(result, "ok"); + + const events = parseDebugLines(lines); + assert.deepEqual(events[0], { + event: "cli_start", + run_id: "test-run-id", + cli_version: "0.5.6", + node_version: "v22.0.0", + platform: "darwin", + arch: "arm64", + }); + assert.equal(events[1].event, "phase_start"); + assert.equal(events[1].phase, "ensureValidToken"); + assert.equal(events[1].run_id, "test-run-id"); + assert.equal(events[2].event, "phase_end"); + assert.equal(events[2].phase, "ensureValidToken"); + assert.equal(events[2].duration_ms, 28); +}); + +test("disabled diagnostics are silent while preserving phase behavior", async () => { + const lines: string[] = []; + const diagnostics = createDiagnostics({ + enabled: false, + runId: "test-run-id", + cliVersion: "0.5.6", + nodeVersion: "v22.0.0", + platform: "darwin", + arch: "arm64", + write: (line) => lines.push(line), + now: () => 0, + }); + + const result = await diagnostics.phase("mcp.connect", async () => "ok"); + + assert.equal(result, "ok"); + assert.deepEqual(lines, []); +}); + +test("phase errors include sanitized details", async () => { + const lines: string[] = []; + let now = 5; + const diagnostics = createDiagnostics({ + enabled: true, + runId: "test-run-id", + cliVersion: "0.5.6", + nodeVersion: "v22.0.0", + platform: "darwin", + arch: "arm64", + write: (line) => lines.push(line), + now: () => now, + }); + const error = Object.assign( + new Error("Token refresh failed: access_token=abc123&client_secret=shh Authorization: Bearer secret-token"), + { code: "ERR_BAD_RESPONSE", status: 500 }, + ); + + await assert.rejects( + diagnostics.phase("callTool", async () => { + now = 9.2; + throw error; + }), + /Token refresh failed/, + ); + + const event = parseDebugLines(lines).at(-1)!; + const serialized = JSON.stringify(event); + + assert.equal(event.event, "phase_error"); + assert.equal(event.phase, "callTool"); + assert.equal(event.duration_ms, 4); + assert.equal((event.error as Record).name, "Error"); + assert.equal((event.error as Record).code, "ERR_BAD_RESPONSE"); + assert.equal((event.error as Record).status, 500); + assert.match((event.error as Record).message, /access_token=\[REDACTED\]/); + assert.match((event.error as Record).message, /client_secret=\[REDACTED\]/); + assert.doesNotMatch(serialized, /abc123|shh|secret-token/); +}); + +test("sanitizeError handles non-Error throws without leaking token-shaped values", () => { + const sanitized = sanitizeError({ + message: "failed with refresh_token=refresh-secret", + token: "raw-token", + code: "E_TOKEN", + }); + + assert.deepEqual(sanitized, { + name: "Object", + message: "failed with refresh_token=[REDACTED]", + code: "E_TOKEN", + }); + assert.doesNotMatch(JSON.stringify(sanitized), /refresh-secret|raw-token/); +}); From 3466ffaf98152e3e5555827edc6df1ccdd45a6a4 Mon Sep 17 00:00:00 2001 From: Rasul Kireev Date: Tue, 30 Jun 2026 16:45:02 +0000 Subject: [PATCH 2/2] test(cli): cover debug auth errors --- src/index.ts | 1 + tests/cli-debug.test.ts | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 tests/cli-debug.test.ts diff --git a/src/index.ts b/src/index.ts index 113c424..f6c6388 100644 --- a/src/index.ts +++ b/src/index.ts @@ -210,6 +210,7 @@ async function main() { // If not authenticated and trying a non-login command, tell user to log in if (!config.access_token && hasSubcommand && positionalArgs[0] !== "login" && positionalArgs[0] !== "login-with-token" && positionalArgs[0] !== "skills" && positionalArgs[0] !== "config") { + diagnostics.error("auth.required", new Error("Not logged in."), { command: positionalArgs[0] }); process.stderr.write("\x1b[31mNot logged in.\x1b[0m Run `readwise login` or `readwise login-with-token` to authenticate.\n"); process.exitCode = 1; return; diff --git a/tests/cli-debug.test.ts b/tests/cli-debug.test.ts new file mode 100644 index 0000000..286ef9f --- /dev/null +++ b/tests/cli-debug.test.ts @@ -0,0 +1,36 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + +test("debug output includes sanitized error details for unauthenticated commands", () => { + const home = mkdtempSync(join(tmpdir(), "readwise-cli-test-")); + + try { + const result = spawnSync( + process.execPath, + ["--import", "tsx", "src/index.ts", "--debug", "reader-search-documents", "--query", "secret query"], + { + cwd: process.cwd(), + env: { + ...process.env, + HOME: home, + READWISE_CLI_DEBUG: undefined, + }, + encoding: "utf-8", + }, + ); + + assert.equal(result.status, 1); + assert.match(result.stderr, /\[readwise-cli debug\]/); + assert.match(result.stderr, /"event":"error"/); + assert.match(result.stderr, /"phase":"auth.required"/); + assert.match(result.stderr, /"command":"reader-search-documents"/); + assert.match(result.stderr, /Not logged in/); + assert.doesNotMatch(result.stderr, /secret query/); + } finally { + rmSync(home, { recursive: true, force: true }); + } +});