diff --git a/bin.js b/bin.js index 4a7a5f73..36659a1e 100644 --- a/bin.js +++ b/bin.js @@ -73,8 +73,9 @@ if (cmd.h || cmd.help) { // Remove default values opts = filter(opts, value => value !== DEFAULT_VALUE) const config = loadConfig(opts.config) - // Override config with cli options - opts = Object.assign({}, config, opts) + const envConfig = loadEnvConfig() + // Priority: CLI flags > config file > env vars + opts = Object.assign({}, envConfig, config, opts) // set defaults opts.errorLikeObjectKeys = opts.errorLikeObjectKeys || 'err,error' opts.errorProps = opts.errorProps || '' @@ -101,6 +102,56 @@ if (cmd.h || cmd.help) { return result.data } + /** + * Load pino-pretty configuration from environment variables. + * All options use the `PINO_PRETTY_` prefix and SCREAMING_SNAKE_CASE naming. + * For example: `--singleLine` → `PINO_PRETTY_SINGLE_LINE=true` + * Boolean options accept: `true`, `1`, `false`, `0` (case-insensitive). + * @returns {object} + */ + function loadEnvConfig () { + const envMap = { + PINO_PRETTY_COLORIZE: { key: 'colorize', type: 'boolean' }, + PINO_PRETTY_CRLF: { key: 'crlf', type: 'boolean' }, + PINO_PRETTY_ERROR_PROPS: { key: 'errorProps', type: 'string' }, + PINO_PRETTY_LEVEL_FIRST: { key: 'levelFirst', type: 'boolean' }, + PINO_PRETTY_MINIMUM_LEVEL: { key: 'minimumLevel', type: 'string' }, + PINO_PRETTY_CUSTOM_LEVELS: { key: 'customLevels', type: 'string' }, + PINO_PRETTY_CUSTOM_COLORS: { key: 'customColors', type: 'string' }, + PINO_PRETTY_USE_ONLY_CUSTOM_PROPS: { key: 'useOnlyCustomProps', type: 'boolean' }, + PINO_PRETTY_ERROR_LIKE_OBJECT_KEYS: { key: 'errorLikeObjectKeys', type: 'string' }, + PINO_PRETTY_MESSAGE_KEY: { key: 'messageKey', type: 'string' }, + PINO_PRETTY_LEVEL_KEY: { key: 'levelKey', type: 'string' }, + PINO_PRETTY_LEVEL_LABEL: { key: 'levelLabel', type: 'string' }, + PINO_PRETTY_MESSAGE_FORMAT: { key: 'messageFormat', type: 'string' }, + PINO_PRETTY_TIMESTAMP_KEY: { key: 'timestampKey', type: 'string' }, + PINO_PRETTY_TRANSLATE_TIME: { key: 'translateTime', type: 'string' }, + PINO_PRETTY_IGNORE: { key: 'ignore', type: 'string' }, + PINO_PRETTY_INCLUDE: { key: 'include', type: 'string' }, + PINO_PRETTY_HIDE_OBJECT: { key: 'hideObject', type: 'boolean' }, + PINO_PRETTY_SINGLE_LINE: { key: 'singleLine', type: 'boolean' } + } + + const result = {} + for (const [envKey, { key, type }] of Object.entries(envMap)) { + const value = process.env[envKey] + if (value === undefined || value === '') continue + + if (type === 'boolean') { + const lower = value.toLowerCase() + if (lower === 'true' || lower === '1') { + result[key] = true + } else if (lower === 'false' || lower === '0') { + result[key] = false + } + // Invalid boolean values are silently ignored + } else { + result[key] = value + } + } + return result + } + function filter (obj, cb) { return Object.keys(obj).reduce((acc, key) => { const value = obj[key] diff --git a/test/cli-env.test.js b/test/cli-env.test.js new file mode 100644 index 00000000..d5533d62 --- /dev/null +++ b/test/cli-env.test.js @@ -0,0 +1,114 @@ +'use strict' + +process.env.TZ = 'UTC' + +const path = require('node:path') +const { spawn } = require('node:child_process') +const { describe, test } = require('node:test') +const { once } = require('./helper') + +const bin = require.resolve(path.join(__dirname, '..', 'bin.js')) +const logLine = '{"level":30,"time":1522431328992,"msg":"hello world","pid":42,"hostname":"foo"}\n' +const baseEnv = { TERM: 'dumb', TZ: 'UTC' } +const formattedEpoch = '17:35:28.992' + +describe('cli env vars', () => { + test('PINO_PRETTY_LEVEL_FIRST=true flips level and time', async (t) => { + t.plan(1) + const env = { ...baseEnv, PINO_PRETTY_LEVEL_FIRST: 'true' } + const child = spawn(process.argv[0], [bin], { env }) + child.on('error', t.assert.fail) + const endPromise = once(child.stdout, 'data', (data) => { + t.assert.strictEqual(data.toString(), `INFO [${formattedEpoch}] (42): hello world\n`) + }) + child.stdin.write(logLine) + await endPromise + t.after(() => child.kill()) + }) + + test('PINO_PRETTY_LEVEL_FIRST=1 flips level and time', async (t) => { + t.plan(1) + const env = { ...baseEnv, PINO_PRETTY_LEVEL_FIRST: '1' } + const child = spawn(process.argv[0], [bin], { env }) + child.on('error', t.assert.fail) + const endPromise = once(child.stdout, 'data', (data) => { + t.assert.strictEqual(data.toString(), `INFO [${formattedEpoch}] (42): hello world\n`) + }) + child.stdin.write(logLine) + await endPromise + t.after(() => child.kill()) + }) + + test('PINO_PRETTY_SINGLE_LINE=true prints all on one line', async (t) => { + t.plan(1) + const multiKeyLog = '{"level":30,"time":1522431328992,"msg":"hello","pid":42,"hostname":"foo","extra":"value"}\n' + const env = { ...baseEnv, PINO_PRETTY_SINGLE_LINE: 'true' } + const child = spawn(process.argv[0], [bin], { env }) + child.on('error', t.assert.fail) + const endPromise = once(child.stdout, 'data', (data) => { + // In single line mode the extra fields appear inline, not on separate lines + const output = data.toString() + t.assert.ok(!output.includes('\n '), 'should not have indented extra fields') + }) + child.stdin.write(multiKeyLog) + await endPromise + t.after(() => child.kill()) + }) + + test('PINO_PRETTY_IGNORE=pid,hostname removes those keys', async (t) => { + t.plan(1) + const env = { ...baseEnv, PINO_PRETTY_IGNORE: 'pid,hostname' } + const child = spawn(process.argv[0], [bin], { env }) + child.on('error', t.assert.fail) + const endPromise = once(child.stdout, 'data', (data) => { + t.assert.strictEqual(data.toString(), `[${formattedEpoch}] INFO: hello world\n`) + }) + child.stdin.write(logLine) + await endPromise + t.after(() => child.kill()) + }) + + test('PINO_PRETTY_MESSAGE_KEY overrides default message key', async (t) => { + t.plan(1) + const customLog = '{"level":30,"time":1522431328992,"message":"custom key msg","pid":42,"hostname":"foo"}\n' + const env = { ...baseEnv, PINO_PRETTY_MESSAGE_KEY: 'message' } + const child = spawn(process.argv[0], [bin], { env }) + child.on('error', t.assert.fail) + const endPromise = once(child.stdout, 'data', (data) => { + t.assert.ok(data.toString().includes('custom key msg')) + }) + child.stdin.write(customLog) + await endPromise + t.after(() => child.kill()) + }) + + test('CLI flag overrides env var (CLI wins)', async (t) => { + t.plan(1) + // env says level-first=true, but CLI does not pass it => CLI default should win + // Since the default is no levelFirst, output should show time first + const env = { ...baseEnv, PINO_PRETTY_LEVEL_FIRST: 'false' } + const child = spawn(process.argv[0], [bin, '--levelFirst'], { env }) + child.on('error', t.assert.fail) + const endPromise = once(child.stdout, 'data', (data) => { + // CLI flag wins: level appears first + t.assert.strictEqual(data.toString(), `INFO [${formattedEpoch}] (42): hello world\n`) + }) + child.stdin.write(logLine) + await endPromise + t.after(() => child.kill()) + }) + + test('invalid PINO_PRETTY_LEVEL_FIRST value is silently ignored', async (t) => { + t.plan(1) + const env = { ...baseEnv, PINO_PRETTY_LEVEL_FIRST: 'yes' } + const child = spawn(process.argv[0], [bin], { env }) + child.on('error', t.assert.fail) + const endPromise = once(child.stdout, 'data', (data) => { + // Default output: time first + t.assert.strictEqual(data.toString(), `[${formattedEpoch}] INFO (42): hello world\n`) + }) + child.stdin.write(logLine) + await endPromise + t.after(() => child.kill()) + }) +})