From 47b86340914a185808ec1337b2135cac153ae601 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:04:32 +0000 Subject: [PATCH] perf(cli): Replace consola with a small in-house logger consola was ~75 KB of the ~320 KB `dist/complete.cjs` bundle that `sentry __complete` loads on every TAB press. The CLI used only levels, tags, the fancy stderr layout, Sentry log forwarding, and two prompt types. The new logger keeps consola's output layout and log record shape, so `Sentry.createConsolaReporter()` still forwards logs. Prompts call `@clack/prompts` directly and load only when asked, and the completion bundle drops them. Ctrl+C on a prompt now returns clack's cancel symbol, which callers already expect, instead of the initial value. Fixes #1769 Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- packages/cli/package.json | 1 - packages/cli/src/cli.ts | 2 +- packages/cli/src/commands/issue/plan.ts | 2 +- packages/cli/src/commands/log/view.ts | 4 +- packages/cli/src/lib/logger.ts | 536 ++++++++++++++---- packages/cli/src/lib/mutate-command.ts | 2 +- packages/cli/src/lib/polling.ts | 4 +- packages/cli/src/lib/seer-trial.ts | 2 +- packages/cli/src/lib/telemetry.ts | 2 +- packages/cli/src/lib/timezone.ts | 6 +- packages/cli/test/commands/auth/login.test.ts | 2 +- packages/cli/test/commands/cli.test.ts | 6 +- packages/cli/test/commands/cli/fix.test.ts | 2 +- packages/cli/test/commands/cli/setup.test.ts | 8 +- .../cli/test/commands/cli/upgrade.test.ts | 6 +- packages/cli/test/commands/event/view.test.ts | 2 +- .../test/commands/issue/merge.func.test.ts | 2 +- .../cli/test/commands/issue/plan.func.test.ts | 2 +- packages/cli/test/commands/log/list.test.ts | 2 +- .../cli/test/commands/log/view.func.test.ts | 2 +- packages/cli/test/commands/log/view.test.ts | 4 +- .../cli/test/commands/project/delete.test.ts | 2 +- .../cli/test/commands/trace/view.func.test.ts | 2 +- packages/cli/test/e2e/auth.test.ts | 8 +- packages/cli/test/lib/auth-hint.test.ts | 4 +- packages/cli/test/lib/logger.test.ts | 149 ++++- packages/cli/test/lib/seer-trial.test.ts | 2 +- packages/cli/test/script/cli-startup.test.ts | 2 - pnpm-lock.yaml | 9 - 29 files changed, 597 insertions(+), 180 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 4c5150f99..84d5dfad8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -125,7 +125,6 @@ "binpatch": "^0.4.2", "chalk": "^5.6.2", "cli-highlight": "^2.1.11", - "consola": "^3.4.2", "esbuild": "^0.28.1", "fast-check": "^4.8.0", "fflate": "^0.8.3", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index b3f27257b..73f8accc1 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -41,7 +41,7 @@ async function preloadProjectContext(cwd: string): Promise { // Apply .sentryclirc env shim (token + URL). The URL trust check is // deferred to buildCommand's wrapper where commands can opt out via // skipRcUrlCheck (used by auth login/logout). - // Dynamic import: sentryclirc chains into db/index → sqlite, logger → consola + // Dynamic import: sentryclirc chains into db/index → sqlite and the logger const { applySentryCliRcEnvShim } = await import("./lib/sentryclirc.js"); await applySentryCliRcEnvShim(cwd); diff --git a/packages/cli/src/commands/issue/plan.ts b/packages/cli/src/commands/issue/plan.ts index f89cc3691..717a442c2 100644 --- a/packages/cli/src/commands/issue/plan.ts +++ b/packages/cli/src/commands/issue/plan.ts @@ -189,7 +189,7 @@ async function confirmRootCauseAndContinue( initial: true, }); - // consola returns a non-boolean (Symbol(clack:cancel)) when cancelled. + // The prompt returns a non-boolean (clack's cancel symbol) when cancelled. if (proceed === true) { return; } diff --git a/packages/cli/src/commands/log/view.ts b/packages/cli/src/commands/log/view.ts index f0a41e784..7e92c691f 100644 --- a/packages/cli/src/commands/log/view.ts +++ b/packages/cli/src/commands/log/view.ts @@ -271,7 +271,7 @@ function formatIdList(ids: string[]): string { /** * Warn about IDs that weren't found in the API response. - * Uses the consola logger for structured output to stderr. + * Uses the logger for structured output to stderr. * * @param logIds - All requested IDs * @param logs - Logs actually returned by the API @@ -310,7 +310,7 @@ async function handleWebOpen(orgSlug: string, logIds: string[]): Promise { type: "confirm", initial: false, }); - // consola prompt returns Symbol(clack:cancel) on Ctrl+C — a truthy value. + // The prompt returns Symbol(clack:cancel) on Ctrl+C — a truthy value. // Strictly check for `true` to avoid opening tabs on cancel. if (confirmed !== true) { return; diff --git a/packages/cli/src/lib/logger.ts b/packages/cli/src/lib/logger.ts index be4c598fe..94e5007ce 100644 --- a/packages/cli/src/lib/logger.ts +++ b/packages/cli/src/lib/logger.ts @@ -1,28 +1,31 @@ /** * Structured logging for Sentry CLI. * - * Built on {@link https://github.com/unjs/consola | consola} — a lightweight CLI logger - * with log levels, tag scoping, and fancy TTY output. Two reporters are wired up: - * - * 1. **FancyReporter** (built-in) — writes to stderr with colors/icons for TTY, - * falls back to BasicReporter in CI/non-TTY environments. Its `formatDate` is - * overridden (see {@link patchReporterDates}) so the `[tag HH:MM:SS]` prefix - * renders in corrected local time — consola's default renders UTC in the SEA + * A small in-house logger with log levels, tag scoping, and consola-style + * terminal output. It replaced `consola`, the largest third-party package that + * `sentry __complete` loaded on every TAB press. Each log call goes to every + * registered reporter: + * + * 1. **The stderr reporter** (built in) — renders consola's "fancy" layout + * (icons, colors, `WARN`/`ERROR` badges, a right-aligned `tag HH:MM:SS`) and + * falls back to plain `[type] [tag] message` lines in CI and tests. The time + * comes from {@link formatLogTime}, which stays in local time in the SEA * binaries this CLI ships. * 2. **Sentry.createConsolaReporter()** — auto-forwards all log messages to Sentry - * structured logs via `_INTERNAL_captureLog`. Library mode drops these logs - * in `beforeSendLog` (see telemetry.ts). + * structured logs via `_INTERNAL_captureLog`. Log records keep consola's + * `{ date, args, type, level, tag }` shape so that reporter still reads them. + * Library mode drops these logs in `beforeSendLog` (see telemetry.ts). * * ## Log levels * - * | Level | consola | Shows | - * |-------|---------|-------| - * | error | 0 | fatal, error | - * | warn | 1 | + warn | - * | log | 2 | + log | - * | info | 3 | + info, success, start, ready, fail (default) | - * | debug | 4 | + debug | - * | trace | 5 | + trace, verbose | + * | Level | Value | Shows | + * |-------|-------|-------| + * | error | 0 | error | + * | warn | 1 | + warn | + * | log | 2 | + log | + * | info | 3 | + info, success, start, fail (default) | + * | debug | 4 | + debug | + * | trace | 5 | + trace | * * ## Usage * @@ -44,23 +47,18 @@ * ## Environment variables * * - `SENTRY_LOG_LEVEL` — Sets log level: `error`, `warn`, `info` (default), `debug`, `trace` - * - `NO_COLOR` — Disables color output (respected by consola natively) + * - `NO_COLOR` — Disables color output * - `FORCE_COLOR` — Forces color output even in non-TTY * - * ## withTag level propagation - * - * Consola's `withTag()` creates an independent instance that snapshots the - * parent's level at creation time. Scoped loggers created at module load - * time (e.g. `const log = logger.withTag("upgrade")`) would miss later - * `setLogLevel()` calls. To fix this, the logger's `withTag` method is - * wrapped to register all children in a registry, and `setLogLevel()` - * propagates the level to every registered child. + * Every logger, including the scoped ones `withTag()` returns, shares one level + * and one reporter list, so a later `setLogLevel()` also reaches the scoped + * loggers that modules create at import time. * * @module */ -import type { ConsolaInstance, ConsolaReporter } from "consola"; -import { createConsola } from "consola"; +import { sep } from "node:path"; +import { formatWithOptions } from "node:util"; import { getEnv } from "./env.js"; import { formatLogTime } from "./timezone.js"; @@ -68,7 +66,7 @@ import { formatLogTime } from "./timezone.js"; /** * Environment variable name for controlling CLI log verbosity. * - * Using `SENTRY_LOG_LEVEL` (not `CONSOLA_LEVEL`) to be consistent with + * Using `SENTRY_LOG_LEVEL` (not a logger-specific name) to be consistent with * other Sentry CLI env vars (`SENTRY_URL`, `SENTRY_AUTH_TOKEN`, etc.). */ export const LOG_LEVEL_ENV_VAR = "SENTRY_LOG_LEVEL"; @@ -76,13 +74,13 @@ export const LOG_LEVEL_ENV_VAR = "SENTRY_LOG_LEVEL"; /** * Valid log level names accepted by `SENTRY_LOG_LEVEL` and `--log-level`. * - * Array index IS the consola numeric level: - * - 0 = error (includes fatal) + * Array index IS the numeric level: + * - 0 = error * - 1 = warn * - 2 = log - * - 3 = info (default — also shows success, start, ready, fail) + * - 3 = info (default — also shows success, start, fail) * - 4 = debug - * - 5 = trace (includes verbose) + * - 5 = trace */ export const LOG_LEVEL_NAMES = [ "error", @@ -98,18 +96,18 @@ export type LogLevelName = (typeof LOG_LEVEL_NAMES)[number]; /** * Default log level when nothing is configured. - * Level 3 shows info, success, start, ready, log, fail — standard user-facing output. + * Level 3 shows info, success, start, log, fail — standard user-facing output. */ const DEFAULT_LOG_LEVEL = 3; /** - * Parse a log level name string into a consola numeric level. + * Parse a log level name string into a numeric level. * * Returns the default level (info = 3) for unrecognized values so that * a typo in `SENTRY_LOG_LEVEL` doesn't break the CLI. * * @param name - Level name from env var or CLI flag - * @returns consola numeric level + * @returns numeric level */ export function parseLogLevel(name: string): number { const idx = LOG_LEVEL_NAMES.indexOf( @@ -124,7 +122,7 @@ export function parseLogLevel(name: string): number { * Following the same pattern as `getSentryUrl()` in oauth.ts — env vars are read * at call time (not module load time) so tests can set them after import. * - * @returns consola numeric level, or null if not set + * @returns numeric level, or null if not set */ export function getEnvLogLevel(): number | null { const envLevel = getEnv()[LOG_LEVEL_ENV_VAR]; @@ -134,15 +132,159 @@ export function getEnvLogLevel(): number | null { return null; } +/** Level of each log method: a call prints when it is at most the logger's level. */ +const LOG_TYPE_LEVELS = { + error: 0, + warn: 1, + log: 2, + info: 3, + success: 3, + fail: 3, + start: 3, + debug: 4, + trace: 5, +} as const; + +/** A log method name, such as `info` or `warn`. */ +type LogType = keyof typeof LOG_TYPE_LEVELS; + +/** + * One log call, in the shape consola reporters read, so the reporter from + * `Sentry.createConsolaReporter()` can forward it. + */ +export type LogObject = { + date: Date; + args: unknown[]; + type: LogType; + level: number; + tag: string; +}; + +/** Receives every log call at or below the current level. */ +export type LogReporter = { + log(logObj: LogObject): void; +}; + +/** Options for a yes/no prompt. */ +type ConfirmPromptOptions = { type: "confirm"; initial?: boolean }; + +/** Options for a free-text prompt. */ +type TextPromptOptions = { + type: "text"; + placeholder?: string; + initial?: string; + default?: string; +}; + +/** + * Set to `true` by esbuild in the `dist/complete.cjs` bundle (see + * `script/bundle.ts`), so the `@clack/prompts` import below stays out of the + * shell completion bundle. Undefined when running from source. + */ +declare const SENTRY_CLI_COMPLETION_BUILD: boolean | undefined; + /** - * Registry of all scoped loggers created via `logger.withTag()` at any depth. + * Ask the user a question on the terminal. * - * When `setLogLevel()` is called, every registered descendant is updated so that - * module-level scoped loggers (created at import time, before the CLI parses - * `--log-level` or `SENTRY_LOG_LEVEL`) see the new level. Grandchildren and - * deeper descendants are tracked via recursive `patchWithTag()`. + * Resolves to the answer, or to clack's cancel symbol when the user presses + * Ctrl+C, so callers check for `=== true` or `typeof answer === "string"`. */ -const scopedLoggers: ConsolaInstance[] = []; +function askUser( + message: string, + opts: ConfirmPromptOptions, +): Promise; +function askUser( + message: string, + opts: TextPromptOptions, +): Promise; +async function askUser( + message: string, + opts: ConfirmPromptOptions | TextPromptOptions, +): Promise { + if ( + typeof SENTRY_CLI_COMPLETION_BUILD !== "undefined" && + SENTRY_CLI_COMPLETION_BUILD + ) { + throw new Error("Prompts are not available during shell completion."); + } else { + // The `else` lets esbuild drop this import from the completion bundle. + // Elsewhere `@clack/prompts` loads only when a command asks a question. + const clack = await import("@clack/prompts"); + if (opts.type === "confirm") { + return await clack.confirm({ message, initialValue: opts.initial }); + } + return await clack.text({ + message, + placeholder: opts.placeholder, + initialValue: opts.initial, + defaultValue: opts.default, + }); + } +} + +/** Level and reporters shared by the root logger and every scoped logger. */ +const state: { level: number; reporters: LogReporter[] } = { + level: DEFAULT_LOG_LEVEL, + reporters: [{ log: writeLogToStderr }], +}; + +/** A logger, optionally scoped to a tag shown with each message. */ +class Logger { + /** Tag shown with each message; nested tags join with `:`. */ + readonly tag: string; + + // Arrow properties keep the methods bound, so they can be passed around. + readonly error = (...args: unknown[]): void => this.emit("error", args); + readonly warn = (...args: unknown[]): void => this.emit("warn", args); + readonly log = (...args: unknown[]): void => this.emit("log", args); + readonly info = (...args: unknown[]): void => this.emit("info", args); + readonly success = (...args: unknown[]): void => this.emit("success", args); + readonly fail = (...args: unknown[]): void => this.emit("fail", args); + readonly start = (...args: unknown[]): void => this.emit("start", args); + readonly debug = (...args: unknown[]): void => this.emit("debug", args); + readonly trace = (...args: unknown[]): void => this.emit("trace", args); + readonly prompt = askUser; + + constructor(tag = "") { + this.tag = tag; + } + + /** The shared level: calls above it are dropped. */ + get level(): number { + return state.level; + } + + set level(level: number) { + state.level = level; + } + + /** Return a logger whose messages carry `tag`, nested under this logger's tag. */ + withTag(tag: string): Logger { + return new Logger(this.tag ? `${this.tag}:${tag}` : tag); + } + + /** Send every later log call to `reporter` too. */ + addReporter(reporter: LogReporter): void { + state.reporters.push(reporter); + } + + private emit(type: LogType, args: unknown[]): void { + const level = LOG_TYPE_LEVELS[type]; + if (level > state.level) { + return; + } + const logObj: LogObject = { + date: new Date(), + args, + type, + level, + tag: this.tag, + }; + for (const reporter of state.reporters) { + reporter.log(logObj); + } + } +} /** * Global CLI logger instance. @@ -151,6 +293,9 @@ const scopedLoggers: ConsolaInstance[] = []; * Use `logger.debug()`, `logger.trace()` for diagnostic output. * Use `logger.withTag('scope')` for domain-specific scoped loggers. * + * All diagnostic/log output goes to stderr — stdout is reserved for command + * output (data, JSON, tables). + * * The Sentry reporter is added lazily via {@link attachSentryReporter} after * Sentry.init() completes, since the reporter needs an active Sentry client. * @@ -158,21 +303,14 @@ const scopedLoggers: ConsolaInstance[] = []; * by bin.ts calling `setLogLevel(getEnvLogLevel())` — not at module load time — * so tests can override the env var after import. */ -export const logger = createConsola({ - level: DEFAULT_LOG_LEVEL, - // All diagnostic/log output goes to stderr — stdout is reserved for - // command output (data, JSON, tables). Both streams must be set because - // consola's BasicReporter (non-TTY) routes debug/info to stdout by default. - stdout: process.stderr, - stderr: process.stderr, -}); +export const logger = new Logger(); /** - * Write an already-formatted line to stderr verbatim, bypassing consola. + * Write an already-formatted line to stderr verbatim, bypassing the logger. * * Event tails (`sentry local`) render their own prefix — an event timestamp, * level tag, and source tag — before printing. Routing those lines through - * `logger.log()` makes consola's reporter prepend/append a *second* timestamp + * `logger.log()` makes the reporter prepend/append a *second* timestamp * (the wall-clock print time), so every line carried two different times. * Writing directly keeps the single, meaningful event timestamp. * @@ -195,63 +333,6 @@ export function printJsonLine(line: string): void { process.stdout.write(`${line}\n`); } -/** - * Patch a consola instance's `withTag` so every child (and grandchild) - * is registered in {@link scopedLoggers} for {@link setLogLevel} propagation. - */ -function patchWithTag(instance: ConsolaInstance): void { - const original = instance.withTag.bind(instance); - instance.withTag = (tag: string): ConsolaInstance => { - const child = original(tag); - scopedLoggers.push(child); - patchWithTag(child); - return child; - }; -} - -// Patch the root logger so all descendants are tracked. -// Consola's withTag() creates a completely independent instance that -// snapshots the level at creation time — children never see later -// setLogLevel() calls. By registering them here, setLogLevel() can -// propagate the new level to all descendants. -patchWithTag(logger); - -/** - * A consola reporter exposes a `formatDate(date, opts)` method that produces - * the time shown in the `[tag HH:MM:SS]` log prefix. Consola does not export - * its `FancyReporter`/`BasicReporter` classes, so we type just the slice we - * override. - */ -type DateFormattingReporter = { - formatDate?: (date: Date, opts: unknown) => string; -}; - -/** - * Replace each default reporter's `formatDate` with {@link formatLogTime}. - * - * Consola's built-in reporters format the prefix time via - * `date.toLocaleTimeString()` with no arguments. That call renders in whatever - * timezone the runtime resolved — which silently falls back to **UTC** in the - * SEA binaries this CLI ships, so users saw log timestamps hours off from their - * local clock. Overriding `formatDate` guarantees the prefix uses the corrected - * local time (see {@link formatLogTime}) regardless of ICU state, and switches - * to an unambiguous 24-hour format. - * - * The instances are reused from `logger.options.reporters` so all of consola's - * other formatting (icons, colors, alignment, badges) is preserved untouched. - */ -function patchReporterDates(instance: ConsolaInstance): void { - const reporters = (instance.options?.reporters ?? - []) as DateFormattingReporter[]; - for (const reporter of reporters) { - if (typeof reporter.formatDate === "function") { - reporter.formatDate = (date: Date) => formatLogTime(date); - } - } -} - -patchReporterDates(logger); - /** Whether the Sentry reporter has already been attached */ let sentryReporterAttached = false; @@ -263,15 +344,15 @@ let sentryReporterAttached = false; * The caller creates the reporter so this module never imports `@sentry/node`: * the `__complete` fast path loads the logger through the database modules. * - * The reporter auto-forwards all consola log messages to Sentry structured logs, - * mapping consola types to Sentry severity levels: - * - fatal → fatal, error → error, warn → warn - * - info/log/success/ready/start → info - * - debug/verbose → debug, trace → trace + * The reporter auto-forwards all log messages to Sentry structured logs, + * mapping log types to Sentry severity levels: + * - error → error, fail → error, warn → warn + * - info/log/success/start → info + * - debug → debug, trace → trace * * Tags are preserved as `consola.tag` attributes in Sentry. */ -export function attachSentryReporter(reporter: ConsolaReporter): void { +export function attachSentryReporter(reporter: LogReporter): void { if (sentryReporterAttached) { return; } @@ -281,22 +362,225 @@ export function attachSentryReporter(reporter: ConsolaReporter): void { } /** - * Set the logger level at runtime and propagate to all scoped children. + * Set the level of the logger and every scoped logger at runtime. * * Called from: * - bin.ts to apply `SENTRY_LOG_LEVEL` env var early * - `buildCommand` wrapper when `--log-level` or `--verbose` flags are parsed * - * Propagation is necessary because consola's `withTag()` creates independent - * instances that snapshot the parent's level at creation time. Module-level - * scoped loggers (e.g. `const log = logger.withTag("upgrade")`) would never - * see a later level change without this. - * - * @param level - consola numeric level (0-5) + * @param level - numeric level (0-5) */ export function setLogLevel(level: number): void { - logger.level = level; - for (const child of scopedLoggers) { - child.level = level; + state.level = level; +} + +// stderr rendering + +/** SGR open and close codes for the styles the reporter uses. */ +const ANSI_CODES = { + black: [30, 39], + red: [31, 39], + green: [32, 39], + yellow: [33, 39], + magenta: [35, 39], + cyan: [36, 39], + gray: [90, 39], + underline: [4, 24], + bgRed: [41, 49], + bgYellow: [43, 49], +} as const; + +type AnsiStyle = keyof typeof ANSI_CODES; + +/** Icon color per log type. The error and warn levels render as badges instead. */ +const TYPE_COLORS: Record = { + error: "red", + warn: "yellow", + log: "gray", + info: "cyan", + success: "green", + fail: "red", + start: "magenta", + debug: "gray", + trace: "gray", +}; + +/** Badge background for the error (0) and warn (1) levels. */ +const BADGE_BACKGROUNDS: Record = { + 0: "bgRed", + 1: "bgYellow", +}; + +/** Unicode icon per log type, and its ASCII fallback. */ +const TYPE_ICONS: Record = { + error: ["✖", "×"], + warn: ["⚠", "‼"], + log: ["", ""], + info: ["ℹ", "i"], + success: ["✔", "√"], + fail: ["✖", "×"], + start: ["◐", "o"], + debug: ["⚙", "D"], + trace: ["→", "→"], +}; + +/** Matches SGR escape sequences, to measure the visible width of a line. */ +// oxlint-disable-next-line no-control-regex -- matching ANSI escapes requires ESC +const ANSI_PATTERN = /\x1b\[[0-9;]*m/g; + +/** Inline `code` spans, rendered in cyan without their backticks. */ +const CODE_SPAN_PATTERN = /`([^`]+)`/g; + +/** ` _word_ ` spans, rendered underlined. */ +const UNDERLINE_PATTERN = /\s+_([^_]+)_\s+/g; + +/** Whether an env var is set to something other than `""` or `"false"`. */ +function isEnabled(value: string | undefined): boolean { + return Boolean(value) && value !== "false"; +} + +/** + * Whether to render the fancy layout. CI logs and test runs get plain + * `[type] [tag] message` lines instead, as they did with consola. + */ +function useFancyLayout(): boolean { + const env = getEnv(); + return !(isEnabled(env.CI) || env.NODE_ENV === "test" || isEnabled(env.TEST)); +} + +/** Whether stderr takes ANSI colors: `NO_COLOR`, then `FORCE_COLOR`, then TTY detection. */ +function useColor(): boolean { + const env = getEnv(); + if (env.NO_COLOR) { + return false; } + if (env.FORCE_COLOR) { + return env.FORCE_COLOR !== "0"; + } + return Boolean(process.stderr.isTTY) && env.TERM !== "dumb"; +} + +/** Whether the terminal can draw the Unicode icons. */ +function supportsUnicode(): boolean { + const env = getEnv(); + if (process.platform !== "win32") { + return env.TERM !== "linux"; + } + return ( + Boolean(env.WT_SESSION) || + env.TERM_PROGRAM === "vscode" || + env.TERM === "xterm-256color" || + env.TERM === "alacritty" || + env.TERMINAL_EMULATOR === "JetBrains-JediTerm" + ); +} + +/** + * Approximate visible width of a line: its length without ANSI escapes. Wide + * characters count as one column, which only shifts the right-aligned tag. + */ +function visibleWidth(text: string): number { + return text.replace(ANSI_PATTERN, "").length; +} + +/** Options for formatting log arguments, as consola passed them. */ +const FORMAT_OPTIONS = { colors: false, compact: true } as const; + +/** An `Error`, or anything else carrying a stack. */ +type ErrorLike = { message?: unknown; stack: string; cause?: unknown }; + +function isErrorLike(value: unknown): value is ErrorLike { + return ( + typeof value === "object" && + value !== null && + typeof (value as { stack?: unknown }).stack === "string" + ); +} + +/** + * Render an error as its message, its stack frames, and its causes, as consola + * did. Unlike `util.inspect`, this leaves out the error's other properties, + * which can hold API response details. + */ +function formatError(error: ErrorLike, depth: number): string { + const message = + typeof error.message === "string" + ? error.message + : formatWithOptions(FORMAT_OPTIONS, error); + const indent = " ".repeat(depth + 1); + const cwd = process.cwd() + sep; + const frames = error.stack + .split("\n") + .slice(message.split("\n").length) + .map( + (frame) => indent + frame.trim().replace("file://", "").replace(cwd, ""), + ) + .join("\n"); + const prefix = depth > 0 ? `${" ".repeat(depth)}[cause]: ` : ""; + let cause = ""; + if (isErrorLike(error.cause)) { + cause = `\n\n${formatError(error.cause, depth + 1)}`; + } else if (error.cause) { + cause = `\n\n${" ".repeat(depth + 1)}[cause]: ${formatWithOptions(FORMAT_OPTIONS, error.cause)}`; + } + return `${prefix}${message}\n${frames}${cause}`; +} + +/** Render `logObj` as terminal lines, without a trailing newline. */ +function formatLogObj(logObj: LogObject): string { + const message = formatWithOptions( + FORMAT_OPTIONS, + ...logObj.args.map((arg) => (isErrorLike(arg) ? formatError(arg, 0) : arg)), + ); + if (!useFancyLayout()) { + return [`[${logObj.type}]`, logObj.tag && `[${logObj.tag}]`, message] + .filter(Boolean) + .join(" "); + } + + const color = useColor(); + const paint = (style: AnsiStyle, text: string): string => { + if (!(color && text)) { + return text; + } + const [open, close] = ANSI_CODES[style]; + return `\x1b[${open}m${text}\x1b[${close}m`; + }; + const markup = (text: string): string => + text + .replace(CODE_SPAN_PATTERN, (_, code: string) => paint("cyan", code)) + .replace( + UNDERLINE_PATTERN, + (_, word: string) => ` ${paint("underline", word)} `, + ); + + const [firstLine = "", ...additional] = message.split("\n"); + const badge = BADGE_BACKGROUNDS[logObj.level]; + const type = badge + ? paint(badge, paint("black", ` ${logObj.type.toUpperCase()} `)) + : paint( + TYPE_COLORS[logObj.type], + TYPE_ICONS[logObj.type][supportsUnicode() ? 0 : 1], + ); + + const columns = process.stderr.columns || 0; + const tag = logObj.tag ? paint("gray", logObj.tag) : ""; + const date = columns ? paint("gray", formatLogTime(logObj.date)) : ""; + const left = [type, markup(firstLine)].filter(Boolean).join(" "); + const right = [tag, date].filter(Boolean).join(" "); + const space = columns - visibleWidth(left) - visibleWidth(right) - 2; + + let line = + space > 0 && columns >= 80 + ? left + " ".repeat(space) + right + : (right ? `${paint("gray", `[${right}]`)} ` : "") + left; + if (additional.length > 0) { + line += markup(`\n${additional.join("\n")}`); + } + return badge ? `\n${line}\n` : line; +} + +/** The built-in reporter: render the log call and write it to stderr in one write. */ +function writeLogToStderr(logObj: LogObject): void { + process.stderr.write(`${formatLogObj(logObj)}\n`); } diff --git a/packages/cli/src/lib/mutate-command.ts b/packages/cli/src/lib/mutate-command.ts index baed2da39..e99ee1c1a 100644 --- a/packages/cli/src/lib/mutate-command.ts +++ b/packages/cli/src/lib/mutate-command.ts @@ -208,7 +208,7 @@ export async function confirmByTyping( placeholder: expected, }); - // consola prompt returns Symbol(clack:cancel) on Ctrl+C — a truthy value. + // The prompt returns Symbol(clack:cancel) on Ctrl+C — a truthy value. // Check type to avoid treating cancel as a valid response. if (typeof response !== "string") { return false; diff --git a/packages/cli/src/lib/polling.ts b/packages/cli/src/lib/polling.ts index fb411dc11..776d28f19 100644 --- a/packages/cli/src/lib/polling.ts +++ b/packages/cli/src/lib/polling.ts @@ -120,7 +120,7 @@ export async function poll(options: PollOptions): Promise { /** * Start an animated spinner that writes progress to stdout. * - * Uses stdout so the spinner doesn't collide with consola log messages + * Uses stdout so the spinner doesn't collide with logger messages * on stderr. The spinner is erased before command output is written, * and is suppressed entirely in JSON mode and when stdout is not a TTY. * @@ -188,7 +188,7 @@ export type WithProgressOptions = { * * The spinner uses the same braille frames as the Seer polling spinner, * giving a consistent look across all CLI commands. Progress output goes - * to stdout so it doesn't collide with consola log messages on stderr. + * to stdout so it doesn't collide with logger messages on stderr. * * The spinner is suppressed when: * - `options.json` is true (JSON mode — no ANSI noise for agents/CI) diff --git a/packages/cli/src/lib/seer-trial.ts b/packages/cli/src/lib/seer-trial.ts index 45f20ed85..c18204ab1 100644 --- a/packages/cli/src/lib/seer-trial.ts +++ b/packages/cli/src/lib/seer-trial.ts @@ -60,7 +60,7 @@ export function isTrialEligible(error: unknown): error is SeerError { * 2. Show context message + prompt user for confirmation * 3. Start the trial via API * - * Uses consola logger for all output (not raw stderr writes). + * Uses the logger for all output (not raw stderr writes). * * @param orgSlug - Organization slug * @param reason - The SeerError reason (for context message) diff --git a/packages/cli/src/lib/telemetry.ts b/packages/cli/src/lib/telemetry.ts index 9a473c8d9..bfef24f53 100644 --- a/packages/cli/src/lib/telemetry.ts +++ b/packages/cli/src/lib/telemetry.ts @@ -786,7 +786,7 @@ export function initSentry( }); } - // Wire up consola → Sentry log forwarding now that the client is active + // Wire up logger → Sentry log forwarding now that the client is active attachSentryReporter(Sentry.createConsolaReporter()); // End healthy sessions and flush events when the event loop drains. diff --git a/packages/cli/src/lib/timezone.ts b/packages/cli/src/lib/timezone.ts index aa06d8b99..7cdf7299c 100644 --- a/packages/cli/src/lib/timezone.ts +++ b/packages/cli/src/lib/timezone.ts @@ -3,8 +3,8 @@ * * ## Why this exists * - * All human-facing timestamps in the CLI — the `[tag 6:28:59 AM]` log prefix - * emitted by consola, and every `toLocaleString()`/`toLocaleTimeString()` call + * All human-facing timestamps in the CLI — the `[tag 06:28:59]` log prefix + * emitted by the logger, and every `toLocaleString()`/`toLocaleTimeString()` call * in the formatters — are rendered by the JavaScript runtime using its notion * of the "local" timezone. That notion comes from ICU/`process.env.TZ`. * @@ -257,7 +257,7 @@ export function initTimezone(): string | null { /** * Format a `Date` as a local-time clock string for the log prefix. * - * Renders 24-hour `HH:MM:SS` in the machine's local timezone. Unlike consola's + * Renders 24-hour `HH:MM:SS` in the machine's local timezone. Unlike the * default `date.toLocaleTimeString()` this does **not** depend on ICU being * able to resolve a zone: it derives the local wall-clock time from the UTC * time and `getTimezoneOffset()`, which reflects the OS offset even in runtimes diff --git a/packages/cli/test/commands/auth/login.test.ts b/packages/cli/test/commands/auth/login.test.ts index 6a63a465b..e65019257 100644 --- a/packages/cli/test/commands/auth/login.test.ts +++ b/packages/cli/test/commands/auth/login.test.ts @@ -915,7 +915,7 @@ describe("login re-authentication interactive prompt", () => { test("cancel (Ctrl+C): returns without re-auth", async () => { isAuthenticatedSpy.mockReturnValue(true); getUserInfoSpy.mockReturnValue(undefined); - // consola returns Symbol(clack:cancel) on Ctrl+C — truthy but not `true`. + // The prompt returns Symbol(clack:cancel) on Ctrl+C — truthy but not `true`. mockPrompt.mockResolvedValue(Symbol("clack:cancel")); const context = createPromptContext(); diff --git a/packages/cli/test/commands/cli.test.ts b/packages/cli/test/commands/cli.test.ts index 7506f217f..df97de88a 100644 --- a/packages/cli/test/commands/cli.test.ts +++ b/packages/cli/test/commands/cli.test.ts @@ -3,7 +3,7 @@ * * Tests for the sentry cli command group. * - * Progress messages go through consola (→ process.stderr). Final results are + * Progress messages go through the logger (→ process.stderr). Final results are * returned as structured data and rendered to stdout by the output system. * Tests capture both stderr (progress) and stdout (results) to verify behavior. */ @@ -16,7 +16,7 @@ import { extractInputUrl } from "../../src/lib/fetch-input.js"; /** * Create a mock context with a process.stderr.write spy for capturing - * consola output, plus stdout capture for structured output. + * logger output, plus stdout capture for structured output. */ function createMockContext(overrides: Partial<{ execPath: string }> = {}): { context: Record; @@ -29,7 +29,7 @@ function createMockContext(overrides: Partial<{ execPath: string }> = {}): { const stdoutChunks: string[] = []; const errors: string[] = []; - // Capture consola output (routed to process.stderr) + // Capture logger output (routed to process.stderr) const origWrite = process.stderr.write.bind(process.stderr); process.stderr.write = ((chunk: string | Uint8Array) => { stderrChunks.push(String(chunk)); diff --git a/packages/cli/test/commands/cli/fix.test.ts b/packages/cli/test/commands/cli/fix.test.ts index 87ece5ce2..6dd5ec002 100644 --- a/packages/cli/test/commands/cli/fix.test.ts +++ b/packages/cli/test/commands/cli/fix.test.ts @@ -88,7 +88,7 @@ function createContext() { }, stderr: { write: vi.fn((_s: string) => { - /* consola routes here — not used for structured output */ + /* the logger routes here — not used for structured output */ }), }, process: { exitCode: 0 }, diff --git a/packages/cli/test/commands/cli/setup.test.ts b/packages/cli/test/commands/cli/setup.test.ts index ba03d92db..37a482720 100644 --- a/packages/cli/test/commands/cli/setup.test.ts +++ b/packages/cli/test/commands/cli/setup.test.ts @@ -3,7 +3,7 @@ * * Tests the `sentry cli setup` command end-to-end through Stricli's run(). * - * Status messages go through consola (→ process.stderr). Tests capture stderr + * Status messages go through the logger (→ process.stderr). Tests capture stderr * via a spy on process.stderr.write and assert on the collected output. */ @@ -86,11 +86,11 @@ function mockFetch( } /** - * Create a mock Stricli context and a stderr capture for consola output. + * Create a mock Stricli context and a stderr capture for logger output. * * The context provides process/env stubs for the setup command, while - * `getOutput()` returns the combined consola output captured from - * `process.stderr.write` (where consola routes all messages). + * `getOutput()` returns the combined logger output captured from + * `process.stderr.write` (where the logger routes all messages). */ function createMockContext( overrides: Partial<{ diff --git a/packages/cli/test/commands/cli/upgrade.test.ts b/packages/cli/test/commands/cli/upgrade.test.ts index ee57b7976..7f94e8fc9 100644 --- a/packages/cli/test/commands/cli/upgrade.test.ts +++ b/packages/cli/test/commands/cli/upgrade.test.ts @@ -5,7 +5,7 @@ * Covers resolveTargetVersion branches (check mode, already up-to-date, * version validation) and error paths. * - * Status messages go through consola (→ process.stderr). Tests capture stderr + * Status messages go through the logger (→ process.stderr). Tests capture stderr * via a spy on process.stderr.write and assert on the collected output. */ @@ -87,7 +87,7 @@ function mockFetch( /** * Create a mock Stricli context with stderr and stdout capture. * - * `getOutput()` returns **both** consola output (stderr) and structured + * `getOutput()` returns **both** logger output (stderr) and structured * output (stdout) combined, so assertions work regardless of whether * a message is a progress log or a rendered result. * `errors` captures Stricli error output written to context.stderr. @@ -121,7 +121,7 @@ function createMockContext( const origPlain = process.env.SENTRY_PLAIN_OUTPUT; process.env.SENTRY_PLAIN_OUTPUT = "0"; - // Capture consola output (routed to process.stderr) + // Capture logger output (routed to process.stderr) const origStderrWrite = process.stderr.write.bind(process.stderr); process.stderr.write = ((chunk: string | Uint8Array) => { stderrChunks.push(String(chunk)); diff --git a/packages/cli/test/commands/event/view.test.ts b/packages/cli/test/commands/event/view.test.ts index 5a186f29c..baf2b266c 100644 --- a/packages/cli/test/commands/event/view.test.ts +++ b/packages/cli/test/commands/event/view.test.ts @@ -1135,7 +1135,7 @@ describe("viewCommand.func", () => { "test-org/test-proj", ); - // Command should complete without error (warning goes to consola, not stdout) + // Command should complete without error (warning goes to the logger, not stdout) expect(getEventSpy).toHaveBeenCalled(); }); diff --git a/packages/cli/test/commands/issue/merge.func.test.ts b/packages/cli/test/commands/issue/merge.func.test.ts index ad90cc39f..a61e27c59 100644 --- a/packages/cli/test/commands/issue/merge.func.test.ts +++ b/packages/cli/test/commands/issue/merge.func.test.ts @@ -472,7 +472,7 @@ describe("mergeCommand.func()", () => { // User asked for CLI-B, but Sentry picked CLI-A (e.g. larger by count) mergeSpy.mockResolvedValue({ parent: "10A", children: ["10B"] }); - // Warning now goes through log.warn() (consola → process.stderr), not + // Warning now goes through log.warn() (logger → process.stderr), not // this.stderr.write(). Spy on process.stderr.write to capture it. const stderrSpy = vi.spyOn(process.stderr, "write"); try { diff --git a/packages/cli/test/commands/issue/plan.func.test.ts b/packages/cli/test/commands/issue/plan.func.test.ts index e6dcd7b4a..e3773e832 100644 --- a/packages/cli/test/commands/issue/plan.func.test.ts +++ b/packages/cli/test/commands/issue/plan.func.test.ts @@ -130,7 +130,7 @@ describe("issue plan waiting-for-user-response", () => { test("throws when the user cancels the prompt", async () => { vi.mocked(isatty).mockReturnValue(true); analyzeSpy.mockResolvedValue(waitingState([sampleCause(0, "A cause")])); - // consola returns a cancel symbol (non-boolean) when cancelled. + // The prompt returns a cancel symbol (non-boolean) when cancelled. promptSpy.mockResolvedValue(Symbol("clack:cancel")); await expect(runPlan(createMockContext())).rejects.toBeInstanceOf(CliError); diff --git a/packages/cli/test/commands/log/list.test.ts b/packages/cli/test/commands/log/list.test.ts index 0a4ff21e5..5748b6cb2 100644 --- a/packages/cli/test/commands/log/list.test.ts +++ b/packages/cli/test/commands/log/list.test.ts @@ -959,7 +959,7 @@ describe("listCommand.func — trace mode org resolution failure", () => { /** * Collect all output written to a `process.stderr.write` spy. - * Handles both string and Buffer arguments from consola/logger. + * Handles both string and Buffer arguments from the logger. */ function collectProcessStderr( spy: ReturnType>, diff --git a/packages/cli/test/commands/log/view.func.test.ts b/packages/cli/test/commands/log/view.func.test.ts index 8283f48d7..a804ff3a0 100644 --- a/packages/cli/test/commands/log/view.func.test.ts +++ b/packages/cli/test/commands/log/view.func.test.ts @@ -244,7 +244,7 @@ describe("viewCommand.func", () => { }); test("still outputs found logs when some IDs are missing", async () => { - // Only ID1 found, ID2 and ID3 missing — warning goes through consola + // Only ID1 found, ID2 and ID3 missing — warning goes through the logger getLogsSpy.mockResolvedValue([makeSampleLog(ID1)]); const { context, stdoutWrite } = createMockContext(); diff --git a/packages/cli/test/commands/log/view.test.ts b/packages/cli/test/commands/log/view.test.ts index 502c34aae..ce6b6a360 100644 --- a/packages/cli/test/commands/log/view.test.ts +++ b/packages/cli/test/commands/log/view.test.ts @@ -732,8 +732,8 @@ describe("log view --web interactive prompt", () => { }); test("aborts when user cancels prompt with Ctrl+C (truthy Symbol)", async () => { - // consola returns Symbol(clack:cancel) on Ctrl+C — truthy but not `true`. - // Cast needed because the mock is typed as boolean but consola actually + // The prompt returns Symbol(clack:cancel) on Ctrl+C — truthy but not `true`. + // Cast needed because the mock is typed as boolean but the prompt actually // returns a Symbol on cancel. mockPrompt.mockResolvedValue(Symbol("clack:cancel") as unknown as boolean); openInBrowserSpy.mockResolvedValue(undefined); diff --git a/packages/cli/test/commands/project/delete.test.ts b/packages/cli/test/commands/project/delete.test.ts index 151469704..73456c37f 100644 --- a/packages/cli/test/commands/project/delete.test.ts +++ b/packages/cli/test/commands/project/delete.test.ts @@ -491,7 +491,7 @@ describe("project delete — interactive confirmation", () => { }); test("cancels when user presses Ctrl+C (Symbol)", async () => { - // consola returns Symbol(clack:cancel) on Ctrl+C — truthy but not a string. + // The prompt returns Symbol(clack:cancel) on Ctrl+C — truthy but not a string. mockPrompt.mockResolvedValue(Symbol("clack:cancel")); const { context } = createPromptMockContext(); diff --git a/packages/cli/test/commands/trace/view.func.test.ts b/packages/cli/test/commands/trace/view.func.test.ts index 20c35834b..5b53aa64d 100644 --- a/packages/cli/test/commands/trace/view.func.test.ts +++ b/packages/cli/test/commands/trace/view.func.test.ts @@ -717,7 +717,7 @@ describe("viewCommand.func", () => { "test-org/test-project", ); - // Command should complete (warning goes to consola, not stdout) + // Command should complete (warning goes to the logger, not stdout) expect(getDetailedTraceSpy).toHaveBeenCalled(); }); diff --git a/packages/cli/test/e2e/auth.test.ts b/packages/cli/test/e2e/auth.test.ts index 1c401fe4e..678ccb501 100644 --- a/packages/cli/test/e2e/auth.test.ts +++ b/packages/cli/test/e2e/auth.test.ts @@ -60,7 +60,7 @@ describe("sentry auth status", () => { const result = await ctx.run(["auth", "status"]); - // Status messages go to stderr via consola + // Status messages go to stderr via the logger const output = result.stdout + result.stderr; expect(output).toContain("Authenticated"); expect(result.exitCode).toBe(0); @@ -72,7 +72,7 @@ describe("sentry auth status", () => { const result = await ctx.run(["auth", "status"]); expect(result.exitCode).toBe(0); - // Status messages go to stderr via consola + // Status messages go to stderr via the logger const output = result.stdout + result.stderr; expect(output).toContain("Authenticated"); expect(output).toContain("Access verified"); @@ -90,7 +90,7 @@ describe("sentry auth login --token", () => { ctx.serverUrl, ]); - // Login messages go to stderr via consola + // Login messages go to stderr via the logger const output = result.stdout + result.stderr; expect(output).toContain("Authenticated"); expect(result.exitCode).toBe(0); @@ -233,7 +233,7 @@ describe("sentry auth logout", () => { const result = await ctx.run(["auth", "logout"]); expect(result.exitCode).toBe(0); - // Logout messages go to stderr via consola + // Logout messages go to stderr via the logger const logoutOutput = result.stdout + result.stderr; expect(logoutOutput).toMatch(/logged out/i); diff --git a/packages/cli/test/lib/auth-hint.test.ts b/packages/cli/test/lib/auth-hint.test.ts index 3c06746ed..e2d7b60c8 100644 --- a/packages/cli/test/lib/auth-hint.test.ts +++ b/packages/cli/test/lib/auth-hint.test.ts @@ -64,10 +64,10 @@ afterEach(() => { }); /** - * Capture stderr output from consola's default reporter. + * Capture stderr output from the logger's stderr reporter. * * We can't reliably spy on `logger.withTag("auth").info` from a test - * because each `withTag()` call returns a fresh consola instance — the + * because each `withTag()` call returns a fresh logger instance — the * spy target and the instance used inside `auth-hint.ts` are * different objects. Hooking `process.stderr.write` captures the final * rendered output regardless of which instance emitted it. diff --git a/packages/cli/test/lib/logger.test.ts b/packages/cli/test/lib/logger.test.ts index 06ee25eb3..98305916e 100644 --- a/packages/cli/test/lib/logger.test.ts +++ b/packages/cli/test/lib/logger.test.ts @@ -12,12 +12,20 @@ import { getEnvLogLevel, LOG_LEVEL_ENV_VAR, LOG_LEVEL_NAMES, + type LogObject, logger, parseLogLevel, printLine, setLogLevel, } from "../../src/lib/logger.js"; +const clack = vi.hoisted(() => ({ + confirm: vi.fn(), + text: vi.fn(), +})); + +vi.mock("@clack/prompts", () => clack); + describe("parseLogLevel", () => { test("maps known level names to correct numeric values", () => { expect(parseLogLevel("error")).toBe(0); @@ -49,7 +57,7 @@ describe("parseLogLevel", () => { }); describe("LOG_LEVEL_NAMES", () => { - test("contains all expected level names with index = consola numeric level", () => { + test("contains all expected level names with index = numeric level", () => { expect(LOG_LEVEL_NAMES).toEqual([ "error", // 0 "warn", // 1 @@ -185,7 +193,7 @@ describe("setLogLevel", () => { }); describe("logger instance", () => { - test("is a consola instance with expected methods", () => { + test("exposes the log methods", () => { expect(typeof logger.info).toBe("function"); expect(typeof logger.debug).toBe("function"); expect(typeof logger.warn).toBe("function"); @@ -282,3 +290,140 @@ describe("attachSentryReporter", () => { expect(() => attachSentryReporter(createConsolaReporter())).not.toThrow(); }); }); + +describe("reporters", () => { + let originalLevel: number; + let records: LogObject[]; + let stderr: ReturnType; + + beforeEach(() => { + originalLevel = logger.level; + records = []; + stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + }); + + afterEach(() => { + setLogLevel(originalLevel); + stderr.mockRestore(); + }); + + test("receive consola-shaped records that Sentry's reporter reads", () => { + // The level and reporter list are shared, so a reporter added through the + // root logger also sees records from scoped loggers. + logger.addReporter({ log: (logObj) => records.push(logObj) }); + const scoped = logger.withTag("upgrade").withTag("delta"); + setLogLevel(3); + + scoped.warn("Patch %s failed", "0.13.0"); + scoped.debug("dropped above the level"); + + expect(records).toEqual([ + { + date: expect.any(Date), + args: ["Patch %s failed", "0.13.0"], + type: "warn", + level: 1, + tag: "upgrade:delta", + }, + ]); + }); +}); + +describe("stderr output", () => { + const ENV_KEYS = ["CI", "TEST", "NODE_ENV", "NO_COLOR", "FORCE_COLOR"]; + let savedEnv: Record; + let stderr: ReturnType; + + /** Text written to stderr so far. */ + const written = () => + stderr.mock.calls.map((call) => String(call[0])).join(""); + + beforeEach(() => { + savedEnv = Object.fromEntries( + ENV_KEYS.map((key) => [key, process.env[key]]), + ); + stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + }); + + afterEach(() => { + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = savedEnv[key]; + } + } + stderr.mockRestore(); + }); + + test("writes plain [type] [tag] lines in tests and CI", () => { + process.env.CI = "true"; + logger.withTag("auth").info("Logged in as %s", "jane"); + expect(written()).toBe("[info] [auth] Logged in as jane\n"); + }); + + test("writes an error's message, stack, and cause but not its other properties", () => { + process.env.CI = "true"; + const error = Object.assign( + new Error("Request failed", { cause: new Error("socket hang up") }), + { response: { headers: { authorization: "Bearer secret" } } }, + ); + + logger.warn("Upload failed:", error); + + const output = written(); + expect(output).toMatch(/^\[warn\] Upload failed: Request failed\n {2}at /); + expect(output).toContain("\n\n [cause]: socket hang up\n at "); + expect(output).not.toContain("secret"); + }); + + test("writes icons, badges, and inline code in the interactive layout", () => { + for (const key of ["CI", "TEST"]) { + delete process.env[key]; + } + process.env.NODE_ENV = "production"; + process.env.NO_COLOR = "1"; + + logger.withTag("auth").success("Run `sentry auth status`"); + logger.warn("Token expires soon"); + + // Non-TTY stderr has no columns, so the tag leads the line. + expect(written()).toBe( + "[auth] ✔ Run sentry auth status\n\n WARN Token expires soon\n\n", + ); + }); +}); + +describe("prompt", () => { + test("returns clack's cancel symbol instead of the initial value on Ctrl+C", async () => { + const cancel = Symbol("clack:cancel"); + clack.confirm.mockResolvedValue(cancel); + + const answer = await logger.prompt("Import from .sentryclirc?", { + type: "confirm", + initial: true, + }); + + expect(clack.confirm).toHaveBeenCalledWith({ + message: "Import from .sentryclirc?", + initialValue: true, + }); + expect(answer).toBe(cancel); + }); + + test("passes text prompt options through to clack", async () => { + clack.text.mockResolvedValue("my-project"); + + const answer = await logger + .withTag("delete") + .prompt("Type the project slug:", { type: "text", placeholder: "slug" }); + + expect(clack.text).toHaveBeenCalledWith({ + message: "Type the project slug:", + placeholder: "slug", + initialValue: undefined, + defaultValue: undefined, + }); + expect(answer).toBe("my-project"); + }); +}); diff --git a/packages/cli/test/lib/seer-trial.test.ts b/packages/cli/test/lib/seer-trial.test.ts index 29728c305..bb3d73647 100644 --- a/packages/cli/test/lib/seer-trial.test.ts +++ b/packages/cli/test/lib/seer-trial.test.ts @@ -178,7 +178,7 @@ describe("promptAndStartTrial", () => { test("returns false when user cancels with Ctrl+C (Symbol)", async () => { getProductTrialsSpy.mockResolvedValue([MOCK_SEER_TRIAL]); - // consola returns Symbol(clack:cancel) on Ctrl+C + // The prompt returns Symbol(clack:cancel) on Ctrl+C loggerPromptSpy.mockResolvedValue(Symbol("clack:cancel")); const result = await promptAndStartTrial("test-org", "no_budget"); diff --git a/packages/cli/test/script/cli-startup.test.ts b/packages/cli/test/script/cli-startup.test.ts index 6f55bd444..adf72cbb1 100644 --- a/packages/cli/test/script/cli-startup.test.ts +++ b/packages/cli/test/script/cli-startup.test.ts @@ -22,8 +22,6 @@ const COMPLETION_PACKAGES = new Set([ "@sentry/toolkit-core/auth-token", // `errors.ts` (exit codes) "@stricli/core", - // `logger.ts`, which the database modules log through - "consola", // `db/accounts.ts` account IDs "uuidv7", ]); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a4b2895a..34b47fec2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -537,9 +537,6 @@ importers: cli-highlight: specifier: ^2.1.11 version: 2.1.11 - consola: - specifier: ^3.4.2 - version: 3.4.2 esbuild: specifier: ^0.28.1 version: 0.28.1 @@ -5861,10 +5858,6 @@ packages: resolution: {integrity: sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==} engines: {node: '>= 18'} - consola@3.4.2: - resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} - engines: {node: ^14.18.0 || >=16.10.0} - content-disposition@2.0.1: resolution: {integrity: sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==} engines: {node: '>=18'} @@ -14165,8 +14158,6 @@ snapshots: common-ancestor-path@2.0.0: {} - consola@3.4.2: {} - content-disposition@2.0.1: {} content-type@1.0.5: {}