diff --git a/package-lock.json b/package-lock.json index 8bfe4c2..9f60902 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "Apache-2.0", "dependencies": { + "chalk": "^4.1.2", "commander": "^12.1.0" }, "bin": { @@ -683,7 +684,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -699,7 +700,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -714,7 +715,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -725,8 +725,7 @@ "node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "node_modules/commander": { "version": "12.1.0", @@ -1252,7 +1251,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -1948,7 +1947,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, diff --git a/package.json b/package.json index 7224407..78d824d 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "author": "", "license": "Apache-2.0", "dependencies": { + "chalk": "^4.1.2", "commander": "^12.1.0" }, "devDependencies": { diff --git a/src/commands/sourcemaps.ts b/src/commands/sourcemaps.ts index 5885c84..a67c5a9 100644 --- a/src/commands/sourcemaps.ts +++ b/src/commands/sourcemaps.ts @@ -15,9 +15,9 @@ */ import { Command } from 'commander'; -import { runSourcemapInject } from '../sourcemaps'; -import { debug, error } from '../sourcemaps/utils'; +import { runSourcemapInject, SourceMapInjectOptions } from '../sourcemaps'; import { UserFriendlyError } from '../utils/userFriendlyErrors'; +import { createLogger, LogLevel } from '../utils/logger'; export const sourcemapsCommand = new Command('sourcemaps'); @@ -52,24 +52,28 @@ sourcemapsCommand .description(injectDescription) .requiredOption( '--directory ', - 'Path to the directory containing your both JavaScript files and source map files (required)' + 'Path to the directory containing your production JavaScript bundles and their source maps' ) .option( '--dry-run', - 'Use --dry-run to preview the files that will be injected for the given options, without modifying any files on the file system (optional)', - false + 'Use --dry-run to preview the files that will be injected for the given options, without modifying any files on the file system' + ) + .option( + '--debug', + 'Enable debug logs' ) .action( - async (options) => { + async (options: SourceMapInjectOptions) => { + const logger = createLogger(options.debug ? LogLevel.DEBUG : LogLevel.INFO); try { await runSourcemapInject(options); } catch (e) { if (e instanceof UserFriendlyError) { - debug(e.originalError); - error(e.message); + logger.debug(e.originalError); + logger.error(e.message); } else { - error('Exiting due to an unexpected error:'); - error(e); + logger.error('Exiting due to an unexpected error:'); + logger.error(e); } sourcemapsCommand.error(''); } diff --git a/src/sourcemaps/index.ts b/src/sourcemaps/index.ts index c8deccf..ba64c42 100644 --- a/src/sourcemaps/index.ts +++ b/src/sourcemaps/index.ts @@ -29,6 +29,7 @@ import { injectFile } from './injectFile'; export type SourceMapInjectOptions = { directory: string; dryRun: boolean; + debug?: boolean; }; /** diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 0000000..d86033c --- /dev/null +++ b/src/utils/logger.ts @@ -0,0 +1,53 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +import chalk from 'chalk'; + +/** Logger methods can be called just like console.log */ +export interface Logger { + error: typeof console.log; + warn: typeof console.log; + info: typeof console.log; + debug: typeof console.log; +} + +export const enum LogLevel { + ERROR = 4, + WARN = 3, + INFO = 2, + DEBUG = 1 +} + +export function createLogger(logLevel: LogLevel): Logger { + // Send info to stdout, and all other logs to stderr + return { + error: (msg, ...params) => LogLevel.ERROR >= logLevel && prefixedConsoleError(chalk.stderr.red('ERROR '), msg, ...params), + warn: (msg, ...params) => LogLevel.WARN >= logLevel && prefixedConsoleError(chalk.stderr.yellow('WARN '), msg, ...params), + info: (msg, ...params) => LogLevel.INFO >= logLevel && console.log(msg, params), + debug: (msg, ...params) => LogLevel.DEBUG >= logLevel && prefixedConsoleError(chalk.stderr.gray('DEBUG '), msg, ...params), + } as Logger; +} + +/** Carefully wrap console.error so the logger can properly support format strings */ +const prefixedConsoleError = (prefix: string, msg: unknown, ...params: unknown[]) => { + if (typeof msg === 'string') { + // String concatenation is needed for format strings, + // otherwise console.error('Hello ', '%s!', ' World') would print 'Hello %s! World', not 'Hello World!' + console.error(`${prefix}${msg}`, ...params); + } else { + console.error(prefix, msg, ...params); + } +}; diff --git a/test/utils/logger.test.ts b/test/utils/logger.test.ts new file mode 100644 index 0000000..12ec8af --- /dev/null +++ b/test/utils/logger.test.ts @@ -0,0 +1,86 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +import { describe, it, mock } from 'node:test'; +import { createLogger, LogLevel } from '../../src/utils/logger'; +import { equal, match } from 'node:assert/strict'; + +describe('createLogger', () => { + + it('should respect log level', () => { + const output: unknown[] = []; + mock.method(console, 'log', (arg: unknown) => output.push(arg)); + mock.method(console, 'error', (arg: unknown) => output.push(arg)); + + const levels = new Map([ + [ LogLevel.ERROR, 'error' ], + [ LogLevel.WARN, 'warn' ], + [ LogLevel.INFO, 'info' ], + [ LogLevel.DEBUG, 'debug' ], + ]); + + for (const [ level, label ] of levels.entries()) { + const logger = createLogger(level); + logger.error(`${label}.error`); + logger.warn(`${label}.warn`); + logger.info(`${label}.info`); + logger.debug(`${label}.debug`); + } + + const lines = output.join('\n'); + + equal(lines.includes('debug.error'), true); + equal(lines.includes('debug.warn'), true); + equal(lines.includes('debug.info'), true); + equal(lines.includes('debug.debug'), true); + + equal(lines.includes('info.error'), true); + equal(lines.includes('info.warn'), true); + equal(lines.includes('info.info'), true); + equal(lines.includes('info.debug'), false); + + equal(lines.includes('warn.error'), true); + equal(lines.includes('warn.warn'), true); + equal(lines.includes('warn.info'), false); + equal(lines.includes('warn.debug'), false); + + equal(lines.includes('error.error'), true); + equal(lines.includes('error.warn'), false); + equal(lines.includes('error.info'), false); + equal(lines.includes('error.debug'), false); + }); + + it('should not try to concatenate error objects with the prefix string', () => { + const consoleErrorMock = mock.method(console, 'error', () => {}); + + const logger = createLogger(LogLevel.DEBUG); + const err = new Error('error'); + logger.error(err); + + equal(consoleErrorMock.mock.calls[0].arguments[1], err); + }); + + it('should support format functions like console.log does', () => { + const consoleErrorMock = mock.method(console, 'error', () => {}); + + const logger = createLogger(LogLevel.DEBUG); + logger.debug('hello %s', 'world'); + + match(consoleErrorMock.mock.calls[0].arguments[0], /hello %s/); + equal(consoleErrorMock.mock.calls[0].arguments[1], 'world'); + }); + +});