diff --git a/lib/base-cmd.js b/lib/base-cmd.js index 3e6c4758cbd58..b26cbb8c4e284 100644 --- a/lib/base-cmd.js +++ b/lib/base-cmd.js @@ -1,4 +1,5 @@ const { log } = require('proc-log') +const { definitions } = require('@npmcli/config/lib/definitions') class BaseCommand { // these defaults can be overridden by individual commands @@ -10,16 +11,24 @@ class BaseCommand { static name = null static description = null static params = null + static definitions = null // this is a static so that we can read from it without instantiating a command // which would require loading the config static get describeUsage () { - const { definitions } = require('@npmcli/config/lib/definitions') const { aliases: cmdAliases } = require('./utils/cmd-list') const seenExclusive = new Set() const wrapWidth = 80 const { description, usage = [''], name, params } = this + if (this.definitions) { + this.definitions = this.definitions + } else { + this.definitions = definitions + } + + const definitionsPool = { ...definitions, ...this.definitions } + const fullUsage = [ `${description}`, '', @@ -35,14 +44,14 @@ class BaseCommand { if (seenExclusive.has(param)) { continue } - const { exclusive } = definitions[param] - let paramUsage = `${definitions[param].usage}` + const exclusive = definitionsPool[param]?.exclusive + let paramUsage = definitionsPool[param]?.usage if (exclusive) { const exclusiveParams = [paramUsage] seenExclusive.add(param) for (const e of exclusive) { seenExclusive.add(e) - exclusiveParams.push(definitions[e].usage) + exclusiveParams.push(definitionsPool[e].usage) } paramUsage = `${exclusiveParams.join('|')}` } @@ -77,7 +86,7 @@ class BaseCommand { constructor (npm) { this.npm = npm - const { config } = this.npm + const { config } = this if (!this.constructor.skipConfigValidation) { config.validate() @@ -88,6 +97,11 @@ class BaseCommand { } } + get config () { + // Return command-specific config if it exists, otherwise use npm's config + return this.npm.config + } + get name () { return this.constructor.name } diff --git a/lib/npm.js b/lib/npm.js index c635f3e05a7b3..82abaec752361 100644 --- a/lib/npm.js +++ b/lib/npm.js @@ -37,6 +37,8 @@ class Npm { #runId = new Date().toISOString().replace(/[.:]/g, '_') #title = 'npm' #argvClean = [] + #argv = undefined + #excludeNpmCwd = undefined #npmRoot = null #display = null @@ -227,6 +229,14 @@ class Npm { process.env.npm_command = this.command } + if (!Command.definitions || Command.definitions === definitions) { + this.config.logWarnings() + } else { + this.config.loadCommand(Command.definitions) + this.config.logWarnings() + this.config.warn = true + } + if (this.config.get('usage')) { return output.standard(command.usage) } diff --git a/tap-snapshots/test/lib/commands/install.js.test.cjs b/tap-snapshots/test/lib/commands/install.js.test.cjs index 3c9fa9bbec447..d5315397aaf4e 100644 --- a/tap-snapshots/test/lib/commands/install.js.test.cjs +++ b/tap-snapshots/test/lib/commands/install.js.test.cjs @@ -134,9 +134,9 @@ silly logfile done cleaning log files verbose stack Error: The developer of this package has specified the following through devEngines verbose stack Invalid devEngines.runtime verbose stack Invalid name "nondescript" does not match "node" for "runtime" -verbose stack at Install.checkDevEngines ({CWD}/lib/base-cmd.js:181:27) -verbose stack at MockNpm.#exec ({CWD}/lib/npm.js:252:7) -verbose stack at MockNpm.exec ({CWD}/lib/npm.js:208:9) +verbose stack at Install.checkDevEngines ({CWD}/lib/base-cmd.js:195:27) +verbose stack at MockNpm.#exec ({CWD}/lib/npm.js:262:7) +verbose stack at MockNpm.exec ({CWD}/lib/npm.js:210:9) error code EBADDEVENGINES error EBADDEVENGINES The developer of this package has specified the following through devEngines error EBADDEVENGINES Invalid devEngines.runtime @@ -199,9 +199,9 @@ warn EBADDEVENGINES } verbose stack Error: The developer of this package has specified the following through devEngines verbose stack Invalid devEngines.runtime verbose stack Invalid name "nondescript" does not match "node" for "runtime" -verbose stack at Install.checkDevEngines ({CWD}/lib/base-cmd.js:181:27) -verbose stack at MockNpm.#exec ({CWD}/lib/npm.js:252:7) -verbose stack at MockNpm.exec ({CWD}/lib/npm.js:208:9) +verbose stack at Install.checkDevEngines ({CWD}/lib/base-cmd.js:195:27) +verbose stack at MockNpm.#exec ({CWD}/lib/npm.js:262:7) +verbose stack at MockNpm.exec ({CWD}/lib/npm.js:210:9) error code EBADDEVENGINES error EBADDEVENGINES The developer of this package has specified the following through devEngines error EBADDEVENGINES Invalid devEngines.runtime @@ -225,9 +225,9 @@ silly logfile done cleaning log files verbose stack Error: The developer of this package has specified the following through devEngines verbose stack Invalid devEngines.runtime verbose stack Invalid name "nondescript" does not match "node" for "runtime" -verbose stack at Install.checkDevEngines ({CWD}/lib/base-cmd.js:181:27) -verbose stack at MockNpm.#exec ({CWD}/lib/npm.js:252:7) -verbose stack at MockNpm.exec ({CWD}/lib/npm.js:208:9) +verbose stack at Install.checkDevEngines ({CWD}/lib/base-cmd.js:195:27) +verbose stack at MockNpm.#exec ({CWD}/lib/npm.js:262:7) +verbose stack at MockNpm.exec ({CWD}/lib/npm.js:210:9) error code EBADDEVENGINES error EBADDEVENGINES The developer of this package has specified the following through devEngines error EBADDEVENGINES Invalid devEngines.runtime diff --git a/test/lib/npm.js b/test/lib/npm.js index b4ac509adb495..098b1fc193906 100644 --- a/test/lib/npm.js +++ b/test/lib/npm.js @@ -567,3 +567,70 @@ t.test('print usage if non-command param provided', async t => { t.match(joinedOutput(), 'Unknown command: "tset"') t.match(joinedOutput(), 'Did you mean this?') }) + +async function testCommandDefinitions (t, { defaultValue, outputValue, type, flags }) { + const path = require('node:path') + + // Create a temporary command file + const tsetPath = path.join(__dirname, '../../lib/commands/tset.js') + const tsetContent = ` +const Definition = require('@npmcli/config/lib/definitions/definition.js') +const BaseCommand = require('../base-cmd.js') +const { output } = require('proc-log') +const { flatten } = require('@npmcli/config/lib/definitions/index.js') + +module.exports = class TestCommand extends BaseCommand { + static description = 'A test command' + static name = 'tset' + static definitions = { + say: new Definition('say', { + default: ${defaultValue}, + type: ${type}, + description: 'say', + flatten, + }), + } + + async exec () { + const say = this.npm.config.get('say') + output.standard(say) + } +} +` + fs.writeFileSync(tsetPath, tsetContent) + t.teardown(() => { + try { + fs.unlinkSync(tsetPath) + delete require.cache[tsetPath] + } catch (e) { + // ignore + } + }) + + const mockCmdList = require('../../lib/utils/cmd-list.js') + const { npm, joinedOutput } = await loadMockNpm(t, { + argv: ['tset', ...(flags || [])], + mocks: { + '{LIB}/utils/cmd-list.js': { + ...mockCmdList, + commands: [...mockCmdList.commands, 'tset'], + deref: (c) => c === 'tset' ? 'tset' : mockCmdList.deref(c), + }, + }, + }) + + // Now you can execute the mocked command + await npm.exec('tset', []) + + t.match(joinedOutput(), outputValue) +} + +const stack = { + boolean_default: (t) => testCommandDefinitions(t, { type: 'Boolean', defaultValue: 'false', outputValue: 'false' }), + string_default: (t) => testCommandDefinitions(t, { type: 'String', defaultValue: `'meow'`, outputValue: 'meow' }), + string_flag: (t) => testCommandDefinitions(t, { type: 'String', defaultValue: `'meow'`, outputValue: 'woof', flags: ['--say=woof'] }), +} + +Object.entries(stack).forEach(([name, fn]) => { + t.test(name, fn) +}) diff --git a/workspaces/config/lib/index.js b/workspaces/config/lib/index.js index 0ad716ccb069f..443c76e202e6c 100644 --- a/workspaces/config/lib/index.js +++ b/workspaces/config/lib/index.js @@ -51,6 +51,7 @@ const confTypes = new Set([ 'builtin', ...confFileTypes, 'env', + 'flags', 'cli', ]) @@ -59,6 +60,7 @@ class Config { #flatten // populated the first time we flatten the object #flatOptions = null + #warnings = [] static get typeDefs () { return typeDefs @@ -82,17 +84,9 @@ class Config { this.nerfDarts = nerfDarts this.definitions = definitions // turn the definitions into nopt's weirdo syntax - const types = {} - const defaults = {} - this.deprecated = {} - for (const [key, def] of Object.entries(definitions)) { - defaults[key] = def.default - types[key] = def.type - if (def.deprecated) { - this.deprecated[key] = def.deprecated.trim().replace(/\n +/, '\n') - } - } + const { types, defaults, deprecated } = this.getTypesFromDefinitions(definitions) + this.deprecated = deprecated this.#flatten = flatten this.types = types this.shorthands = shorthands @@ -137,6 +131,135 @@ class Config { } this.#loaded = false + + this.warn = true + + this.log = { + warn: (type, ...args) => { + if (!this.warn) { + this.#warnings.push({ type, args }) + } else { + log.warn(...args) + } + }, + } + } + + #checkDeprecated (key) { + if (this.deprecated[key]) { + this.log.warn(`deprecated:${key}`, 'config', key, this.deprecated[key]) + } + } + + #getFlags (types) { + for (const s of Object.keys(this.shorthands)) { + if (s.length > 1 && this.argv.includes(`-${s}`)) { + log.warn(`-${s} is not a valid single-hyphen cli flag and will be removed in the future`) + } + } + nopt.invalidHandler = (k, val, type) => + this.invalidHandler(k, val, type, 'command line options', 'cli') + nopt.unknownHandler = this.unknownHandler + nopt.abbrevHandler = this.abbrevHandler + const conf = nopt(types, this.shorthands, this.argv) + nopt.invalidHandler = null + nopt.unknownHandler = null + this.parsedArgv = conf.argv + delete conf.argv + return conf + } + + #getOneOfKeywords (mustBe, typeDesc) { + let keyword + if (mustBe.length === 1 && typeDesc.includes(Array)) { + keyword = ' one or more' + } else if (mustBe.length > 1 && typeDesc.includes(Array)) { + keyword = ' one or more of:' + } else if (mustBe.length > 1) { + keyword = ' one of:' + } else { + keyword = '' + } + return keyword + } + + #loadObject (obj, where, source, er = null) { + // obj is the raw data read from the file + const conf = this.data.get(where) + if (conf.source) { + const m = `double-loading "${where}" configs from ${source}, ` + + `previously loaded from ${conf.source}` + throw new Error(m) + } + + if (this.sources.has(source)) { + const m = `double-loading config "${source}" as "${where}", ` + + `previously loaded as "${this.sources.get(source)}"` + throw new Error(m) + } + + conf.source = source + this.sources.set(source, where) + if (er) { + conf.loadError = er + if (er.code !== 'ENOENT') { + log.verbose('config', `error loading ${where} config`, er) + } + } else { + conf.raw = obj + for (const [key, value] of Object.entries(obj)) { + const k = envReplace(key, this.env) + const v = this.parseField(value, k) + if (where !== 'default') { + this.#checkDeprecated(k) + if (this.definitions[key]?.exclusive) { + for (const exclusive of this.definitions[key].exclusive) { + if (!this.isDefault(exclusive)) { + throw new TypeError(`--${key} cannot be provided when using --${exclusive}`) + } + } + } + } + if (where !== 'default' || key === 'npm-version') { + this.checkUnknown(where, key) + } + conf.data[k] = v + } + } + } + + async #loadFile (file, type) { + // only catch the error from readFile, not from the loadObject call + log.silly('config', `load:file:${file}`) + await readFile(file, 'utf8').then( + data => { + const parsedConfig = ini.parse(data) + if (type === 'project' && parsedConfig.prefix) { + // Log error if prefix is mentioned in project .npmrc + /* eslint-disable-next-line max-len */ + log.error('config', `prefix cannot be changed from project config: ${file}.`) + } + return this.#loadObject(parsedConfig, type, file) + }, + er => this.#loadObject(null, type, file, er) + ) + } + + getTypesFromDefinitions (definitions) { + if (!definitions) { + definitions = {} + } + const types = {} + const defaults = {} + const deprecated = {} + for (const [key, def] of Object.entries(definitions)) { + defaults[key] = def.default + types[key] = def.type + if (def.deprecated) { + deprecated[key] = def.deprecated.trim().replace(/\n +/, '\n') + } + } + return { types, defaults, deprecated } } get list () { @@ -155,6 +278,29 @@ class Config { return this.#get('global') ? this.globalPrefix : this.localPrefix } + removeWarnings (types) { + const typeSet = new Set(Array.isArray(types) ? types : [types]) + this.#warnings = this.#warnings.filter(w => !typeSet.has(w.type)) + } + + #deduplicateWarnings () { + const seen = new Set() + this.#warnings = this.#warnings.filter(w => { + if (seen.has(w.type)) { + return false + } + seen.add(w.type) + return true + }) + } + + logWarnings () { + for (const warning of this.#warnings) { + log.warn(...warning.args) + } + this.#warnings = [] + } + // return the location where key is found. find (key) { if (!this.loaded) { @@ -172,13 +318,6 @@ class Config { return null } - get (key, where) { - if (!this.loaded) { - throw new Error('call config.load() before reading values') - } - return this.#get(key, where) - } - // we need to get values sometimes, so use this internal one to do so // while in the process of loading. #get (key, where = null) { @@ -189,6 +328,13 @@ class Config { return where === null || hasOwnProperty(data, key) ? data[key] : undefined } + get (key, where) { + if (!this.loaded) { + throw new Error('call config.load() before reading values') + } + return this.#get(key, where) + } + set (key, val, where = 'cli') { if (!this.loaded) { throw new Error('call config.load() before setting values') @@ -362,23 +508,34 @@ class Config { } loadCLI () { - for (const s of Object.keys(this.shorthands)) { - if (s.length > 1 && this.argv.includes(`-${s}`)) { - log.warn(`-${s} is not a valid single-hyphen cli flag and will be removed in the future`) - } - } - nopt.invalidHandler = (k, val, type) => - this.invalidHandler(k, val, type, 'command line options', 'cli') - nopt.unknownHandler = this.unknownHandler - nopt.abbrevHandler = this.abbrevHandler - const conf = nopt(this.types, this.shorthands, this.argv) - nopt.invalidHandler = null - nopt.unknownHandler = null - this.parsedArgv = conf.argv - delete conf.argv + const conf = this.#getFlags(this.types) this.#loadObject(conf, 'cli', 'command line options') } + loadCommand (definitions) { + // Merge command definitions with global definitions + this.definitions = { ...this.definitions, ...definitions } + const { defaults, types, deprecated } = this.getTypesFromDefinitions(definitions) + this.deprecated = { ...this.deprecated, ...deprecated } + this.types = { ...this.types, ...types } + + // Re-parse with merged definitions + const conf = this.#getFlags(this.types) + + // Remove warnings for keys that are now defined + const keysToRemove = Object.keys(definitions).flatMap(key => [ + `unknown:${key}`, + `deprecated:${key}`, + ]) + this.removeWarnings(keysToRemove) + + // Load into new command source - only command-specific defaults + parsed flags + this.#loadObject({ ...defaults, ...conf }, 'flags', 'command-specific flag options') + + // Deduplicate warnings by type (e.g., unknown:key warnings from both cli and flags) + this.#deduplicateWarnings() + } + get valid () { for (const [where, { valid }] of this.data.entries()) { if (valid === false || valid === null && !this.validate(where)) { @@ -510,7 +667,8 @@ class Config { invalidHandler (k, val, type, source, where) { const typeDescription = require('./type-description.js') - log.warn( + this.log.warn( + 'invalid', 'invalid config', k + '=' + JSON.stringify(val), `set in ${source}` @@ -536,7 +694,7 @@ class Config { const msg = 'Must be' + this.#getOneOfKeywords(mustBe, typeDesc) const desc = mustBe.length === 1 ? mustBe[0] : [...new Set(mustBe.map(n => typeof n === 'string' ? n : JSON.stringify(n)))].join(', ') - log.warn('invalid config', msg, desc) + this.log.warn('invalid', 'invalid config', msg, desc) } abbrevHandler (short, long) { @@ -549,109 +707,27 @@ class Config { } } - #getOneOfKeywords (mustBe, typeDesc) { - let keyword - if (mustBe.length === 1 && typeDesc.includes(Array)) { - keyword = ' one or more' - } else if (mustBe.length > 1 && typeDesc.includes(Array)) { - keyword = ' one or more of:' - } else if (mustBe.length > 1) { - keyword = ' one of:' - } else { - keyword = '' - } - return keyword - } - - #loadObject (obj, where, source, er = null) { - // obj is the raw data read from the file - const conf = this.data.get(where) - if (conf.source) { - const m = `double-loading "${where}" configs from ${source}, ` + - `previously loaded from ${conf.source}` - throw new Error(m) - } - - if (this.sources.has(source)) { - const m = `double-loading config "${source}" as "${where}", ` + - `previously loaded as "${this.sources.get(source)}"` - throw new Error(m) - } - - conf.source = source - this.sources.set(source, where) - if (er) { - conf.loadError = er - if (er.code !== 'ENOENT') { - log.verbose('config', `error loading ${where} config`, er) - } - } else { - conf.raw = obj - for (const [key, value] of Object.entries(obj)) { - const k = envReplace(key, this.env) - const v = this.parseField(value, k) - if (where !== 'default') { - this.#checkDeprecated(k) - if (this.definitions[key]?.exclusive) { - for (const exclusive of this.definitions[key].exclusive) { - if (!this.isDefault(exclusive)) { - throw new TypeError(`--${key} cannot be provided when using --${exclusive}`) - } - } - } - } - if (where !== 'default' || key === 'npm-version') { - this.checkUnknown(where, key) - } - conf.data[k] = v - } - } - } - checkUnknown (where, key) { if (!this.definitions[key]) { if (internalEnv.includes(key)) { return } if (!key.includes(':')) { - log.warn(`Unknown ${where} config "${where === 'cli' ? '--' : ''}${key}". This will stop working in the next major version of npm.`) + this.log.warn(`unknown:${key}`, `Unknown ${where} config "${where === 'cli' || where === 'flags' ? '--' : ''}${key}". This will stop working in the next major version of npm.`) return } const baseKey = key.split(':').pop() if (!this.definitions[baseKey] && !this.nerfDarts.includes(baseKey)) { - log.warn(`Unknown ${where} config "${baseKey}" (${key}). This will stop working in the next major version of npm.`) + this.log.warn(`unknown:${baseKey}`, `Unknown ${where} config "${baseKey}" (${key}). This will stop working in the next major version of npm.`) } } } - #checkDeprecated (key) { - if (this.deprecated[key]) { - log.warn('config', key, this.deprecated[key]) - } - } - // Parse a field, coercing it to the best type available. parseField (f, key, listElement = false) { return parseField(f, key, this, listElement) } - async #loadFile (file, type) { - // only catch the error from readFile, not from the loadObject call - log.silly('config', `load:file:${file}`) - await readFile(file, 'utf8').then( - data => { - const parsedConfig = ini.parse(data) - if (type === 'project' && parsedConfig.prefix) { - // Log error if prefix is mentioned in project .npmrc - /* eslint-disable-next-line max-len */ - log.error('config', `prefix cannot be changed from project config: ${file}.`) - } - return this.#loadObject(parsedConfig, type, file) - }, - er => this.#loadObject(null, type, file, er) - ) - } - loadBuiltinConfig () { return this.#loadFile(resolve(this.npmPath, 'npmrc'), 'builtin') } diff --git a/workspaces/config/test/index.js b/workspaces/config/test/index.js index f60070d419bfd..c927ae52ba219 100644 --- a/workspaces/config/test/index.js +++ b/workspaces/config/test/index.js @@ -1144,7 +1144,7 @@ t.test('nerfdart auths set at the top level into the registry', async t => { // now we go ahead and do the repair, and save c.repair() await c.save('user') - t.same(c.list[3], expect) + t.same(c.data.get('user').data, expect) }) } }) @@ -1587,3 +1587,273 @@ t.test('abbreviation expansion warnings', async t => { ['warn', 'Expanding --bef to --before. This will stop working in the next major version of npm'], ], 'Warns about expanded abbreviations') }) + +t.test('warning suppression and logging', async t => { + const path = t.testdir() + const logs = [] + const logHandler = (...args) => logs.push(args) + process.on('log', logHandler) + t.teardown(() => process.off('log', logHandler)) + + const config = new Config({ + npmPath: `${path}/npm`, + env: {}, + argv: [process.execPath, __filename, '--unknown-key', 'value'], + cwd: path, + shorthands, + definitions, + nerfDarts, + }) + + // Load first to collect warnings + await config.load() + + // Now disable warnings and trigger more + config.warn = false + config.log.warn('test-type', 'test warning 1') + config.log.warn('test-type2', 'test warning 2') + + // Should have warnings collected but not logged + const initialWarnings = logs.filter(l => l[0] === 'warn') + const beforeCount = initialWarnings.length + + // Now log the warnings + config.warn = true + config.logWarnings() + const afterLogging = logs.filter(l => l[0] === 'warn') + t.ok(afterLogging.length > beforeCount, 'warnings logged after logWarnings()') + + // Calling logWarnings again should not add more warnings + const warningCount = afterLogging.length + config.logWarnings() + const finalWarnings = logs.filter(l => l[0] === 'warn') + t.equal(finalWarnings.length, warningCount, 'no duplicate warnings after second logWarnings()') +}) + +t.test('removeWarnings', async t => { + const path = t.testdir() + const logs = [] + const logHandler = (...args) => logs.push(args) + process.on('log', logHandler) + t.teardown(() => process.off('log', logHandler)) + + const config = new Config({ + npmPath: `${path}/npm`, + env: {}, + argv: [process.execPath, __filename, '--unknown1', 'value', '--unknown2', 'value'], + cwd: path, + shorthands, + definitions, + nerfDarts, + }) + + config.warn = false + await config.load() + + // Remove specific warning types + config.removeWarnings('unknown:unknown1') + config.logWarnings() + + const warnings = logs.filter(l => l[0] === 'warn') + const hasUnknown1 = warnings.some(w => w[1].includes('unknown1')) + const hasUnknown2 = warnings.some(w => w[1].includes('unknown2')) + + t.notOk(hasUnknown1, 'unknown1 warning removed') + t.ok(hasUnknown2, 'unknown2 warning still present') +}) + +t.test('removeWarnings with array', async t => { + const path = t.testdir() + const logs = [] + const logHandler = (...args) => logs.push(args) + process.on('log', logHandler) + t.teardown(() => process.off('log', logHandler)) + + const config = new Config({ + npmPath: `${path}/npm`, + env: {}, + argv: [process.execPath, __filename, '--unknown1', 'value', '--unknown2', 'value'], + cwd: path, + shorthands, + definitions, + nerfDarts, + }) + + config.warn = false + await config.load() + + // Count warnings before removal + const beforeRemoval = logs.filter(l => l[0] === 'warn').length + + // Remove multiple warning types + config.removeWarnings(['unknown:unknown1', 'unknown:unknown2']) + config.logWarnings() + + const warnings = logs.filter(l => l[0] === 'warn') + // Check that no new unknown1 or unknown2 warnings were added + const hasUnknown1 = warnings.slice(beforeRemoval).some(w => w[1].includes('unknown1')) + const hasUnknown2 = warnings.slice(beforeRemoval).some(w => w[1].includes('unknown2')) + t.notOk(hasUnknown1, 'unknown1 warnings removed') + t.notOk(hasUnknown2, 'unknown2 warnings removed') +}) + +t.test('loadCommand method', async t => { + const path = t.testdir() + const logs = [] + const logHandler = (...args) => logs.push(args) + process.on('log', logHandler) + t.teardown(() => process.off('log', logHandler)) + + const commandDefs = createDef('cmd-option', { + default: false, + type: Boolean, + description: 'A command-specific option', + }) + + const config = new Config({ + npmPath: `${path}/npm`, + env: {}, + argv: [process.execPath, __filename, '--cmd-option', '--unknown-cmd'], + cwd: path, + shorthands, + definitions, + nerfDarts, + }) + + config.warn = false + await config.load() + + // Load command-specific definitions + config.loadCommand(commandDefs) + + // Check that cmd-option is now recognized and set to true + t.equal(config.get('cmd-option'), true, 'command option loaded from CLI') + + // Check that warnings were removed for the now-defined key + config.logWarnings() + const warnings = logs.filter(l => l[0] === 'warn' && l[1].includes('cmd-option')) + t.equal(warnings.length, 0, 'no warnings for now-defined cmd-option') + + // Check that unknown-cmd still generates a warning + const unknownWarnings = logs.filter(l => l[0] === 'warn' && l[1].includes('unknown-cmd')) + t.ok(unknownWarnings.length > 0, 'unknown-cmd still generates warning') +}) + +t.test('loadCommand with deprecated definitions', async t => { + const path = t.testdir() + const logs = [] + const logHandler = (...args) => logs.push(args) + process.on('log', logHandler) + t.teardown(() => process.off('log', logHandler)) + + const commandDefs = createDef('deprecated-opt', { + default: 'default', + type: String, + description: 'A deprecated option', + deprecated: 'This option is deprecated', + }) + + const config = new Config({ + npmPath: `${path}/npm`, + env: {}, + argv: [process.execPath, __filename, '--deprecated-opt', 'value'], + cwd: path, + shorthands, + definitions, + nerfDarts, + }) + + await config.load() + config.loadCommand(commandDefs) + + // Should have deprecation warning + const deprecatedWarnings = logs.filter(l => + l[0] === 'warn' && l[1] === 'config' && l[2] === 'deprecated-opt' + ) + t.ok(deprecatedWarnings.length > 0, 'deprecated option warning logged') +}) + +t.test('getTypesFromDefinitions with no definitions', async t => { + const config = new Config({ + npmPath: t.testdir(), + env: {}, + argv: [process.execPath, __filename], + cwd: process.cwd(), + shorthands, + definitions, + nerfDarts, + }) + + const result = config.getTypesFromDefinitions(undefined) + t.ok(result.types, 'returns types object') + t.ok(result.defaults, 'returns defaults object') + t.ok(result.deprecated, 'returns deprecated object') + t.same(Object.keys(result.types), [], 'empty types for undefined definitions') +}) + +t.test('prefix getter when global is true', async t => { + const path = t.testdir() + const config = new Config({ + npmPath: `${path}/npm`, + env: {}, + argv: [process.execPath, __filename, '--global'], + cwd: path, + shorthands, + definitions, + nerfDarts, + }) + + await config.load() + t.equal(config.prefix, config.globalPrefix, 'prefix returns globalPrefix when global=true') +}) + +t.test('prefix getter when global is false', async t => { + const path = t.testdir() + const config = new Config({ + npmPath: `${path}/npm`, + env: {}, + argv: [process.execPath, __filename], + cwd: path, + shorthands, + definitions, + nerfDarts, + }) + + await config.load() + t.equal(config.prefix, config.localPrefix, 'prefix returns localPrefix when global=false') +}) + +t.test('find throws when config not loaded', async t => { + const config = new Config({ + npmPath: t.testdir(), + env: {}, + argv: [process.execPath, __filename], + cwd: process.cwd(), + shorthands, + definitions, + nerfDarts, + }) + + t.throws( + () => config.find('registry'), + /call config\.load\(\) before reading values/, + 'find throws before load' + ) +}) + +t.test('valid getter with invalid config', async t => { + const path = t.testdir() + const config = new Config({ + npmPath: `${path}/npm`, + env: {}, + argv: [process.execPath, __filename, '--maxsockets', 'not-a-number'], + cwd: path, + shorthands, + definitions, + nerfDarts, + }) + + await config.load() + const isValid = config.valid + t.notOk(isValid, 'config is invalid when it has invalid values') +})