From add37a1a5bc85c86e20fd968b9ec5e60657596c7 Mon Sep 17 00:00:00 2001 From: Kai Gritun Date: Sat, 7 Feb 2026 08:45:18 -0500 Subject: [PATCH] fix: improve boolean env var coercion and add NO_ prefix negation support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes yargs/yargs#2501 Two issues addressed: 1. Boolean string coercion now accepts common truthy/falsy values: - 'true', '1', 'yes', 'on' → true - 'false', '0', 'no', 'off' → false Previously only 'true' was recognized as truthy. 2. Environment variables with NO_ prefix are now treated as boolean negation (consistent with --no-* CLI behavior): - MY_APP_NO_DO_THING=true → doThing: false - MY_APP_NO_DO_THING=false → doThing: true (double negation) The NO_ prefix respects the 'negation-prefix' and 'boolean-negation' configuration options. --- lib/yargs-parser.ts | 44 +++++++++++++++++++++++-- test/yargs-parser.mjs | 76 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 2 deletions(-) diff --git a/lib/yargs-parser.ts b/lib/yargs-parser.ts index 48a2d0f2..bbd906db 100644 --- a/lib/yargs-parser.ts +++ b/lib/yargs-parser.ts @@ -617,7 +617,18 @@ export class YargsParser { // handle parsing boolean arguments --foo=true --bar false. if (checkAllAliases(key, flags.bools) || checkAllAliases(key, flags.counts)) { - if (typeof val === 'string') val = val === 'true' + if (typeof val === 'string') { + const lower = val.toLowerCase() + // Support common truthy/falsy string representations + if (lower === 'true' || lower === '1' || lower === 'yes' || lower === 'on') { + val = true + } else if (lower === 'false' || lower === '0' || lower === 'no' || lower === 'off') { + val = false + } else { + // For other strings, treat non-empty as truthy (backwards compat edge case) + val = val === 'true' + } + } } let value = Array.isArray(val) @@ -728,19 +739,48 @@ export class YargsParser { if (typeof envPrefix === 'undefined') return const prefix = typeof envPrefix === 'string' ? envPrefix : '' + // Convert negation-prefix to env var format (e.g., 'no-' -> 'NO_') + const negationPrefix = (configuration['negation-prefix'] || 'no-').toUpperCase().replace(/-/g, '_') const env = mixin.env() Object.keys(env).forEach(function (envVar) { if (prefix === '' || envVar.lastIndexOf(prefix, 0) === 0) { // get array of nested keys and convert them to camel case + let isNegated = false const keys = envVar.split('__').map(function (key, i) { if (i === 0) { key = key.substring(prefix.length) + // Check for negation prefix (e.g., NO_DO_THING -> doThing with negation) + if (configuration['boolean-negation'] && key.lastIndexOf(negationPrefix, 0) === 0) { + key = key.substring(negationPrefix.length) + isNegated = true + } } return camelCase(key) }) if (((configOnly && flags.configs[keys.join('.')]) || !configOnly) && !hasKey(argv, keys)) { - setArg(keys.join('.'), env[envVar]) + let value: any = env[envVar] + // Handle negated boolean env vars + if (isNegated && checkAllAliases(keys.join('.'), flags.bools)) { + // For negated booleans: NO_FOO=true means foo=false, NO_FOO=false means foo=true + const lower = String(value).toLowerCase() + if (lower === 'true' || lower === '1' || lower === 'yes' || lower === 'on') { + value = false + } else if (lower === 'false' || lower === '0' || lower === 'no' || lower === 'off') { + value = true + } else { + value = false // Default negated to false for non-standard values + } + setKey(argv, keys, value) + // Also set aliases + if (flags.aliases[keys.join('.')]) { + flags.aliases[keys.join('.')].forEach(function (x) { + setKey(argv, x.split('.'), value) + }) + } + } else { + setArg(keys.join('.'), value) + } } } }) diff --git a/test/yargs-parser.mjs b/test/yargs-parser.mjs index 4c692375..dfe7a285 100644 --- a/test/yargs-parser.mjs +++ b/test/yargs-parser.mjs @@ -2306,6 +2306,82 @@ describe('yargs-parser', function () { bar: 'bar' }) }) + + it('should coerce boolean env var "1" to true', function () { + process.env.TEST_BOOL_DO_THING = '1' + const result = parser([], { + envPrefix: 'TEST_BOOL', + boolean: ['doThing'] + }) + result.doThing.should.equal(true) + delete process.env.TEST_BOOL_DO_THING + }) + + it('should coerce boolean env var "0" to false', function () { + process.env.TEST_BOOL_ZERO = '0' + const result = parser([], { + envPrefix: 'TEST_BOOL', + boolean: ['zero'] + }) + result.zero.should.equal(false) + delete process.env.TEST_BOOL_ZERO + }) + + it('should coerce boolean env var "yes" to true', function () { + process.env.TEST_BOOL_YES = 'yes' + const result = parser([], { + envPrefix: 'TEST_BOOL', + boolean: ['yes'] + }) + result.yes.should.equal(true) + delete process.env.TEST_BOOL_YES + }) + + it('should coerce boolean env var "no" to false', function () { + process.env.TEST_BOOL_NO = 'no' + const result = parser([], { + envPrefix: 'TEST_BOOL', + boolean: ['no'] + }) + result.no.should.equal(false) + delete process.env.TEST_BOOL_NO + }) + + it('should handle NO_ prefix in env vars for boolean negation', function () { + process.env.MY_APP_NO_DO_THING = 'true' + const result = parser([], { + envPrefix: 'MY_APP_', + boolean: ['doThing'], + default: { doThing: true } + }) + // NO_DO_THING=true should negate doThing, making it false + result.doThing.should.equal(false) + delete process.env.MY_APP_NO_DO_THING + }) + + it('should handle NO_ prefix with "1" value for boolean negation', function () { + process.env.MY_APP_NO_FEATURE = '1' + const result = parser([], { + envPrefix: 'MY_APP_', + boolean: ['feature'], + default: { feature: true } + }) + // NO_FEATURE=1 should negate feature, making it false + result.feature.should.equal(false) + delete process.env.MY_APP_NO_FEATURE + }) + + it('should handle NO_ prefix with "false" value (double negation)', function () { + process.env.MY_APP_NO_OPT = 'false' + const result = parser([], { + envPrefix: 'MY_APP_', + boolean: ['opt'], + default: { opt: false } + }) + // NO_OPT=false means do NOT negate, so opt should be true + result.opt.should.equal(true) + delete process.env.MY_APP_NO_OPT + }) }) describe('configuration', function () {