From 3cc7c48fe2a33e24197611e94a0ef35b166e1727 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Tue, 24 Mar 2026 14:43:25 +0100 Subject: [PATCH 1/2] feat: add global --log-level option and Sentry breadcrumb tracing - Add global --log-level option (default: info) to control CLI log verbosity without environment variables - Connect logDebug/logError/logWarn to Sentry breadcrumbs via a callback hook (avoids circular dependency between log.ts and sentry.ts) - logInfo/logWarn now respect the log level (previously always emitted) - DEBUG=1 env var still works as a shortcut for --log-level debug --- src/cli.ts | 14 ++++++-- src/helpers/log.ts | 78 +++++++++++++++++++++++++++++++++++++++++-- src/helpers/sentry.ts | 9 ++++- 3 files changed, 94 insertions(+), 7 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index bc6b955..38a1a8f 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,5 +1,5 @@ #!/usr/bin/env bun -import { Command } from "@commander-js/extra-typings"; +import { Command, Option } from "@commander-js/extra-typings"; import { semver } from "bun"; import packageJson from "../package.json"; @@ -15,7 +15,7 @@ import { registerSessionContextCommand } from "./commands/session-context/index" import { registerTelemetryCommand } from "./commands/telemetry"; import { registerUpgradeCommand } from "./commands/upgrade"; import { installGit } from "./helpers/git"; -import { logError } from "./helpers/log"; +import { type LogLevel, logError, setLogLevel } from "./helpers/log"; import { createPathIfNotExists, paths } from "./helpers/paths"; import { getPlatformInfo, isSupportedPlatform } from "./helpers/platform"; import { @@ -66,14 +66,22 @@ async function main() { initSentry(); initTelemetry(); + const logLevelOption = new Option("--log-level ", "Set log verbosity") + .choices(["error", "warn", "info", "debug"] as const) + .default("info" as const); + const program = new Command() .name("archgate") .version(packageJson.version) - .description("AI governance for software development"); + .description("AI governance for software development") + .addOption(logLevelOption); // Track command execution for Sentry breadcrumbs and PostHog analytics let commandStartTime = 0; program.hook("preAction", (thisCommand) => { + // Apply log level from global option before any command runs + const rootOpts = program.opts(); + setLogLevel(rootOpts.logLevel as LogLevel); const fullCommand = getFullCommandName(thisCommand); addBreadcrumb("command", `Running: ${fullCommand}`); // Collect which options were used (presence only, no values) diff --git a/src/helpers/log.ts b/src/helpers/log.ts index 9f91787..fe02432 100644 --- a/src/helpers/log.ts +++ b/src/helpers/log.ts @@ -1,21 +1,93 @@ import { styleText } from "node:util"; +// --------------------------------------------------------------------------- +// Log levels +// --------------------------------------------------------------------------- + +export type LogLevel = "error" | "warn" | "info" | "debug"; + +const LOG_LEVEL_PRIORITY: Record = { + error: 0, + warn: 1, + info: 2, + debug: 3, +}; + +/** + * The active log level. Defaults to "info", meaning error/warn/info are shown + * and debug is suppressed. Set via `setLogLevel()` from the global `--log-level` + * option, or via the `DEBUG` environment variable (which forces "debug"). + */ +let currentLevel: LogLevel = Bun.env.DEBUG ? "debug" : "info"; + +/** + * Set the active log level. Called once from CLI initialization when + * the `--log-level` global option is parsed. + */ +export function setLogLevel(level: LogLevel): void { + currentLevel = level; + if (level === "debug") { + Bun.env.DEBUG = "1"; + } +} + +/** Get the current log level. */ +export function getLogLevel(): LogLevel { + return currentLevel; +} + +function isEnabled(level: LogLevel): boolean { + return LOG_LEVEL_PRIORITY[level] <= LOG_LEVEL_PRIORITY[currentLevel]; +} + +// --------------------------------------------------------------------------- +// Breadcrumb hook — allows Sentry to tap into log calls without circular deps +// --------------------------------------------------------------------------- + +type BreadcrumbFn = ( + category: string, + message: string, + level: "debug" | "info" | "warning" | "error" +) => void; + +let breadcrumbHook: BreadcrumbFn | null = null; + +/** + * Register a function that receives log events as Sentry breadcrumbs. + * Called once from sentry.ts after Sentry is initialized. + */ +export function registerBreadcrumbHook(fn: BreadcrumbFn): void { + breadcrumbHook = fn; +} + +// --------------------------------------------------------------------------- +// Log functions +// --------------------------------------------------------------------------- + export function logDebug(...args: Parameters) { - if (Bun.env.DEBUG) { + if (isEnabled("debug") || Bun.env.DEBUG) { const header = styleText("bgWhite", "DEBUG:"); console.warn(header, ...args); + breadcrumbHook?.("log", args.map(String).join(" "), "debug"); } if (Bun.env.TRACE) console.trace(); } export function logInfo(...args: Parameters) { - console.log(styleText("bold", "info:"), ...args); + if (isEnabled("info")) { + console.log(styleText("bold", "info:"), ...args); + } } export function logError(...args: Parameters) { + // Errors are always shown regardless of log level console.error(styleText(["red", "bold"], "error:"), ...args); + breadcrumbHook?.("log", args.map(String).join(" "), "error"); } export function logWarn(...args: Parameters) { - console.warn(styleText(["yellow", "bold"], "warn:"), ...args); + if (isEnabled("warn")) { + console.warn(styleText(["yellow", "bold"], "warn:"), ...args); + breadcrumbHook?.("log", args.map(String).join(" "), "warning"); + } } diff --git a/src/helpers/sentry.ts b/src/helpers/sentry.ts index 50e92fa..627203b 100644 --- a/src/helpers/sentry.ts +++ b/src/helpers/sentry.ts @@ -17,7 +17,7 @@ import * as Sentry from "@sentry/node-core/light"; import packageJson from "../../package.json"; import { detectInstallMethod } from "./install-info"; -import { logDebug } from "./log"; +import { logDebug, registerBreadcrumbHook } from "./log"; import { getPlatformInfo } from "./platform"; import { getInstallId, isTelemetryEnabled } from "./telemetry-config"; @@ -117,6 +117,13 @@ export function initSentry(): void { }); initialized = true; + + // Connect log helpers to Sentry breadcrumbs so logDebug/logError/logWarn + // calls are captured in the breadcrumb trail for error reports. + registerBreadcrumbHook((category, message, level) => { + addBreadcrumb(category, message, undefined, level); + }); + logDebug("Sentry initialized"); } catch { logDebug("Sentry init failed (silently ignored)"); From 3747b84b3e274fad4830ed32dc7dcbd00102b163 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Tue, 24 Mar 2026 15:08:33 +0100 Subject: [PATCH 2/2] feat: improve debug logging across CLI Add logDebug calls to critical code paths that previously had no diagnostic output, making --log-level debug useful for troubleshooting: - upgrade command: install method detection, version comparison, binary download, external upgrade process spawning - binary-upgrade: GitHub API fetch, download size, archive extraction, binary replacement - auth: device code request/polling, GitHub user fetch, token claim - login-flow: step-by-step flow progress, signup fallback - signup: request submission and response status - editor-detect: detection results for all editors - git-files: all git commands, file counts, error fallbacks --- src/commands/upgrade.ts | 41 ++++++++++++++++++++++++++++++++--- src/engine/git-files.ts | 24 +++++++++++++++++--- src/helpers/auth.ts | 18 ++++++++++++++- src/helpers/binary-upgrade.ts | 11 +++++++++- src/helpers/editor-detect.ts | 3 +++ src/helpers/login-flow.ts | 9 +++++++- src/helpers/signup.ts | 5 +++++ 7 files changed, 102 insertions(+), 9 deletions(-) diff --git a/src/commands/upgrade.ts b/src/commands/upgrade.ts index f07fcbc..6a2e419 100644 --- a/src/commands/upgrade.ts +++ b/src/commands/upgrade.ts @@ -11,7 +11,7 @@ import { getManualInstallHint, replaceBinary, } from "../helpers/binary-upgrade"; -import { logError } from "../helpers/log"; +import { logDebug, logError } from "../helpers/log"; import { internalPath } from "../helpers/paths"; import { getPlatformInfo, resolveCommand } from "../helpers/platform"; import { trackUpgradeResult } from "../helpers/telemetry"; @@ -114,10 +114,15 @@ async function detectLocalPm(): Promise<{ async function getGlobalBinDir(cmd: string[]): Promise { try { + logDebug("Getting global bin dir:", cmd.join(" ")); const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "pipe" }); const stdout = await new Response(proc.stdout).text(); const exitCode = await proc.exited; - if (exitCode !== 0) return null; + if (exitCode !== 0) { + logDebug("Global bin dir command failed, exit code:", exitCode); + return null; + } + logDebug("Global bin dir:", stdout.trim()); return stdout.trim() || null; } catch { return null; @@ -125,21 +130,32 @@ async function getGlobalBinDir(cmd: string[]): Promise { } async function detectInstallMethod(): Promise { + logDebug("Detecting install method, execPath:", process.execPath); + if (isBinaryInstall()) { + logDebug("Install method: binary"); return { type: "binary", binaryPath: process.execPath }; } if (isProtoInstall()) { const protoCmd = (await resolveCommand("proto")) ?? "proto"; + logDebug("Install method: proto, cmd:", protoCmd); return { type: "proto", protoCmd }; } if (isLocalInstall()) { const local = await detectLocalPm(); - if (local) return { type: "local", ...local }; + if (local) { + logDebug("Install method: local, pm:", local.cmd); + return { type: "local", ...local }; + } } const binaryPath = process.execPath; + logDebug( + "Checking package managers:", + PACKAGE_MANAGERS.map((pm) => pm.name).join(", ") + ); const candidates = await Promise.all( PACKAGE_MANAGERS.map(async (pm) => { @@ -147,6 +163,14 @@ async function detectInstallMethod(): Promise { if (!resolved) return null; const globalBinCmd = [resolved, ...pm.globalBinCmd.slice(1)]; const binDir = await getGlobalBinDir(globalBinCmd); + logDebug( + "PM candidate:", + pm.name, + "resolved:", + resolved, + "binDir:", + binDir + ); return { pm, resolved, binDir }; }) ); @@ -156,6 +180,7 @@ async function detectInstallMethod(): Promise { ); if (match) { + logDebug("Install method: package-manager, matched:", match.pm.name); return { type: "package-manager", cmd: match.resolved, @@ -164,6 +189,7 @@ async function detectInstallMethod(): Promise { }; } + logDebug("Install method: package-manager (fallback to npm)"); const npmCandidate = candidates.find((c) => c?.pm.name === "npm"); const npm = PACKAGE_MANAGERS.find((pm) => pm.name === "npm")!; return { @@ -175,6 +201,7 @@ async function detectInstallMethod(): Promise { } async function upgradeBinary(tag: string): Promise { + logDebug("Upgrading via binary download for tag:", tag); const artifact = getArtifactInfo(); if (!artifact) { logError( @@ -184,9 +211,12 @@ async function upgradeBinary(tag: string): Promise { process.exit(1); } + logDebug("Artifact:", artifact.name, "ext:", artifact.ext); const hint = getManualInstallHint(); try { const newBinaryPath = await downloadReleaseBinary(tag, artifact); + logDebug("Downloaded binary to:", newBinaryPath); + logDebug("Replacing binary:", process.execPath); replaceBinary(process.execPath, newBinaryPath); } catch (err) { logError( @@ -201,8 +231,10 @@ async function runExternalUpgrade( cmd: string[], manualHint: string ): Promise { + logDebug("Running external upgrade:", cmd.join(" ")); const proc = Bun.spawn(cmd, { stdout: "inherit", stderr: "inherit" }); const exitCode = await proc.exited; + logDebug("External upgrade exit code:", exitCode); if (exitCode !== 0) { logError( @@ -222,6 +254,7 @@ export function registerUpgradeCommand(program: Command) { console.log("Checking for latest Archgate release..."); const tag = await fetchLatestGitHubVersion(); + logDebug("GitHub latest tag:", tag ?? "(null)"); if (!tag) { logError( "Failed to fetch release info from GitHub.", @@ -233,6 +266,7 @@ export function registerUpgradeCommand(program: Command) { const packageJson = await import("../../package.json"); const currentVersion = packageJson.default.version; const latestVersion = tag.replace(/^v/, ""); + logDebug("Version comparison:", currentVersion, "vs", latestVersion); const order = semver.order(currentVersion, latestVersion); if (order === null) { @@ -250,6 +284,7 @@ export function registerUpgradeCommand(program: Command) { console.log(`Upgrading ${currentVersion} -> ${latestVersion}...`); const method = await detectInstallMethod(); + logDebug("Upgrade method:", method.type); if (method.type === "binary") { await upgradeBinary(tag); diff --git a/src/engine/git-files.ts b/src/engine/git-files.ts index e8670f5..7a30072 100644 --- a/src/engine/git-files.ts +++ b/src/engine/git-files.ts @@ -1,10 +1,13 @@ /** Git file-listing utilities for ADR scope resolution and change detection. */ +import { logDebug } from "../helpers/log"; + /** * Run a git command using Bun.spawn (cross-platform, no shell). * Bun.$ hangs on Windows due to pipe handling issues — this is the safe alternative. */ async function runGit(args: string[], cwd: string): Promise { + logDebug("Running: git", args.join(" ")); const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", @@ -27,8 +30,11 @@ export async function getGitTrackedFiles( ["ls-files", "--cached", "--others", "--exclude-standard"], projectRoot ); - return new Set(result.trim().split("\n").filter(Boolean)); + const files = new Set(result.trim().split("\n").filter(Boolean)); + logDebug("Git tracked files:", files.size); + return files; } catch { + logDebug("Git tracked files lookup failed (not a git repo?)"); return null; } } @@ -54,7 +60,14 @@ export async function resolveScopedFiles( }) ); - return [...new Set(results.flat())].sort(); + const all = [...new Set(results.flat())].sort(); + logDebug( + "Scoped files resolved:", + all.length, + "from patterns:", + patterns.join(", ") + ); + return all; } /** Get changed files from git staging area. */ @@ -64,8 +77,11 @@ export async function getStagedFiles(projectRoot: string): Promise { ["diff", "--cached", "--name-only"], projectRoot ); - return result.trim().split("\n").filter(Boolean); + const files = result.trim().split("\n").filter(Boolean); + logDebug("Staged files:", files.length); + return files; } catch { + logDebug("Failed to get staged files (not a git repo?)"); return []; } } @@ -82,8 +98,10 @@ export async function getChangedFiles(projectRoot: string): Promise { ...staged.trim().split("\n").filter(Boolean), ...unstaged.trim().split("\n").filter(Boolean), ]); + logDebug("Changed files (staged + unstaged):", all.size); return [...all].sort(); } catch { + logDebug("Failed to get changed files (not a git repo?)"); return []; } } diff --git a/src/helpers/auth.ts b/src/helpers/auth.ts index 9ab041b..d6b8a6d 100644 --- a/src/helpers/auth.ts +++ b/src/helpers/auth.ts @@ -6,6 +6,7 @@ * credential helpers (macOS Keychain, Windows Credential Manager, libsecret). */ +import { logDebug } from "./log"; import { SignupRequiredError, isSignupRequiredError } from "./signup"; // --------------------------------------------------------------------------- @@ -64,6 +65,7 @@ type DeviceTokenResponse = * The user will be shown the `user_code` and asked to visit `verification_uri`. */ export async function requestDeviceCode(): Promise { + logDebug("Requesting device code from:", GITHUB_DEVICE_CODE_URL); const response = await fetch(GITHUB_DEVICE_CODE_URL, { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json" }, @@ -72,12 +74,15 @@ export async function requestDeviceCode(): Promise { }); if (!response.ok) { + logDebug("Device code request failed, status:", response.status); throw new Error( `GitHub device code request failed (HTTP ${response.status})` ); } - return (await response.json()) as DeviceCodeResponse; + const data = (await response.json()) as DeviceCodeResponse; + logDebug("Device code received, expires in:", data.expires_in, "seconds"); + return data; } /** @@ -91,6 +96,10 @@ export async function pollForAccessToken( ): Promise { const deadline = Date.now() + expiresIn * 1000; let pollInterval = interval; + logDebug( + "Starting device code polling, deadline:", + new Date(deadline).toISOString() + ); /* oxlint-disable no-await-in-loop -- sequential polling is required by RFC 8628 */ while (Date.now() < deadline) { @@ -117,15 +126,18 @@ export async function pollForAccessToken( const data = (await response.json()) as DeviceTokenResponse; if ("access_token" in data) { + logDebug("Access token received"); return data.access_token; } if ("error" in data) { if (data.error === "authorization_pending") { + logDebug("Authorization pending, retrying in", pollInterval, "seconds"); continue; } if (data.error === "slow_down") { pollInterval += 5; + logDebug("Slow down requested, new interval:", pollInterval, "seconds"); continue; } // expired_token, access_denied, or other terminal error @@ -150,6 +162,7 @@ export interface GitHubUserInfo { export async function getGitHubUser( accessToken: string ): Promise { + logDebug("Fetching GitHub user info"); const response = await fetch("https://api.github.com/user", { headers: { Authorization: `Bearer ${accessToken}`, @@ -169,6 +182,7 @@ export async function getGitHubUser( if (!data.login) { throw new Error("GitHub API did not return a username"); } + logDebug("GitHub user:", data.login); return { login: data.login, email: data.email ?? null }; } @@ -181,6 +195,7 @@ export async function getGitHubUser( * via POST /api/token/claim on the plugins service. */ export async function claimArchgateToken(githubToken: string): Promise { + logDebug("Claiming archgate token from:", `${PLUGINS_API}/api/token/claim`); const response = await fetch(`${PLUGINS_API}/api/token/claim`, { method: "POST", headers: { @@ -210,5 +225,6 @@ export async function claimArchgateToken(githubToken: string): Promise { if (!data.token) { throw new Error("Plugins service did not return a token"); } + logDebug("Archgate token claimed successfully"); return data.token; } diff --git a/src/helpers/binary-upgrade.ts b/src/helpers/binary-upgrade.ts index 111a8f2..50d6cfb 100644 --- a/src/helpers/binary-upgrade.ts +++ b/src/helpers/binary-upgrade.ts @@ -68,14 +68,19 @@ const GITHUB_RELEASES_API = `https://api.github.com/repos/${GITHUB_REPO}/release * Returns the tag (e.g. "v0.13.1") or null on failure. */ export async function fetchLatestGitHubVersion(): Promise { + logDebug("Fetching latest release from:", GITHUB_RELEASES_API); const response = await fetch(GITHUB_RELEASES_API, { headers: { "User-Agent": "archgate-cli" }, signal: AbortSignal.timeout(15_000), }); - if (!response.ok) return null; + if (!response.ok) { + logDebug("GitHub API response not ok, status:", response.status); + return null; + } const data = (await response.json()) as GitHubRelease; + logDebug("Latest release tag:", data.tag_name ?? "(none)"); return data.tag_name ?? null; } @@ -95,6 +100,7 @@ export async function downloadReleaseBinary( const archiveUrl = `${baseUrl}/${artifact.name}${artifact.ext}`; const checksumUrl = `${baseUrl}/${artifact.name}${artifact.ext}.sha256`; + logDebug("Downloading binary from:", archiveUrl); const response = await fetch(archiveUrl, { headers: { "User-Agent": "archgate-cli" }, signal: AbortSignal.timeout(60_000), @@ -105,6 +111,7 @@ export async function downloadReleaseBinary( } const buffer = await response.arrayBuffer(); + logDebug("Downloaded", Math.round(buffer.byteLength / 1024), "KB"); // Verify SHA256 checksum when available (releases after this change) try { @@ -135,6 +142,7 @@ export async function downloadReleaseBinary( } const tmpDir = mkdtempSync(join(tmpdir(), "archgate-upgrade-")); const archivePath = join(tmpDir, `archgate${artifact.ext}`); + logDebug("Extracting archive to:", tmpDir); await Bun.write(archivePath, buffer); @@ -204,6 +212,7 @@ export function replaceBinary( currentPath: string, newBinaryPath: string ): void { + logDebug("Replacing binary:", currentPath, "with:", newBinaryPath); if (isWindows()) { const oldPath = currentPath + ".old"; diff --git a/src/helpers/editor-detect.ts b/src/helpers/editor-detect.ts index 8276545..3b92023 100644 --- a/src/helpers/editor-detect.ts +++ b/src/helpers/editor-detect.ts @@ -9,6 +9,7 @@ import inquirer from "inquirer"; import { EDITOR_LABELS } from "./init-project"; import type { EditorTarget } from "./init-project"; +import { logDebug } from "./log"; import { resolveCommand } from "./platform"; import { isClaudeCliAvailable, @@ -28,6 +29,7 @@ export interface DetectedEditor { * Runs all checks in parallel for speed. */ export async function detectEditors(): Promise { + logDebug("Detecting available editor CLIs"); const [claude, cursor, vscode, copilot] = await Promise.all([ isClaudeCliAvailable(), resolveCommand("cursor").then((r) => r !== null), @@ -35,6 +37,7 @@ export async function detectEditors(): Promise { isCopilotCliAvailable(), ]); + logDebug("Editor detection:", { claude, cursor, vscode, copilot }); return [ { id: "claude" as const, label: EDITOR_LABELS.claude, available: claude }, { id: "cursor" as const, label: EDITOR_LABELS.cursor, available: cursor }, diff --git a/src/helpers/login-flow.ts b/src/helpers/login-flow.ts index 1d703d1..a3bbbd4 100644 --- a/src/helpers/login-flow.ts +++ b/src/helpers/login-flow.ts @@ -14,7 +14,7 @@ import { claimArchgateToken, } from "./auth"; import { saveCredentials } from "./credential-store"; -import { logError, logInfo } from "./log"; +import { logDebug, logError, logInfo } from "./log"; import { SignupRequiredError, requestSignup } from "./signup"; export interface LoginFlowOptions { @@ -44,7 +44,12 @@ export async function runLoginFlow( ): Promise { logInfo("Authenticating with GitHub...\n"); + logDebug("Starting login flow"); const deviceCode = await requestDeviceCode(); + logDebug( + "Device code received, verification URI:", + deviceCode.verification_uri + ); console.log( `Open ${styleText("bold", deviceCode.verification_uri)} in your browser` ); @@ -67,8 +72,10 @@ export async function runLoginFlow( let archgateToken: string; try { archgateToken = await claimArchgateToken(githubToken); + logDebug("Token claimed successfully"); } catch (err) { if (!(err instanceof SignupRequiredError)) throw err; + logDebug("Signup required — starting signup flow"); console.log( `\nYour GitHub account ${styleText("bold", githubUser)} is not yet registered.` diff --git a/src/helpers/signup.ts b/src/helpers/signup.ts index d909eb0..737f3a2 100644 --- a/src/helpers/signup.ts +++ b/src/helpers/signup.ts @@ -2,6 +2,8 @@ * signup.ts — Archgate plugins platform signup for unregistered users. */ +import { logDebug } from "./log"; + const PLUGINS_API = "https://plugins.archgate.dev"; /** @@ -43,6 +45,7 @@ export async function requestSignup( useCase: string, editor: string = "claude-code" ): Promise { + logDebug("Submitting signup request for:", github); const response = await fetch(`${PLUGINS_API}/api/signup`, { method: "POST", headers: { @@ -56,9 +59,11 @@ export async function requestSignup( }); if (response.status !== 201) { + logDebug("Signup request failed, status:", response.status); return { ok: false, token: null }; } const data = (await response.json().catch(() => ({}))) as { token?: string }; + logDebug("Signup successful, token provided:", Boolean(data.token)); return { ok: true, token: data.token ?? null }; }