import chalk from 'chalk'; import ora from 'ora'; import { sanitizeTerminalText } from '../utils/terminal'; export interface GlobalCliOptions { database?: string; json: boolean; quiet: boolean; } export interface ProgressHandle { update(text: string): void; succeed(text?: string): void; fail(text?: string): void; warn(text?: string): void; stop(): void; } export interface ResultOptions { /** * When true, the pretty payload is the essential output of the command (e.g. * an OPML/JSON export to stdout) and must still be printed under --quiet. * --json behavior is unchanged. */ essential?: boolean; } export interface CommandOutput { progress(text: string): ProgressHandle; /** Display-ready text. Sanitize untrusted fields before applying Chalk styles. */ info(text: string): void; warn(text: string): void; /** The pretty formatter must sanitize untrusted fields before styling them. */ result(data: T, pretty: (data: T) => string, opts?: ResultOptions): void; error(message: string, opts?: { code?: string; stack?: string }): void; } const NOOP_PROGRESS: ProgressHandle = { update: () => {}, succeed: () => {}, fail: () => {}, warn: () => {}, stop: () => {}, }; export function createOutput(opts: Pick): CommandOutput { const isJson = opts.json; const isQuiet = opts.quiet; const silentChatter = isJson || isQuiet; return { progress(text) { if (silentChatter) return NOOP_PROGRESS; const spinner = ora(sanitizeTerminalText(text)).start(); return { update(t) { spinner.text = sanitizeTerminalText(t); }, succeed(t) { spinner.succeed(t === undefined ? undefined : sanitizeTerminalText(t)); }, fail(t) { spinner.fail(t === undefined ? undefined : sanitizeTerminalText(t)); }, warn(t) { spinner.warn(t === undefined ? undefined : sanitizeTerminalText(t)); }, stop() { spinner.stop(); }, }; }, info(text) { if (silentChatter) return; console.error(text); }, warn(text) { if (silentChatter) return; console.error(chalk.yellow(sanitizeTerminalText(text))); }, result(data, pretty, resultOpts) { if (isJson) { console.log(JSON.stringify(data, null, 2)); } else if (!isQuiet || resultOpts?.essential) { console.log(pretty(data)); } }, error(message, errOpts) { if (isJson) { const payload: Record = { message }; if (errOpts?.code) payload.code = errOpts.code; if (errOpts?.stack) payload.stack = errOpts.stack; console.error(JSON.stringify({ error: payload }, null, 2)); } else { console.error(chalk.red(sanitizeTerminalText(message))); if (errOpts?.stack) console.error(chalk.gray(sanitizeTerminalText(errOpts.stack, { preserveNewlines: true }))); } }, }; } export type RecordedOutputCall = | { kind: 'progress'; text: string } | { kind: 'info'; text: string } | { kind: 'warn'; text: string } | { kind: 'result'; data: unknown; pretty: string; essential?: boolean } | { kind: 'error'; message: string; code?: string; stack?: string }; export function createFakeOutput(): { output: CommandOutput; calls: RecordedOutputCall[] } { const calls: RecordedOutputCall[] = []; const output: CommandOutput = { progress(text) { calls.push({ kind: 'progress', text }); return NOOP_PROGRESS; }, info(text) { calls.push({ kind: 'info', text }); }, warn(text) { calls.push({ kind: 'warn', text }); }, result(data, pretty, resultOpts) { calls.push({ kind: 'result', data, pretty: pretty(data), essential: resultOpts?.essential }); }, error(message, errOpts) { calls.push({ kind: 'error', message, code: errOpts?.code, stack: errOpts?.stack }); }, }; return { output, calls }; }