UNPKG

@puls-atlas/cli

Version:

The Puls Atlas CLI tool for managing Atlas projects

538 lines 16.1 kB
import chalk from 'chalk'; import ora from 'ora'; import { inspect } from 'util'; import { isPromise } from 'es-toolkit/predicate'; import { isBoolean, isFunction, isNumber, isString } from 'es-toolkit/compat'; const BANNER_WIDTH = 70; const DEFAULT_LOG_DETAIL_LEVEL = 'compact'; const BACKGROUND_COLORS = ['green', 'yellow', 'red', 'blue', 'magenta', 'cyan', 'white']; const VERBOSE_LOG_DETAIL_LEVELS = new Set(['debug', 'detail', 'detailed', 'full', 'verbose']); let activeSpinner = null; const formatMessagePart = value => { if (isFunction(value)) { return formatMessagePart(value(chalk)); } if (value instanceof Error) { return value.stack ?? value.message; } if (isString(value)) { return value; } if (isNumber(value) || isBoolean(value) || typeof value === 'bigint') { return String(value); } if (value === null || value === undefined) { return String(value); } return inspect(value, { breakLength: Infinity, colors: false, compact: true, depth: 6 }); }; const formatMessage = message => { if (Array.isArray(message)) { return message.map(formatMessagePart).join(' '); } return formatMessagePart(message); }; const normalizeLogDetailLevel = value => { if (!isString(value)) { return DEFAULT_LOG_DETAIL_LEVEL; } const normalizedValue = value.trim().toLowerCase(); if (VERBOSE_LOG_DETAIL_LEVELS.has(normalizedValue)) { return 'verbose'; } return DEFAULT_LOG_DETAIL_LEVEL; }; const normalizeSectionEntries = entries => { const resolvedEntries = Array.isArray(entries) ? entries : [entries]; return resolvedEntries.flatMap(entry => { if (entry === null || entry === undefined || entry === false) { return []; } if (Array.isArray(entry)) { return normalizeSectionEntries(entry); } if (typeof entry === 'object' && !(entry instanceof Error) && Object.prototype.hasOwnProperty.call(entry, 'label') && Object.prototype.hasOwnProperty.call(entry, 'value')) { throw new Error('logger.section() only accepts list items. Use logger.summary() for key-value rows.'); } return [formatMessage(entry)]; }); }; const normalizeSummaryRows = rows => { const resolvedRows = Array.isArray(rows) ? rows : [rows]; return resolvedRows.flatMap(row => { if (row === null || row === undefined || row === false) { return []; } if (Array.isArray(row)) { return normalizeSummaryRows(row); } if (typeof row === 'object' && !(row instanceof Error) && Object.prototype.hasOwnProperty.call(row, 'label') && Object.prototype.hasOwnProperty.call(row, 'value')) { return [{ label: formatMessagePart(row.label), tone: row.tone ?? null, value: formatMessagePart(row.value) }]; } if (typeof row === 'object' && !(row instanceof Error) && Object.prototype.hasOwnProperty.call(row, 'value')) { return [{ label: null, tone: row.tone ?? null, value: formatMessagePart(row.value) }]; } return [{ label: null, tone: null, value: formatMessage(row) }]; }); }; const applySummaryTone = (value, tone) => { const toneKey = isString(tone) ? tone.trim().toLowerCase() : null; switch (toneKey) { case 'accent': return chalk.cyan(value); case 'debug': case 'muted': return chalk.gray(value); case 'error': return chalk.red(value); case 'success': return chalk.green(value); case 'warning': return chalk.yellow(value); default: return value; } }; const normalizeSummarySpacing = value => { switch (isString(value) ? value.trim().toLowerCase() : 'none') { case 'before': case 'after': case 'both': return value.trim().toLowerCase(); default: return 'none'; } }; const wrapBannerLines = message => { const segments = String(message).split(/\r?\n/); const lines = []; for (const segment of segments) { const words = segment.split(/\s+/).filter(Boolean); if (words.length === 0) { lines.push(''); continue; } let currentLine = ''; for (const word of words) { if (word.length > BANNER_WIDTH) { if (currentLine.length > 0) { lines.push(currentLine); currentLine = ''; } for (let index = 0; index < word.length; index += BANNER_WIDTH) { lines.push(word.slice(index, index + BANNER_WIDTH)); } continue; } const nextLine = currentLine.length === 0 ? word : `${currentLine} ${word}`; if (nextLine.length > BANNER_WIDTH) { lines.push(currentLine); currentLine = word; } else { currentLine = nextLine; } } if (currentLine.length > 0) { lines.push(currentLine); } } return lines.length > 0 ? lines : ['']; }; const format = (severity, message) => { const severityColor = { info: chalk.blue, error: chalk.red, success: chalk.green, warning: chalk.yellow, debug: chalk.gray, callout: chalk.blackBright }; const icon = { info: 'ℹ', error: '✖', success: '✔', warning: '⚠', debug: '⚙', callout: '' }; return severityColor[severity](`${icon[severity].padEnd(1, ' ')} ${message}`); }; const logBanner = (message, borderColor = 'blue', padding = 1, consoleImpl = console) => { const lines = wrapBannerLines(message); const maxLength = lines.map(line => line.length).reduce((prev, curr) => Math.max(prev, curr), BANNER_WIDTH) + 4; const emptyLine = chalk[borderColor](`║${' '.repeat(maxLength - 2)}║`); consoleImpl.log(chalk[borderColor](`╔${'═'.repeat(maxLength - 2)}╗`)); for (let i = 0; i < padding; i++) { consoleImpl.log(emptyLine); } lines.forEach(line => { consoleImpl.log(chalk[borderColor](`║ ${chalk.white(line.padEnd(maxLength - 4, ' '))} ║`)); }); for (let i = 0; i < padding; i++) { consoleImpl.log(emptyLine); } consoleImpl.log(chalk[borderColor](`╚${'═'.repeat(maxLength - 2)}╝`)); }; const log = (message = '', severity = null, dependencies = {}) => { const { consoleImpl = console } = dependencies; if (message === '') { return consoleImpl.log(''); } if (severity === 'banner') { logBanner(formatMessage(message), 'blue', 1, consoleImpl); } else { switch (severity) { case null: if (Array.isArray(message)) { consoleImpl.log(...message.map(value => isFunction(value) ? value(chalk) : value)); } else { consoleImpl.log(isFunction(message) ? message(chalk) : message); } break; case 'success': case 'warning': case 'info': case 'debug': consoleImpl.log(format(severity, formatMessage(message))); break; default: consoleImpl.log(format('info', formatMessage(message))); break; } } }; const normalizeErrorOptions = options => { if (isBoolean(options)) { return { exit: options, exitCode: 0 }; } if (options && typeof options === 'object' && !Array.isArray(options)) { return { details: options.details ?? options.error, exit: options.exit ?? false, exitCode: options.exitCode ?? 0, exitImpl: options.exitImpl, consoleImpl: options.consoleImpl }; } if (options === undefined) { return { exit: true, exitCode: 0 }; } return { details: options, exit: false, exitCode: 0 }; }; export const shouldSuspendSpinnerForStdio = stdio => { if (stdio === undefined || stdio === 'inherit') { return true; } if (!Array.isArray(stdio)) { return false; } return stdio.includes('inherit'); }; export const suspendActiveSpinner = () => { const spinner = activeSpinner; if (!spinner || !spinner.internalState || spinner.internalState.completed) { return () => {}; } const state = spinner.internalState; state.suspendDepth += 1; if (state.suspendDepth === 1) { state.resumeOnRelease = spinner.isSpinning === true; if (state.resumeOnRelease) { state.pause(); } } return () => { if (state.suspendDepth === 0) { return; } state.suspendDepth -= 1; if (state.suspendDepth === 0 && state.resumeOnRelease && !state.completed && activeSpinner === spinner) { state.resume(); } }; }; export const runWithSuspendedSpinner = (action, enabled = true) => { const resume = enabled ? suspendActiveSpinner() : () => {}; try { const result = action(); if (isPromise(result)) { return result.finally(resume); } resume(); return result; } catch (error) { resume(); throw error; } }; export const createLogger = (dependencies = {}) => { const { consoleImpl = console, detailLevel = normalizeLogDetailLevel(process.env.ATLAS_CLI_LOG_DETAIL), exitImpl = process.exit, oraImpl = ora } = dependencies; const isVerboseOutput = detailLevel === 'verbose'; const createManagedSpinner = (text, spinnerName = 'timeTravel') => { const state = { completed: false, currentInstance: null, resumeOnRelease: false, spinnerName, suspendDepth: 0, text }; const createSpinnerInstance = () => oraImpl({ spinner: state.spinnerName, text: state.text }); const stopCurrentInstance = () => { state.currentInstance?.stop(); state.currentInstance = null; }; const startFreshInstance = nextText => { if (isString(nextText)) { state.text = nextText; } state.currentInstance = createSpinnerInstance().start(); return spinnerApi; }; const complete = (method, message) => { state.completed = true; state.resumeOnRelease = false; state.suspendDepth = 0; if (isString(message)) { state.text = message; } const spinnerInstance = state.currentInstance ?? createSpinnerInstance(); if (isString(state.text)) { spinnerInstance.text = state.text; } state.currentInstance = null; if (activeSpinner === spinnerApi) { activeSpinner = null; } spinnerInstance[method]?.(message); return spinnerApi; }; const spinnerApi = { fail: message => complete('fail', message), start: nextText => { if (state.completed) { return spinnerApi; } if (state.currentInstance?.isSpinning) { if (isString(nextText)) { spinnerApi.text = nextText; } return spinnerApi; } return startFreshInstance(nextText); }, stop: () => complete('stop'), succeed: message => complete('succeed', message) }; Object.defineProperties(spinnerApi, { isSpinning: { enumerable: true, get: () => state.currentInstance?.isSpinning === true }, text: { enumerable: true, get: () => state.text, set: value => { state.text = value; if (state.currentInstance) { state.currentInstance.text = value; } } } }); spinnerApi.internalState = { get completed() { return state.completed; }, pause: stopCurrentInstance, resume: () => { if (!state.completed && !state.currentInstance) { startFreshInstance(); } }, resumeOnRelease: false, suspendDepth: 0 }; startFreshInstance(); activeSpinner = spinnerApi; return spinnerApi; }; const loggerApi = { isVerbose: () => isVerboseOutput, log: (message = '', severity = null) => { log(message, severity, { consoleImpl }); return loggerApi; }, break: () => { log('', null, { consoleImpl }); return loggerApi; }, info: (...messages) => { log(messages, 'info', { consoleImpl }); return loggerApi; }, summary: (title, rows = [], options = {}) => { const { detailOnly = false, emptyMessage = null, indent = ' ', spacing = 'none' } = options; if (detailOnly && !isVerboseOutput) { return loggerApi; } const normalizedTitle = formatMessage(title); const summaryRows = normalizeSummaryRows(rows); const resolvedSpacing = normalizeSummarySpacing(spacing); if (resolvedSpacing === 'before' || resolvedSpacing === 'both') { consoleImpl.log(''); } consoleImpl.log(chalk.bold(normalizedTitle)); if (summaryRows.length === 0) { if (emptyMessage) { consoleImpl.log(`${indent}${chalk.gray(formatMessagePart(emptyMessage))}`); } if (resolvedSpacing === 'after' || resolvedSpacing === 'both') { consoleImpl.log(''); } return loggerApi; } const labelWidth = summaryRows.reduce((maxWidth, row) => row.label === null ? maxWidth : Math.max(maxWidth, `${row.label}:`.length), 0); for (const row of summaryRows) { const formattedValue = applySummaryTone(row.value, row.tone); if (row.label === null) { consoleImpl.log(`${indent}${formattedValue}`); continue; } const label = chalk.gray(`${row.label}:`.padEnd(labelWidth, ' ')); consoleImpl.log(`${indent}${label} ${formattedValue}`); } if (resolvedSpacing === 'after' || resolvedSpacing === 'both') { consoleImpl.log(''); } return loggerApi; }, section: (title, entries = [], options = {}) => { const { bullet = ' - ', detailOnly = false, emptyMessage = null, severity = 'info' } = options; if (detailOnly && !isVerboseOutput) { return loggerApi; } const sectionEntries = normalizeSectionEntries(entries); if (sectionEntries.length === 0) { if (emptyMessage) { log(`${title}: ${emptyMessage}`, severity, { consoleImpl }); } return loggerApi; } log(`${title}:`, severity, { consoleImpl }); for (const entry of sectionEntries) { log(`${bullet}${entry}`, severity, { consoleImpl }); } return loggerApi; }, banner: (...messages) => { log(messages, 'banner', { consoleImpl }); return loggerApi; }, success: (...messages) => { log(messages, 'success', { consoleImpl }); return loggerApi; }, warning: (...messages) => { log(messages, 'warning', { consoleImpl }); return loggerApi; }, debug: (...messages) => { log(messages, 'debug', { consoleImpl }); return loggerApi; }, error: (message, options = true) => { const normalizedOptions = normalizeErrorOptions(options); const resolvedExit = normalizedOptions.exitImpl ?? exitImpl; const resolvedConsole = normalizedOptions.consoleImpl ?? consoleImpl; const errorMessage = formatMessage(normalizedOptions.details === undefined ? message : [message, normalizedOptions.details]); resolvedConsole.error(format('error', errorMessage)); if (normalizedOptions.exit) { resolvedExit(normalizedOptions.exitCode); } return loggerApi; }, callout: (message, bg = 'green') => { const normalizedBg = String(bg).trim().toLowerCase(); const resolvedBg = BACKGROUND_COLORS.includes(normalizedBg) ? normalizedBg : 'green'; const chalkKey = `bg${resolvedBg.charAt(0).toUpperCase()}${resolvedBg.slice(1)}`; consoleImpl.log(format('callout', chalk[chalkKey].bold(` ➤ ${formatMessage(message)} `))); return loggerApi; }, spinner: (text, spinner = 'timeTravel') => (() => { if (activeSpinner?.internalState && !activeSpinner.internalState.completed) { activeSpinner.stop(); } return createManagedSpinner(text, spinner); })() }; return loggerApi; }; export const logger = createLogger();