diff --git a/bin/commands/interactive.mjs b/bin/commands/interactive.mjs index 3c855d00..2cd6b58a 100644 --- a/bin/commands/interactive.mjs +++ b/bin/commands/interactive.mjs @@ -13,6 +13,7 @@ import { } from '@clack/prompts'; import commands from './index.mjs'; +import { Logger } from '../../src/logger/index.mjs'; /** * Validates that a string is not empty. @@ -165,7 +166,7 @@ export default async function interactive() { const finalCommand = cmdParts.join(' '); - console.log(`\nGenerated command:\n${finalCommand}\n`); + Logger.getInstance().info(`\nGenerated command:\n${finalCommand}\n`); // Step 5: Confirm and execute the generated command if (await confirm({ message: 'Run now?', initialValue: true })) { diff --git a/bin/utils.mjs b/bin/utils.mjs index fdb3e70e..37a6f9b7 100644 --- a/bin/utils.mjs +++ b/bin/utils.mjs @@ -1,4 +1,5 @@ import createMarkdownLoader from '../src/loaders/markdown.mjs'; +import { Logger } from '../src/logger/index.mjs'; import createMarkdownParser from '../src/parsers/markdown.mjs'; /** @@ -42,7 +43,7 @@ export const errorWrap = try { return await fn(...args); } catch (err) { - console.error(err); + Logger.getInstance().error(err); process.exit(1); } }; diff --git a/src/logger/__tests__/logger.test.mjs b/src/logger/__tests__/logger.test.mjs new file mode 100644 index 00000000..51685af8 --- /dev/null +++ b/src/logger/__tests__/logger.test.mjs @@ -0,0 +1,221 @@ +import { deepStrictEqual, strictEqual } from 'node:assert'; +import { describe, it } from 'node:test'; + +import { LogLevel } from '../constants.mjs'; +import { createLogger } from '../logger.mjs'; + +/** + * @type {import('../types').Metadata} + */ +const metadata = { + file: { + path: 'test.md', + position: { + start: { line: 1 }, + end: { line: 1 }, + }, + }, +}; + +describe('createLogger', () => { + describe('DEBUG', () => { + it('should log DEBUG messages when logger level is set to DEBUG', t => { + t.mock.timers.enable({ apis: ['Date'] }); + + const transport = t.mock.fn(); + + const logger = createLogger(transport, LogLevel.debug); + + logger.debug('Hello, World!', metadata); + + strictEqual(transport.mock.callCount(), 1); + + const call = transport.mock.calls[0]; + deepStrictEqual(call.arguments, [ + { + level: LogLevel.debug, + message: 'Hello, World!', + metadata, + module: undefined, + timestamp: 0, + }, + ]); + }); + + it('should filter DEBUG messages when logger level is set to INFO or higher', t => { + [LogLevel.info, LogLevel.warn, LogLevel.error, LogLevel.fatal].forEach( + loggerLevel => { + const transport = t.mock.fn(); + + const logger = createLogger(transport, loggerLevel); + + logger.debug('Hello, World!'); + + strictEqual(transport.mock.callCount(), 0); + } + ); + }); + }); + + describe('INFO', () => { + it('should log INFO messages when logger level is set to INFO or lower', t => { + t.mock.timers.enable({ apis: ['Date'] }); + [LogLevel.info, LogLevel.debug].forEach(loggerLevel => { + const transport = t.mock.fn(); + + const logger = createLogger(transport, loggerLevel); + + logger.info('Hello, World!', metadata); + + strictEqual(transport.mock.callCount(), 1); + + const call = transport.mock.calls[0]; + deepStrictEqual(call.arguments, [ + { + level: LogLevel.info, + message: 'Hello, World!', + metadata, + module: undefined, + timestamp: 0, + }, + ]); + }); + }); + + it('should filter INFO messages when logger level is set to WARN or higher', t => { + [LogLevel.warn, LogLevel.error, LogLevel.fatal].forEach(loggerLevel => { + const transport = t.mock.fn(); + + const logger = createLogger(transport, loggerLevel); + + logger.info('Hello, World!'); + + strictEqual(transport.mock.callCount(), 0); + }); + }); + }); + + describe('WARN', () => { + it('should log WARN messages when logger level is set to WARN or lower', t => { + t.mock.timers.enable({ apis: ['Date'] }); + + [LogLevel.warn, LogLevel.info, LogLevel.debug].forEach(loggerLevel => { + const transport = t.mock.fn(); + + const logger = createLogger(transport, loggerLevel); + + logger.warn('Hello, World!', metadata); + + strictEqual(transport.mock.callCount(), 1); + + const call = transport.mock.calls[0]; + deepStrictEqual(call.arguments, [ + { + level: LogLevel.warn, + message: 'Hello, World!', + metadata, + module: undefined, + timestamp: 0, + }, + ]); + }); + }); + + it('should filter WARN messages when logger level is set to ERROR or higher', t => { + [LogLevel.error, LogLevel.fatal].forEach(loggerLevel => { + const transport = t.mock.fn(); + + const logger = createLogger(transport, loggerLevel); + + logger.warn('Hello, World!'); + + strictEqual(transport.mock.callCount(), 0); + }); + }); + }); + + describe('ERROR', () => { + it('should log ERROR messages when logger level is set to ERROR or lower', t => { + t.mock.timers.enable({ apis: ['Date'] }); + + [LogLevel.error, LogLevel.warn, LogLevel.info, LogLevel.debug].forEach( + loggerLevel => { + const transport = t.mock.fn(); + + const logger = createLogger(transport, loggerLevel); + + logger.error('Hello, World!', metadata); + + strictEqual(transport.mock.callCount(), 1); + + const call = transport.mock.calls[0]; + deepStrictEqual(call.arguments, [ + { + level: LogLevel.error, + message: 'Hello, World!', + metadata, + module: undefined, + timestamp: 0, + }, + ]); + } + ); + }); + + it('should filter ERROR messages when logger level is set to FATAL', t => { + const transport = t.mock.fn(); + + const logger = createLogger(transport, LogLevel.fatal); + + logger.warn('Hello, World!'); + + strictEqual(transport.mock.callCount(), 0); + }); + }); + + it('should filter all messages when minimum level is set above FATAL', t => { + const transport = t.mock.fn(); + + // silent logs + const logger = createLogger(transport, 100); + + Object.keys(LogLevel).forEach(level => { + logger[level]('Hello, World!'); + }); + + strictEqual(transport.mock.callCount(), 0); + }); + + it('should log all messages if message is a string array', t => { + const transport = t.mock.fn(); + + const logger = createLogger(transport, LogLevel.info); + + logger.info(['Hello, 1!', 'Hello, 2!', 'Hello, 3!']); + + strictEqual(transport.mock.callCount(), 3); + }); + + it('should log error message', t => { + t.mock.timers.enable({ apis: ['Date'] }); + + const transport = t.mock.fn(); + + const logger = createLogger(transport, LogLevel.error); + + logger.error(new Error('Hello, World!')); + + strictEqual(transport.mock.callCount(), 1); + + const call = transport.mock.calls[0]; + deepStrictEqual(call.arguments, [ + { + level: LogLevel.error, + message: 'Hello, World!', + metadata: {}, + module: undefined, + timestamp: 0, + }, + ]); + }); +}); diff --git a/src/logger/__tests__/transports/console.test.mjs b/src/logger/__tests__/transports/console.test.mjs new file mode 100644 index 00000000..71d1268b --- /dev/null +++ b/src/logger/__tests__/transports/console.test.mjs @@ -0,0 +1,210 @@ +import { deepStrictEqual, strictEqual } from 'assert'; +import { describe, it } from 'node:test'; + +import { LogLevel } from '../../constants.mjs'; +import console from '../../transports/console.mjs'; + +describe('console', () => { + it('should print debug messages', t => { + t.mock.timers.enable({ apis: ['Date'] }); + + const fn = t.mock.method(process.stdout, 'write'); + + // noop + fn.mock.mockImplementation(() => {}); + + console({ + level: LogLevel.debug, + message: 'Test message', + timestamp: Date.now(), + }); + + const callsArgs = process.stdout.write.mock.calls.map( + call => call.arguments[0] + ); + + strictEqual(process.stdout.write.mock.callCount(), 4); + deepStrictEqual(callsArgs, [ + '[00:00:00.000]', + ' \x1B[34mDEBUG\x1B[39m', + ': Test message', + '\n', + ]); + }); + + it('should print info messages', t => { + t.mock.timers.enable({ apis: ['Date'] }); + const fn = t.mock.method(process.stdout, 'write'); + + // noop + fn.mock.mockImplementation(() => {}); + + console({ + level: LogLevel.info, + message: 'Test message', + timestamp: Date.now(), + }); + + const callsArgs = process.stdout.write.mock.calls.map( + call => call.arguments[0] + ); + + strictEqual(process.stdout.write.mock.callCount(), 4); + deepStrictEqual(callsArgs, [ + '[00:00:00.000]', + ' \x1B[32mINFO\x1B[39m', + ': Test message', + '\n', + ]); + }); + + it('should print error messages ', t => { + t.mock.timers.enable({ apis: ['Date'] }); + + const fn = t.mock.method(process.stdout, 'write'); + + // noop + fn.mock.mockImplementation(() => {}); + + console({ + level: LogLevel.error, + message: 'Test message', + timestamp: Date.now(), + }); + + const callsArgs = process.stdout.write.mock.calls.map( + call => call.arguments[0] + ); + + strictEqual(process.stdout.write.mock.callCount(), 4); + deepStrictEqual(callsArgs, [ + '[00:00:00.000]', + ' \x1B[35mERROR\x1B[39m', + ': Test message', + '\n', + ]); + }); + + it('should print fatal messages', t => { + t.mock.timers.enable({ apis: ['Date'] }); + + const fn = t.mock.method(process.stdout, 'write'); + + // noop + fn.mock.mockImplementation(() => {}); + + console({ + level: LogLevel.fatal, + message: 'Test message', + timestamp: Date.now(), + }); + + const callsArgs = process.stdout.write.mock.calls.map( + call => call.arguments[0] + ); + + strictEqual(process.stdout.write.mock.callCount(), 4); + deepStrictEqual(callsArgs, [ + '[00:00:00.000]', + ' \x1B[31mFATAL\x1B[39m', + ': Test message', + '\n', + ]); + }); + + it('should print messages with file', t => { + t.mock.timers.enable({ apis: ['Date'] }); + + const fn = t.mock.method(process.stdout, 'write'); + + // noop + fn.mock.mockImplementation(() => {}); + + console({ + level: LogLevel.info, + message: 'Test message', + metadata: { + file: { + path: 'test.md', + position: { + start: { line: 1 }, + end: { line: 1 }, + }, + }, + }, + timestamp: Date.now(), + }); + + const callsArgs = process.stdout.write.mock.calls.map( + call => call.arguments[0] + ); + + strictEqual(process.stdout.write.mock.callCount(), 6); + deepStrictEqual(callsArgs, [ + '[00:00:00.000]', + ' \x1B[32mINFO\x1B[39m', + ': Test message', + ' at test.md', + '(1:1)', + '\n', + ]); + }); + + it('should print child logger name', t => { + t.mock.timers.enable({ apis: ['Date'] }); + + const fn = t.mock.method(process.stdout, 'write'); + + // noop + fn.mock.mockImplementation(() => {}); + + console({ + level: LogLevel.info, + message: 'Test message', + timestamp: Date.now(), + module: 'child1', + }); + + const callsArgs = process.stdout.write.mock.calls.map( + call => call.arguments[0] + ); + + strictEqual(process.stdout.write.mock.callCount(), 5); + deepStrictEqual(callsArgs, [ + '[00:00:00.000]', + ' \x1B[32mINFO\x1B[39m', + ' (child1)', + ': Test message', + '\n', + ]); + }); + + it('should print without colors if FORCE_COLOR = 0', t => { + process.env.FORCE_COLOR = 0; + + t.mock.timers.enable({ apis: ['Date'] }); + + const fn = t.mock.method(process.stdout, 'write'); + + // noop + fn.mock.mockImplementation(() => {}); + + console({ + level: LogLevel.info, + message: 'Test message', + timestamp: Date.now(), + }); + + const callsArgs = process.stdout.write.mock.calls.map( + call => call.arguments[0] + ); + + strictEqual(process.stdout.write.mock.callCount(), 4); + deepStrictEqual(callsArgs, [ + '[00:00:00.000]', + ' INFO', + ': Test message', + '\n', + ]); + }); +}); diff --git a/src/logger/__tests__/transports/github.test.mjs b/src/logger/__tests__/transports/github.test.mjs new file mode 100644 index 00000000..3ee156f8 --- /dev/null +++ b/src/logger/__tests__/transports/github.test.mjs @@ -0,0 +1,185 @@ +import { deepStrictEqual, strictEqual } from 'assert'; +import { describe, it } from 'node:test'; + +import { LogLevel } from '../../constants.mjs'; +import github from '../../transports/github.mjs'; + +describe('github', () => { + it('should print debug messages', t => { + t.mock.timers.enable({ apis: ['Date'] }); + + const fn = t.mock.method(process.stdout, 'write'); + + // noop + fn.mock.mockImplementation(() => {}); + + github({ + level: LogLevel.debug, + message: 'Test message', + timestamp: Date.now(), + }); + + const callsArgs = process.stdout.write.mock.calls.map( + call => call.arguments[0] + ); + + strictEqual(process.stdout.write.mock.callCount(), 1); + deepStrictEqual(callsArgs, [ + '::debug::[00:00:00.000] \x1B[34mDEBUG\x1B[39m: Test message\n', + ]); + }); + + it('should print info messages', t => { + t.mock.timers.enable({ apis: ['Date'] }); + const fn = t.mock.method(process.stdout, 'write'); + + // noop + fn.mock.mockImplementation(() => {}); + + github({ + level: LogLevel.info, + message: 'Test message', + timestamp: Date.now(), + }); + + const callsArgs = process.stdout.write.mock.calls.map( + call => call.arguments[0] + ); + + strictEqual(process.stdout.write.mock.callCount(), 1); + deepStrictEqual(callsArgs, [ + '::notice ::[00:00:00.000] \x1B[32mINFO\x1B[39m: Test message\n', + ]); + }); + + it('should print error messages ', t => { + t.mock.timers.enable({ apis: ['Date'] }); + + const fn = t.mock.method(process.stdout, 'write'); + + // noop + fn.mock.mockImplementation(() => {}); + + github({ + level: LogLevel.error, + message: 'Test message', + timestamp: Date.now(), + }); + + const callsArgs = process.stdout.write.mock.calls.map( + call => call.arguments[0] + ); + + strictEqual(process.stdout.write.mock.callCount(), 1); + deepStrictEqual(callsArgs, [ + '::error ::[00:00:00.000] \x1B[35mERROR\x1B[39m: Test message\n', + ]); + }); + + it('should print fatal messages', t => { + t.mock.timers.enable({ apis: ['Date'] }); + + const fn = t.mock.method(process.stdout, 'write'); + + // noop + fn.mock.mockImplementation(() => {}); + + github({ + level: LogLevel.fatal, + message: 'Test message', + timestamp: Date.now(), + }); + + const callsArgs = process.stdout.write.mock.calls.map( + call => call.arguments[0] + ); + + strictEqual(process.stdout.write.mock.callCount(), 1); + deepStrictEqual(callsArgs, [ + '::error ::[00:00:00.000] \x1B[31mFATAL\x1B[39m: Test message\n', + ]); + }); + + it('should print messages with file', t => { + t.mock.timers.enable({ apis: ['Date'] }); + + const fn = t.mock.method(process.stdout, 'write'); + + // noop + fn.mock.mockImplementation(() => {}); + + github({ + level: LogLevel.info, + message: 'Test message', + metadata: { + file: { + path: 'test.md', + position: { + start: { line: 1 }, + end: { line: 1 }, + }, + }, + }, + timestamp: Date.now(), + }); + + const callsArgs = process.stdout.write.mock.calls.map( + call => call.arguments[0] + ); + + strictEqual(process.stdout.write.mock.callCount(), 1); + deepStrictEqual(callsArgs, [ + '::notice file=test.md,line=1,endLine=1::[00:00:00.000] \x1B[32mINFO\x1B[39m: Test message\n', + ]); + }); + + it('should print child logger name', t => { + t.mock.timers.enable({ apis: ['Date'] }); + const fn = t.mock.method(process.stdout, 'write'); + + // noop + fn.mock.mockImplementation(() => {}); + + github({ + level: LogLevel.info, + message: 'Test message', + timestamp: Date.now(), + module: 'child1', + }); + + const callsArgs = process.stdout.write.mock.calls.map( + call => call.arguments[0] + ); + + strictEqual(process.stdout.write.mock.callCount(), 1); + deepStrictEqual(callsArgs, [ + '::notice ::[00:00:00.000] \x1B[32mINFO\x1B[39m (child1): Test message\n', + ]); + }); + + it('should print without colors if FORCE_COLOR = 0', t => { + process.env.FORCE_COLOR = 0; + + t.mock.timers.enable({ apis: ['Date'] }); + + const fn = t.mock.method(process.stdout, 'write'); + + // noop + fn.mock.mockImplementation(() => {}); + + github({ + level: LogLevel.info, + message: 'Test message', + timestamp: Date.now(), + }); + + const callsArgs = process.stdout.write.mock.calls.map( + call => call.arguments[0] + ); + + strictEqual(process.stdout.write.mock.callCount(), 1); + deepStrictEqual(callsArgs, [ + '::notice ::[00:00:00.000] INFO: Test message\n', + ]); + }); +}); diff --git a/src/logger/constants.mjs b/src/logger/constants.mjs new file mode 100644 index 00000000..07683124 --- /dev/null +++ b/src/logger/constants.mjs @@ -0,0 +1,34 @@ +'use strict'; + +/** + * Numeric log level definitions. + */ +export const LogLevel = { + debug: 10, + info: 20, + warn: 30, + error: 40, + fatal: 50, +}; + +/** + * Maps log level numbers to their string tags. + */ +export const levelTags = { + [LogLevel.debug]: 'DEBUG', + [LogLevel.info]: 'INFO', + [LogLevel.warn]: 'WARN', + [LogLevel.error]: 'ERROR', + [LogLevel.fatal]: 'FATAL', +}; + +/** + * Maps log level numbers to CLI color names. + */ +export const levelToColorMap = { + [LogLevel.debug]: 'blue', + [LogLevel.info]: 'green', + [LogLevel.warn]: 'yellow', + [LogLevel.error]: 'magenta', + [LogLevel.fatal]: 'red', +}; diff --git a/src/logger/index.mjs b/src/logger/index.mjs new file mode 100644 index 00000000..4b2ac5b4 --- /dev/null +++ b/src/logger/index.mjs @@ -0,0 +1,27 @@ +'use strict'; + +import { createLogger } from './logger.mjs'; +import { transports } from './transports/index.mjs'; + +/** + * @typedef {ReturnType} LoggerInstance + */ + +/** + * Creates a new logger instance with the specified transport. + * + * @param {string} [transportName='console'] - Name of the transport to use. + * @returns {LoggerInstance} + */ +export const Logger = (transportName = 'console') => { + const transport = transports[transportName]; + + if (!transport) { + throw new Error(`Transport '${transportName}' not found.`); + } + + return createLogger(transport); +}; + +// Default logger instance using console transport +export default Logger(); diff --git a/src/logger/logger.mjs b/src/logger/logger.mjs new file mode 100644 index 00000000..2277f4f7 --- /dev/null +++ b/src/logger/logger.mjs @@ -0,0 +1,131 @@ +'use strict'; + +import { LogLevel } from './constants.mjs'; + +/** + * @typedef {import('./types').Metadata} Metadata + * @typedef {import('./types').LogMessage} LogMessage + */ + +/** + * Creates a logger instance with the specified transport, log level and an + * optional module name. + * + * @param {import('./types').Transport} transport - Function to handle log output. + * @param {number} [loggerLevel] - Minimum log level to output. + * @param {string} [module] - Optional module name for the logger. + */ +export const createLogger = ( + transport, + loggerLevel = LogLevel.info, + module +) => { + /** + * Logs a message at the given level with optional metadata. + * + * @param {number} level - Log level for the message. + * @param {LogMessage} message - Message to log. + * @param {Metadata} metadata - Additional metadata + * @returns {void} + */ + const log = (level, message, metadata = {}) => { + if (!shouldLog(level)) { + return; + } + + if (Array.isArray(message)) { + return message.forEach(msg => log(level, msg, metadata)); + } + + const timestamp = Date.now(); + + // Extract message string from Error object or use message as-is + const msg = message instanceof Error ? message.message : message; + + transport({ + level, + message: msg, + timestamp, + metadata, + module, + }); + }; + + /** + * Logs an info message. + * + * @param {LogMessage} message - Info message to log. + * @param {Metadata} metadata - Additional metadata + * @returns {void} + */ + const info = (message, metadata = {}) => + log(LogLevel.info, message, metadata); + + /** + * Logs a warning message. + * + * @param {LogMessage} message - Warning message to log. + * @param {Metadata} metadata - Additional metadata + * @returns {void} + */ + const warn = (message, metadata = {}) => + log(LogLevel.warn, message, metadata); + + /** + * Logs an error message or Error object. + * + * @param {LogMessage} message - Error message or Error object to log. + * @param {Metadata} metadata - Additional metadata + * @returns {void} + */ + const error = (message, metadata = {}) => + log(LogLevel.error, message, metadata); + + /** + * Logs a fatal error message or Error object. + * + * @param {LogMessage} message - Fatal error message or Error object to log. + * @param {Metadata} metadata - Additional metadata + * @returns {void} + */ + const fatal = (message, metadata = {}) => + log(LogLevel.fatal, message, metadata); + + /** + * Logs a debug message. + * + * @param {LogMessage} message - Debug message to log. + * @param {Metadata} metadata - Additional metadata + * @returns {void} + */ + const debug = (message, metadata = {}) => + log(LogLevel.debug, message, metadata); + + /** + * Creates a child logger for a specific module. + * + * @param {string} module - Module name for the child logger. + * @returns {ReturnType} + */ + const child = module => createLogger(transport, loggerLevel, module); + + /** + * Checks if the given log level should be logged based on the current logger + * level. + * + * @param {number} level - Log level to check. + * @returns {boolean} + */ + const shouldLog = level => { + return level >= loggerLevel; + }; + + return { + info, + warn, + error, + fatal, + debug, + child, + }; +}; diff --git a/src/logger/transports/console.mjs b/src/logger/transports/console.mjs new file mode 100644 index 00000000..325b2a3c --- /dev/null +++ b/src/logger/transports/console.mjs @@ -0,0 +1,42 @@ +'use strict'; + +import { prettifyLevel } from '../utils/colors.mjs'; +import { prettifyTimestamp } from '../utils/time.mjs'; + +/** + * Logs a formatted message to stdout for human-friendly CLI output. + * + * @param {import('../types').TransportContext} context + * @returns {void} + */ +const console = ({ level, message, timestamp, metadata = {}, module }) => { + const { file } = metadata; + + const time = prettifyTimestamp(timestamp); + + process.stdout.write(`[${time}]`); + + const prettyLevel = prettifyLevel(level); + + process.stdout.write(` ${prettyLevel}`); + + if (module) { + process.stdout.write(` (${module})`); + } + + process.stdout.write(`: ${message}`); + + if (file) { + process.stdout.write(` at ${file.path}`); + } + + if (file?.position) { + const position = `(${file.position.start.line}:${file.position.end.line})`; + + process.stdout.write(position); + } + + process.stdout.write('\n'); +}; + +export default console; diff --git a/src/logger/transports/github.mjs b/src/logger/transports/github.mjs new file mode 100644 index 00000000..8b5fb512 --- /dev/null +++ b/src/logger/transports/github.mjs @@ -0,0 +1,42 @@ +'use strict'; + +import { debug, notice, warning, error } from '@actions/core'; + +import { LogLevel } from '../constants.mjs'; +import { prettifyLevel } from '../utils/colors.mjs'; +import { prettifyTimestamp } from '../utils/time.mjs'; + +const actions = { + [LogLevel.debug]: debug, + [LogLevel.info]: notice, + [LogLevel.warn]: warning, + [LogLevel.error]: error, + [LogLevel.fatal]: error, +}; + +/** + * Logs messages to GitHub Actions with formatted output and file info with + * appropriate gh actions based on level. + * + * @param {import('../types').TransportContext} context + * @returns {void} + */ +const github = ({ level, message, timestamp, metadata = {}, module }) => { + const { file } = metadata; + + const time = prettifyTimestamp(timestamp); + + const prettyLevel = prettifyLevel(level); + + const logMessage = `[${time}] ${prettyLevel}${module ? ` (${module})` : ''}: ${message}`; + + const logFn = actions[level] ?? notice; + + logFn(logMessage, { + file: file?.path, + startLine: file?.position?.start.line, + endLine: file?.position?.end.line, + }); +}; + +export default github; diff --git a/src/logger/transports/index.mjs b/src/logger/transports/index.mjs new file mode 100644 index 00000000..1adcfd70 --- /dev/null +++ b/src/logger/transports/index.mjs @@ -0,0 +1,11 @@ +'use strict'; + +import console from './console.mjs'; +import github from './github.mjs'; + +export const transports = { + console, + github, +}; + +export const availableTransports = Object.keys(transports); diff --git a/src/logger/types.d.ts b/src/logger/types.d.ts new file mode 100644 index 00000000..3e7fc9c1 --- /dev/null +++ b/src/logger/types.d.ts @@ -0,0 +1,27 @@ +export type LogLevel = 'info' | 'warn' | 'error' | 'fatal' | 'trace' | 'debug'; + +export type LogMessage = string | Error | string[]; + +export interface Position { + start: { line: number }; + end: { line: number }; +} + +export interface File { + path: string; + position?: Position; +} + +interface Metadata { + file?: File; +} + +interface TransportContext { + level: number; + message: string; + timestamp: number; + metadata?: Metadata; + module?: string; +} + +export type Transport = (context: TransportContext) => void; diff --git a/src/logger/utils/colors.mjs b/src/logger/utils/colors.mjs new file mode 100644 index 00000000..ebdcb165 --- /dev/null +++ b/src/logger/utils/colors.mjs @@ -0,0 +1,18 @@ +'use strict'; + +import { styleText } from 'node:util'; + +import { levelTags, levelToColorMap } from '../constants.mjs'; + +/** + * Returns a styled, uppercase log level tag for CLI output with color mapping + * for better readability. + * + * @param {number} level + * @returns {string} + */ +export const prettifyLevel = level => { + const tag = levelTags[level] ?? String(level); + + return styleText(levelToColorMap[level], tag.toUpperCase()); +}; diff --git a/src/logger/utils/time.mjs b/src/logger/utils/time.mjs new file mode 100644 index 00000000..a35291ab --- /dev/null +++ b/src/logger/utils/time.mjs @@ -0,0 +1,21 @@ +'use strict'; + +/** + * Formats a Unix timestamp in milliseconds as a human-readable time string + * in UTC timezone for CLI output. + * + * @param {number} timestamp + * @returns {string} + */ +export const prettifyTimestamp = timestamp => { + const date = new Date(timestamp); + + return new Intl.DateTimeFormat('en-US', { + timeZone: 'UTC', + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + fractionalSecondDigits: 3, + }).format(date); +};