@hashgraph/solo
Version:
An opinionated CLI tool to deploy and manage private Hedera Networks.
410 lines • 17 kB
JavaScript
// SPDX-License-Identifier: Apache-2.0
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var __param = (this && this.__param) || function (paramIndex, decorator) {
return function (target, key) { decorator(target, key, paramIndex); }
};
var SoloPinoLogger_1;
import pino from 'pino';
import pinoPretty from 'pino-pretty';
import { mkdirSync } from 'node:fs';
import { v4 as uuidv4 } from 'uuid';
// eslint-disable-next-line unicorn/import-style
import * as util from 'node:util';
import chalk from 'chalk';
import * as constants from '../constants.js';
import { inject, injectable } from 'tsyringe-neo';
import { patchInject } from '../dependency-injection/container-helper.js';
import { InjectTokens } from '../dependency-injection/inject-tokens.js';
import { PathEx } from '../../business/utils/path-ex.js';
import { SoloError } from '../errors/solo-error.js';
import { MessageLevel } from './message-level.js';
/**
* Pino-based implementation of the SoloLogger interface.
*
* Emits two files under constants.SOLO_LOGS_DIR:
* - solo.ndjson : newline-delimited JSON (authoritative)
* - solo.log : pretty human-readable
*/
let SoloPinoLogger = class SoloPinoLogger {
static { SoloPinoLogger_1 = this; }
developmentMode;
pinoLogger;
traceId;
logBindings = {};
messageGroupMap = new Map();
MINOR_LINE_SEPARATOR = '-------------------------------------------------------------------------------';
static MAX_BOX_WIDTH = 120;
static MIN_BOX_WIDTH = 70;
/**
* @param logLevel - the log level to use (fatal|error|warn|info|debug|trace)
* @param developmentMode - if true, show full stack traces in error messages
*/
constructor(logLevel, developmentMode) {
this.developmentMode = developmentMode;
logLevel = patchInject(logLevel, InjectTokens.LogLevel, this.constructor.name) ?? 'info';
this.developmentMode = patchInject(developmentMode, InjectTokens.DevelopmentMode, this.constructor.name);
this.nextTraceId();
// Ensure logs directory exists
const logsDirectory = constants.SOLO_LOGS_DIR;
try {
mkdirSync(logsDirectory, { recursive: true });
}
catch {
// no-op: if this fails, pino will attempt to create the files and error if impossible
}
// Configure dual outputs: NDJSON (machine) + pretty (human)
const ndjsonTarget = {
target: 'pino/file',
level: logLevel,
options: { destination: PathEx.join(logsDirectory, 'solo.ndjson') },
};
const prettyTarget = {
target: 'pino-pretty',
level: logLevel,
options: {
destination: PathEx.join(logsDirectory, 'solo.log'), // write formatted logs to <logsDirectory>/solo.log
translateTime: 'HH:MM:ss.l', // prepend timestamp as [HH:MM:ss.ms]
colorize: false, // disable pino-pretty color output (avoid ANSI codes)
messageKey: 'msg', // use the 'msg' property as the main log message
messageFormat: '{msg} [traceId="{traceId}"]', // format line: message + traceId suffix
ignore: 'pid,hostname,traceId', // exclude these fields from printed output
colorizeObjects: false, // don't colorize objects or nested values
crlf: false, // use '\n' (Unix newlines) instead of '\r\n' (Windows)
hideObject: false, // don't hide full object payloads after message
},
};
const baseOptions = {
level: logLevel,
// Always include traceId and active log bindings when set via mixin
mixin: () => ({
...this.logBindings,
...(this.traceId ? { traceId: this.traceId } : {}),
}),
// Redact obvious secrets if they sneak into objects
redact: {
paths: ['*.authorization', '*.Authorization', '*.accessToken', '*.privateKey', '*.operatorKey'],
remove: true,
},
};
if (process.env.CI === 'true') {
const ndjsonStream = pino.destination({
dest: PathEx.join(logsDirectory, 'solo.ndjson'),
sync: true,
});
const prettyStream = pinoPretty({
...prettyTarget.options,
destination: pino.destination({
dest: PathEx.join(logsDirectory, 'solo.log'),
sync: true,
}),
});
this.pinoLogger = pino(baseOptions, pino.multistream([
{ level: logLevel, stream: ndjsonStream },
{ level: logLevel, stream: prettyStream },
]));
}
else {
this.pinoLogger = pino(baseOptions, pino.transport({ targets: [ndjsonTarget, prettyTarget] }));
}
}
setDevMode(developmentMode) {
this.debug(`dev mode logging: ${developmentMode}`);
this.developmentMode = developmentMode;
}
isDevMode() {
return this.developmentMode ?? false;
}
nextTraceId() {
this.traceId = uuidv4();
}
setLogBinding(key, value) {
if (value === undefined || value === null || value === '') {
delete this.logBindings[key];
return;
}
this.logBindings[key] = value;
}
addLogBindings(bindings) {
for (const [key, value] of Object.entries(bindings)) {
this.setLogBinding(key, value);
}
}
clearLogBindings(...keys) {
if (keys.length === 0) {
for (const key of Object.keys(this.logBindings)) {
delete this.logBindings[key];
}
return;
}
for (const key of keys) {
delete this.logBindings[key];
}
}
prepMeta(meta = {}) {
if (this.traceId) {
meta['traceId'] = this.traceId;
}
return meta;
}
showUser(message, ...arguments_) {
const formatted = util.format(String(message), ...arguments_.map(String));
console.log(formatted);
// Mirror existing behavior: also persist to logs at info level
this.info(formatted);
}
stripAnsi(text) {
// eslint-disable-next-line no-control-regex
return text.replaceAll(/\u001B\[[0-9;]*m/g, '');
}
padWithBorder(message, chalkColor = chalk.red, length = 83) {
const border = chalkColor('│');
const messageLines = [];
for (const line of message.split('\n')) {
const repeats = Math.max(0, length - this.stripAnsi(line).length - 4);
messageLines.push(`${border} ${line}${' '.repeat(repeats)} ${border}`);
}
return messageLines.join('\n');
}
buildCauseChain(error) {
const chain = [error];
let cause = error.cause;
let depth = 0;
while (cause instanceof Error && depth < 10) {
chain.push(cause);
cause = cause.cause;
depth += 1;
}
return chain;
}
getFormattedCode(error) {
const formattedCode = error instanceof SoloError ? error.getFormattedCode() : undefined;
return formattedCode ? `[${formattedCode}] ` : '';
}
buildContentLines(error, causeChain) {
const lines = [];
if (this.developmentMode) {
let indent = ' ';
let prefix = '';
for (const entry of causeChain) {
const messageText = this.getFormattedCode(entry) + entry.message;
lines.push(chalk.red(indent + prefix + messageText));
if (entry.stack) {
const formatted = entry.stack
.split('\n')
.filter((line) => !line.includes('node:internal'))
.join('\n')
.trim();
lines.push(...(indent + formatted).split('\n').map((line) => chalk.gray(line)), '');
}
indent += ' ';
prefix += 'Caused by: ';
}
}
else {
const errorMessage = this.getFormattedCode(error) + error.message;
lines.push(...errorMessage.split('\n').map((line) => chalk.red(line)));
}
if (error instanceof SoloError) {
const documentUrl = error.getDocumentUrl();
if (!this.developmentMode) {
const troubleshootingSteps = error.getTroubleshootingSteps();
if (troubleshootingSteps && troubleshootingSteps.length > 0) {
for (const step of troubleshootingSteps) {
lines.push(chalk.cyan(' →') + ' ' + step);
}
}
}
if (documentUrl) {
lines.push('', chalk.cyan(`Learn more: ${documentUrl}`));
}
}
return lines;
}
wrapLine(line, maxWidth) {
const plainText = this.stripAnsi(line);
if (plainText.length <= maxWidth) {
return [line];
}
// eslint-disable-next-line no-control-regex
const ansiPrefix = line.match(/^(?:\[[0-9;]*m)+/)?.[0] ?? '';
const ansiSuffix = ansiPrefix ? '[0m' : '';
const indent = plainText.match(/^(\s*)/)?.[1] ?? '';
const result = [];
let remaining = plainText;
while (remaining.length > maxWidth) {
// Search outside the indent so wrapping never splits within it and
// continuation lines stay at the same indentation level.
const relativeSpaceAt = remaining.slice(indent.length).lastIndexOf(' ', maxWidth - 1 - indent.length);
const spaceAt = relativeSpaceAt === -1 ? -1 : indent.length + relativeSpaceAt;
const breakAt = spaceAt > 0 ? spaceAt : maxWidth;
result.push(ansiPrefix + remaining.slice(0, breakAt) + ansiSuffix);
const afterBreak = remaining.slice(spaceAt > 0 ? breakAt + 1 : breakAt);
remaining = indent + afterBreak;
}
if (remaining) {
result.push(ansiPrefix + remaining + ansiSuffix);
}
return result.length > 0 ? result : [line];
}
renderErrorBox(lines) {
const maxInteriorWidth = SoloPinoLogger_1.MAX_BOX_WIDTH - 4;
const wrappedLines = lines.flatMap((line) => this.wrapLine(line, maxInteriorWidth));
const maxContentWidth = Math.max(...wrappedLines.map((l) => this.stripAnsi(l).length));
const boxWidth = Math.min(SoloPinoLogger_1.MAX_BOX_WIDTH, Math.max(SoloPinoLogger_1.MIN_BOX_WIDTH, maxContentWidth + 4));
const interiorWidth = boxWidth - 4;
console.log(chalk.red(`╭─ ERROR ─${'─'.repeat(interiorWidth - 7)}╮`));
for (const line of wrappedLines) {
console.log(this.padWithBorder(line, chalk.red, boxWidth));
}
console.log(chalk.red(`╰${'─'.repeat(interiorWidth + 2)}╯`));
}
showUserError(error) {
const normalizedError = error instanceof Error ? error : new Error(String(error));
const causeChain = this.buildCauseChain(normalizedError);
const lines = this.buildContentLines(normalizedError, causeChain);
this.renderErrorBox(lines);
this.toPino('error', error, []);
}
error(message, ...arguments_) {
this.toPino('error', message, arguments_);
}
warn(message, ...arguments_) {
this.toPino('warn', message, arguments_);
}
info(message, ...arguments_) {
this.toPino('info', message, arguments_);
}
debug(message, ...arguments_) {
this.toPino('debug', message, arguments_);
}
showList(title, items = []) {
this.showUser(chalk.green(`\n *** ${title} ***`));
this.showUser(chalk.green(this.MINOR_LINE_SEPARATOR));
if (items.length > 0) {
for (const name of items) {
this.showUser(chalk.cyan(` - ${name}`));
}
}
else {
this.showUser(chalk.blue('[ None ]'));
}
this.showUser('\n');
return true;
}
showJSON(title, object) {
this.showUser(chalk.green(`\n *** ${title} ***`));
this.showUser(chalk.green(this.MINOR_LINE_SEPARATOR));
console.log(JSON.stringify(object, undefined, 2));
}
getMessageGroup(key) {
if (!this.messageGroupMap.has(key)) {
throw new SoloError(`Message group with key "${key}" does not exist.`);
}
return this.messageGroupMap.get(key);
}
addMessageGroup(key, title) {
if (this.messageGroupMap.has(key)) {
this.warn(`Message group with key "${key}" already exists. Skipping.`);
return;
}
this.messageGroupMap.set(key, [`${title}:`]);
this.debug(`Added message group "${title}" with key "${key}".`);
}
addMessageGroupMessage(key, message) {
if (!this.messageGroupMap.has(key)) {
throw new SoloError(`Message group with key "${key}" does not exist.`);
}
this.messageGroupMap.get(key).push(message);
this.debug(`Added message to group "${key}": ${message}`);
}
showMessageGroup(key, messageLevel = MessageLevel.INFO) {
if (!this.messageGroupMap.has(key)) {
this.warn(`Message group with key "${key}" does not exist.`);
return;
}
let titleColor;
let textColor;
switch (messageLevel) {
case MessageLevel.ERROR: {
titleColor = chalk.red;
textColor = chalk.red;
break;
}
case MessageLevel.WARN: {
titleColor = chalk.yellow;
textColor = chalk.yellow;
break;
}
default: {
titleColor = chalk.green;
textColor = chalk.cyan;
break;
}
}
const messages = this.messageGroupMap.get(key);
this.showUser(titleColor(`\n *** ${messages[0]} ***`));
this.showUser(titleColor(this.MINOR_LINE_SEPARATOR));
for (let index = 1; index < messages.length; index++) {
this.showUser(textColor(` - ${messages[index]}`));
}
this.showUser(titleColor(this.MINOR_LINE_SEPARATOR));
this.debug(`Displayed message group "${key}".`);
}
getMessageGroupKeys() {
return [...this.messageGroupMap.keys()];
}
showAllMessageGroups() {
const keys = this.getMessageGroupKeys();
if (keys.length === 0) {
this.debug('No message groups available.');
return;
}
for (const key of keys) {
this.showMessageGroup(key);
}
}
flush(callback) {
this.info('Flushing logs and exiting...');
this.pinoLogger.flush(callback);
}
toPino(level, message, arguments_) {
// Build base object (traceId via mixin already present, but include explicitly for clarity in unit tests)
let object = {};
const meta = this.prepMeta({});
// Prefer structured errors/objects when provided
if (message instanceof Error) {
object = { ...object, ...meta, err: message };
this.pinoLogger[level](object, message.message ?? 'Error');
return;
}
if (message && typeof message === 'object') {
object = { ...object, ...meta, ...message };
const message_ = arguments_.length > 0 ? util.format('%s', ...arguments_.map(String)) : undefined;
if (message_) {
this.pinoLogger[level](object, message_);
}
else {
this.pinoLogger[level](object);
}
return;
}
const formatted = util.format(String(message), ...arguments_);
this.pinoLogger[level](meta, formatted);
}
};
SoloPinoLogger = SoloPinoLogger_1 = __decorate([
injectable(),
__param(0, inject(InjectTokens.LogLevel)),
__param(1, inject(InjectTokens.DevelopmentMode)),
__metadata("design:paramtypes", [String, Boolean])
], SoloPinoLogger);
export { SoloPinoLogger };
//# sourceMappingURL=solo-pino-logger.js.map