@puls-atlas/cli
Version:
The Puls Atlas CLI tool for managing Atlas projects
538 lines • 16.1 kB
JavaScript
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();