diff --git a/docs/lib/build.js b/docs/lib/build.js index 86f8acac102f1..c214e43cdd702 100644 --- a/docs/lib/build.js +++ b/docs/lib/build.js @@ -7,6 +7,109 @@ const parseFrontMatter = require('front-matter') const checkNav = require('./check-nav.js') const { DOC_EXT, ...transform } = require('./index.js') +// Auto-generate doc templates for commands without docs +const autoGenerateMissingDocs = async (contentPath, navPath, commandsPath = null) => { + commandsPath = commandsPath || join(__dirname, '../../lib/commands') + const docsCommandsPath = join(contentPath, 'commands') + + // Get all command files + const commandFiles = await fs.readdir(commandsPath) + const commands = commandFiles + .filter(f => f.endsWith('.js')) + .map(f => basename(f, '.js')) + + // Get existing doc files + const existingDocs = await fs.readdir(docsCommandsPath) + const documentedCommands = existingDocs + .filter(f => f.startsWith('npm-') && f.endsWith(DOC_EXT)) + .map(f => f.replace('npm-', '').replace(DOC_EXT, '')) + + // Find commands without docs + const missingDocs = commands.filter(cmd => !documentedCommands.includes(cmd)) + + // Generate docs for missing commands + for (const cmd of missingDocs) { + const Command = require(join(commandsPath, `${cmd}.js`)) + const description = Command.description || `The ${cmd} command` + const docPath = join(docsCommandsPath, `npm-${cmd}${DOC_EXT}`) + + const template = `--- +title: npm-${cmd} +section: 1 +description: ${description} +--- + +### Synopsis + + + +### Description + +${description} + +### Configuration + + + +### See Also + +* [npm help config](/commands/npm-config) +` + + await fs.writeFile(docPath, template, 'utf-8') + } + + // Update nav.yml if there are new commands + if (missingDocs.length > 0) { + const navContent = await fs.readFile(navPath, 'utf-8') + const navData = yaml.parse(navContent) + + // Find the CLI Commands section + const commandsSection = navData.find(section => section.title === 'CLI Commands') + if (commandsSection && commandsSection.children) { + // Get existing command entries + const existingEntries = new Set( + commandsSection.children + .map(child => child.url?.replace('/commands/npm-', '')) + .filter(Boolean) + ) + + // Add missing commands to the children array + for (const cmd of missingDocs) { + if (!existingEntries.has(cmd)) { + const Command = require(join(commandsPath, `${cmd}.js`)) + const description = Command.description || `The ${cmd} command` + + commandsSection.children.push({ + title: `npm ${cmd}`, + url: `/commands/npm-${cmd}`, + description: description, + }) + } + } + + // Sort children alphabetically by title + commandsSection.children.sort((a, b) => { + if (a.title === 'npm') { + return -1 + } + if (b.title === 'npm') { + return 1 + } + return a.title.localeCompare(b.title) + }) + + // Write updated nav.yml + const prefix = ` +# This is the navigation for the documentation pages; it is not used +# directly within the CLI documentation. Instead, it will be used +# for the https://docs.npmjs.com/ site. +` + await fs.writeFile(navPath, `${prefix}\n\n${yaml.stringify(navData)}`, 'utf-8') + } + } +} + const mkDirs = async (paths) => { const uniqDirs = [...new Set(paths.map((p) => dirname(p)))] return Promise.all(uniqDirs.map((d) => fs.mkdir(d, { recursive: true }))) @@ -28,7 +131,12 @@ const pAll = async (obj) => { }, {}) } -const run = async ({ content, template, nav, man, html, md }) => { +const run = async ({ content, template, nav, man, html, md, skipAutoGenerate }) => { + // Auto-generate docs for commands without documentation + if (!skipAutoGenerate) { + await autoGenerateMissingDocs(content, nav) + } + await rmAll(man, html, md) const [contentPaths, navFile, options] = await Promise.all([ readDocs(content), @@ -145,3 +253,4 @@ const run = async ({ content, template, nav, man, html, md }) => { } module.exports = run +module.exports.autoGenerateMissingDocs = autoGenerateMissingDocs diff --git a/docs/lib/content/commands/npm-get.md b/docs/lib/content/commands/npm-get.md new file mode 100644 index 0000000000000..9e03458e7c8ce --- /dev/null +++ b/docs/lib/content/commands/npm-get.md @@ -0,0 +1,21 @@ +--- +title: npm-get +section: 1 +description: Get a value from the npm configuration +--- + +### Synopsis + + + +### Description + +Get a value from the npm configuration + +### Configuration + + + +### See Also + +* [npm help config](/commands/npm-config) diff --git a/docs/lib/content/commands/npm-ll.md b/docs/lib/content/commands/npm-ll.md new file mode 100644 index 0000000000000..cceb4284592ef --- /dev/null +++ b/docs/lib/content/commands/npm-ll.md @@ -0,0 +1,21 @@ +--- +title: npm-ll +section: 1 +description: List installed packages +--- + +### Synopsis + + + +### Description + +List installed packages + +### Configuration + + + +### See Also + +* [npm help config](/commands/npm-config) diff --git a/docs/lib/content/commands/npm-set.md b/docs/lib/content/commands/npm-set.md new file mode 100644 index 0000000000000..864ce81be43ba --- /dev/null +++ b/docs/lib/content/commands/npm-set.md @@ -0,0 +1,21 @@ +--- +title: npm-set +section: 1 +description: Set a value in the npm configuration +--- + +### Synopsis + + + +### Description + +Set a value in the npm configuration + +### Configuration + + + +### See Also + +* [npm help config](/commands/npm-config) diff --git a/docs/lib/content/nav.yml b/docs/lib/content/nav.yml index f6f8014f28071..f3a1bf7779f3a 100644 --- a/docs/lib/content/nav.yml +++ b/docs/lib/content/nav.yml @@ -1,267 +1,276 @@ + # This is the navigation for the documentation pages; it is not used # directly within the CLI documentation. Instead, it will be used # for the https://docs.npmjs.com/ site. + - title: CLI Commands shortName: Commands url: /commands children: - - title: npm - url: /commands/npm - description: JavaScript package manager - - title: npm access - url: /commands/npm-access - description: Set access level on published packages - - title: npm adduser - url: /commands/npm-adduser - description: Add a registry user account - - title: npm audit - url: /commands/npm-audit - description: Run a security audit - - title: npm bugs - url: /commands/npm-bugs - description: Bugs for a package in a web browser maybe - - title: npm cache - url: /commands/npm-cache - description: Manipulates packages cache - - title: npm ci - url: /commands/npm-ci - description: Install a project with a clean slate - - title: npm completion - url: /commands/npm-completion - description: Tab completion for npm - - title: npm config - url: /commands/npm-config - description: Manage the npm configuration files - - title: npm dedupe - url: /commands/npm-dedupe - description: Reduce duplication - - title: npm deprecate - url: /commands/npm-deprecate - description: Deprecate a version of a package - - title: npm diff - url: /commands/npm-diff - description: The registry diff command - - title: npm dist-tag - url: /commands/npm-dist-tag - description: Modify package distribution tags - - title: npm docs - url: /commands/npm-docs - description: Docs for a package in a web browser maybe - - title: npm doctor - url: /commands/npm-doctor - description: Check your environments - - title: npm edit - url: /commands/npm-edit - description: Edit an installed package - - title: npm exec - url: /commands/npm-exec - description: Run a command from an npm package - - title: npm explain - url: /commands/npm-explain - description: Explain installed packages - - title: npm explore - url: /commands/npm-explore - description: Browse an installed package - - title: npm find-dupes - url: /commands/npm-find-dupes - description: Find duplication in the package tree - - title: npm fund - url: /commands/npm-fund - description: Retrieve funding information - - title: npm help - url: /commands/npm-help - description: Search npm help documentation - - title: npm help-search - url: /commands/npm-help-search - description: Get help on npm - - title: npm init - url: /commands/npm-init - description: Create a package.json file - - title: npm install - url: /commands/npm-install - description: Install a package - - title: npm install-ci-test - url: /commands/npm-install-ci-test - description: Install a project with a clean slate and run tests - - title: npm install-test - url: /commands/npm-install-test - description: Install package(s) and run tests - - title: npm link - url: /commands/npm-link - description: Symlink a package folder - - title: npm login - url: /commands/npm-login - description: Login to a registry user account - - title: npm logout - url: /commands/npm-logout - description: Log out of the registry - - title: npm ls - url: /commands/npm-ls - description: List installed packages - - title: npm org - url: /commands/npm-org - description: Manage orgs - - title: npm outdated - url: /commands/npm-outdated - description: Check for outdated packages - - title: npm owner - url: /commands/npm-owner - description: Manage package owners - - title: npm pack - url: /commands/npm-pack - description: Create a tarball from a package - - title: npm ping - url: /commands/npm-ping - description: Ping npm registry - - title: npm pkg - url: /commands/npm-pkg - description: Manages your package.json - - title: npm prefix - url: /commands/npm-prefix - description: Display prefix - - title: npm profile - url: /commands/npm-profile - description: Change settings on your registry profile - - title: npm prune - url: /commands/npm-prune - description: Remove extraneous packages - - title: npm publish - url: /commands/npm-publish - description: Publish a package - - title: npm query - url: /commands/npm-query - description: Retrieve a filtered list of packages - - title: npm rebuild - url: /commands/npm-rebuild - description: Rebuild a package - - title: npm repo - url: /commands/npm-repo - description: Open package repository page in the browser - - title: npm restart - url: /commands/npm-restart - description: Restart a package - - title: npm root - url: /commands/npm-root - description: Display npm root - - title: npm run - url: /commands/npm-run - description: Run arbitrary package scripts - - title: npm sbom - url: /commands/npm-sbom - description: Generate a Software Bill of Materials (SBOM) - - title: npm search - url: /commands/npm-search - description: Search for packages - - title: npm shrinkwrap - url: /commands/npm-shrinkwrap - description: Lock down dependency versions for publication - - title: npm star - url: /commands/npm-star - description: Mark your favorite packages - - title: npm stars - url: /commands/npm-stars - description: View packages marked as favorites - - title: npm start - url: /commands/npm-start - description: Start a package - - title: npm stop - url: /commands/npm-stop - description: Stop a package - - title: npm team - url: /commands/npm-team - description: Manage organization teams and team memberships - - title: npm test - url: /commands/npm-test - description: Test a package - - title: npm token - url: /commands/npm-token - description: Manage your authentication tokens - - title: npm undeprecate - url: /commands/npm-undeprecate - description: Undeprecate a version of a package - - title: npm uninstall - url: /commands/npm-uninstall - description: Remove a package - - title: npm unpublish - url: /commands/npm-unpublish - description: Remove a package from the registry - - title: npm unstar - url: /commands/npm-unstar - description: Remove an item from your favorite packages - - title: npm update - url: /commands/npm-update - description: Update a package - - title: npm version - url: /commands/npm-version - description: Bump a package version - - title: npm view - url: /commands/npm-view - description: View registry info - - title: npm whoami - url: /commands/npm-whoami - description: Display npm username - - title: npx - url: /commands/npx - description: Run a command from an npm package - + - title: npm + url: /commands/npm + description: JavaScript package manager + - title: npm access + url: /commands/npm-access + description: Set access level on published packages + - title: npm adduser + url: /commands/npm-adduser + description: Add a registry user account + - title: npm audit + url: /commands/npm-audit + description: Run a security audit + - title: npm bugs + url: /commands/npm-bugs + description: Bugs for a package in a web browser maybe + - title: npm cache + url: /commands/npm-cache + description: Manipulates packages cache + - title: npm ci + url: /commands/npm-ci + description: Install a project with a clean slate + - title: npm completion + url: /commands/npm-completion + description: Tab completion for npm + - title: npm config + url: /commands/npm-config + description: Manage the npm configuration files + - title: npm dedupe + url: /commands/npm-dedupe + description: Reduce duplication + - title: npm deprecate + url: /commands/npm-deprecate + description: Deprecate a version of a package + - title: npm diff + url: /commands/npm-diff + description: The registry diff command + - title: npm dist-tag + url: /commands/npm-dist-tag + description: Modify package distribution tags + - title: npm docs + url: /commands/npm-docs + description: Docs for a package in a web browser maybe + - title: npm doctor + url: /commands/npm-doctor + description: Check your environments + - title: npm edit + url: /commands/npm-edit + description: Edit an installed package + - title: npm exec + url: /commands/npm-exec + description: Run a command from an npm package + - title: npm explain + url: /commands/npm-explain + description: Explain installed packages + - title: npm explore + url: /commands/npm-explore + description: Browse an installed package + - title: npm find-dupes + url: /commands/npm-find-dupes + description: Find duplication in the package tree + - title: npm fund + url: /commands/npm-fund + description: Retrieve funding information + - title: npm get + url: /commands/npm-get + description: Get a value from the npm configuration + - title: npm help + url: /commands/npm-help + description: Search npm help documentation + - title: npm help-search + url: /commands/npm-help-search + description: Get help on npm + - title: npm init + url: /commands/npm-init + description: Create a package.json file + - title: npm install + url: /commands/npm-install + description: Install a package + - title: npm install-ci-test + url: /commands/npm-install-ci-test + description: Install a project with a clean slate and run tests + - title: npm install-test + url: /commands/npm-install-test + description: Install package(s) and run tests + - title: npm link + url: /commands/npm-link + description: Symlink a package folder + - title: npm ll + url: /commands/npm-ll + description: List installed packages + - title: npm login + url: /commands/npm-login + description: Login to a registry user account + - title: npm logout + url: /commands/npm-logout + description: Log out of the registry + - title: npm ls + url: /commands/npm-ls + description: List installed packages + - title: npm org + url: /commands/npm-org + description: Manage orgs + - title: npm outdated + url: /commands/npm-outdated + description: Check for outdated packages + - title: npm owner + url: /commands/npm-owner + description: Manage package owners + - title: npm pack + url: /commands/npm-pack + description: Create a tarball from a package + - title: npm ping + url: /commands/npm-ping + description: Ping npm registry + - title: npm pkg + url: /commands/npm-pkg + description: Manages your package.json + - title: npm prefix + url: /commands/npm-prefix + description: Display prefix + - title: npm profile + url: /commands/npm-profile + description: Change settings on your registry profile + - title: npm prune + url: /commands/npm-prune + description: Remove extraneous packages + - title: npm publish + url: /commands/npm-publish + description: Publish a package + - title: npm query + url: /commands/npm-query + description: Retrieve a filtered list of packages + - title: npm rebuild + url: /commands/npm-rebuild + description: Rebuild a package + - title: npm repo + url: /commands/npm-repo + description: Open package repository page in the browser + - title: npm restart + url: /commands/npm-restart + description: Restart a package + - title: npm root + url: /commands/npm-root + description: Display npm root + - title: npm run + url: /commands/npm-run + description: Run arbitrary package scripts + - title: npm sbom + url: /commands/npm-sbom + description: Generate a Software Bill of Materials (SBOM) + - title: npm search + url: /commands/npm-search + description: Search for packages + - title: npm set + url: /commands/npm-set + description: Set a value in the npm configuration + - title: npm shrinkwrap + url: /commands/npm-shrinkwrap + description: Lock down dependency versions for publication + - title: npm star + url: /commands/npm-star + description: Mark your favorite packages + - title: npm stars + url: /commands/npm-stars + description: View packages marked as favorites + - title: npm start + url: /commands/npm-start + description: Start a package + - title: npm stop + url: /commands/npm-stop + description: Stop a package + - title: npm team + url: /commands/npm-team + description: Manage organization teams and team memberships + - title: npm test + url: /commands/npm-test + description: Test a package + - title: npm token + url: /commands/npm-token + description: Manage your authentication tokens + - title: npm undeprecate + url: /commands/npm-undeprecate + description: Undeprecate a version of a package + - title: npm uninstall + url: /commands/npm-uninstall + description: Remove a package + - title: npm unpublish + url: /commands/npm-unpublish + description: Remove a package from the registry + - title: npm unstar + url: /commands/npm-unstar + description: Remove an item from your favorite packages + - title: npm update + url: /commands/npm-update + description: Update a package + - title: npm version + url: /commands/npm-version + description: Bump a package version + - title: npm view + url: /commands/npm-view + description: View registry info + - title: npm whoami + url: /commands/npm-whoami + description: Display npm username + - title: npx + url: /commands/npx + description: Run a command from an npm package - title: Configuring npm shortName: Configuring url: /configuring-npm children: - - title: Install - url: /configuring-npm/install - description: Download and install node and npm - - title: Folders - url: /configuring-npm/folders - description: Folder structures used by npm - - title: .npmrc - url: /configuring-npm/npmrc - description: The npm config files - - title: npm-shrinkwrap.json - url: /configuring-npm/npm-shrinkwrap-json - description: A publishable lockfile - - title: package.json - url: /configuring-npm/package-json - description: Specifics of npm's package.json handling - - title: package-lock.json - url: /configuring-npm/package-lock-json - description: A manifestation of the manifest - + - title: Install + url: /configuring-npm/install + description: Download and install node and npm + - title: Folders + url: /configuring-npm/folders + description: Folder structures used by npm + - title: .npmrc + url: /configuring-npm/npmrc + description: The npm config files + - title: npm-shrinkwrap.json + url: /configuring-npm/npm-shrinkwrap-json + description: A publishable lockfile + - title: package.json + url: /configuring-npm/package-json + description: Specifics of npm's package.json handling + - title: package-lock.json + url: /configuring-npm/package-lock-json + description: A manifestation of the manifest - title: Using npm shortName: Using url: /using-npm children: - - title: Registry - url: /using-npm/registry - description: The JavaScript Package Registry - - title: Package spec - url: /using-npm/package-spec - description: Package name specifier - - title: Config - url: /using-npm/config - description: About npm configuration - - title: Logging - url: /using-npm/logging - description: Why, What & How we Log - - title: Scope - url: /using-npm/scope - description: Scoped packages - - title: Scripts - url: /using-npm/scripts - description: How npm handles the "scripts" field - - title: Workspaces - url: /using-npm/workspaces - description: Working with workspaces - - title: Organizations - url: /using-npm/orgs - description: Working with teams & organizations - - title: Dependency Selectors - url: /using-npm/dependency-selectors - description: Dependency Selector Syntax & Querying - - title: Developers - url: /using-npm/developers - description: Developer guide - - title: Removal - url: /using-npm/removal - description: Cleaning the slate + - title: Registry + url: /using-npm/registry + description: The JavaScript Package Registry + - title: Package spec + url: /using-npm/package-spec + description: Package name specifier + - title: Config + url: /using-npm/config + description: About npm configuration + - title: Logging + url: /using-npm/logging + description: Why, What & How we Log + - title: Scope + url: /using-npm/scope + description: Scoped packages + - title: Scripts + url: /using-npm/scripts + description: How npm handles the "scripts" field + - title: Workspaces + url: /using-npm/workspaces + description: Working with workspaces + - title: Organizations + url: /using-npm/orgs + description: Working with teams & organizations + - title: Dependency Selectors + url: /using-npm/dependency-selectors + description: Dependency Selector Syntax & Querying + - title: Developers + url: /using-npm/developers + description: Developer guide + - title: Removal + url: /using-npm/removal + description: Cleaning the slate diff --git a/docs/lib/index.js b/docs/lib/index.js index 5e40f48882cad..0e5425946092f 100644 --- a/docs/lib/index.js +++ b/docs/lib/index.js @@ -40,12 +40,17 @@ const getCommandByDoc = (docFile, docExt) => { // `npx` is not technically a command in and of itself, // so it just needs the usage of npm exec const srcName = name === 'npx' ? 'exec' : name - const { params, usage = [''], workspaces } = require(`../../lib/commands/${srcName}`) + const command = require(`../../lib/commands/${srcName}`) + const { params, usage = [''], workspaces } = command + const commandDefinitions = command.definitions || {} + const definitionPool = { ...definitions, ...commandDefinitions } const usagePrefix = name === 'npx' ? 'npx' : `npm ${name}` if (params) { for (const param of params) { - if (definitions[param].exclusive) { - for (const e of definitions[param].exclusive) { + // Check command-specific definitions first, fall back to global definitions + const paramDef = definitionPool[param] + if (paramDef && paramDef.exclusive) { + for (const e of paramDef.exclusive) { if (!params.includes(e)) { params.splice(params.indexOf(param) + 1, 0, e) } @@ -93,14 +98,27 @@ const replaceUsage = (src, { path }) => { } const replaceParams = (src, { path }) => { - const { params } = getCommandByDoc(path, DOC_EXT) + const { params, name } = getCommandByDoc(path, DOC_EXT) const replacer = params && assertPlaceholder(src, path, TAGS.CONFIG) if (!params) { return src } - const paramsConfig = params.map((n) => definitions[n].describe()) + // Load command to get command-specific definitions if they exist + let commandDefinitions = {} + try { + const command = require(`../../lib/commands/${name}`) + commandDefinitions = command.definitions || {} + } catch { + // If command doesn't exist or has no definitions, continue with global definitions only + } + + const paramsConfig = params.map((n) => { + // Check command-specific definitions first, fall back to global definitions + const def = commandDefinitions[n] || definitions[n] + return def.describe() + }) return src.replace(replacer, paramsConfig.join('\n\n')) } diff --git a/docs/test/index.js b/docs/test/index.js index be537e68b2a18..0257a07a0e663 100644 --- a/docs/test/index.js +++ b/docs/test/index.js @@ -1,6 +1,8 @@ const t = require('tap') const { join } = require('path') const walk = require('ignore-walk') +const fs = require('fs/promises') +const yaml = require('yaml') const { paths: { content: CONTENT_DIR, nav: NAV, template: TEMPLATE } } = require('../lib/index.js') const testBuildDocs = async (t, { verify, ...opts } = {}) => { @@ -13,6 +15,11 @@ const testBuildDocs = async (t, { verify, ...opts } = {}) => { ...opts, } + // Ensure commands directory exists if content is provided + if (fixtures.content && !fixtures.content.commands) { + fixtures.content.commands = {} + } + const root = t.testdir(fixtures) const paths = { @@ -22,6 +29,8 @@ const testBuildDocs = async (t, { verify, ...opts } = {}) => { man: join(root, 'man'), html: join(root, 'html'), md: join(root, 'md'), + // Skip auto-generation of missing docs when using test fixtures + skipAutoGenerate: !!fixtures.content, } return { @@ -31,6 +40,83 @@ const testBuildDocs = async (t, { verify, ...opts } = {}) => { } } +// Helper to create a standard command doc with placeholders +const createCommandDoc = (title, description) => `--- +title: ${title} +section: 1 +description: ${description} +--- + +### Synopsis + + + +### Configuration + + +` + +// Helper to read and return HTML content from a built doc +const readHtmlDoc = async (htmlPath, commandName) => { + const htmlFile = join(htmlPath, `commands/${commandName}.html`) + return await fs.readFile(htmlFile, 'utf-8') +} + +// Helper to test a command doc with common assertions +const testCommandDoc = async (t, commandName, description, assertions = {}) => { + const doc = createCommandDoc(commandName, description) + const { html } = await testBuildDocs(t, { + content: { + commands: { [`${commandName}.md`]: doc }, + }, + nav: `- url: /commands/${commandName}`, + }) + + const htmlContent = await readHtmlDoc(html, commandName) + + // Default assertions + t.ok(htmlContent.length > 0, `generates HTML for ${commandName} command`) + + // Custom assertions + if (assertions.match) { + for (const pattern of assertions.match) { + t.match(htmlContent, pattern, `contains expected pattern: ${pattern}`) + } + } + + return { html, htmlContent } +} + +// Helper to create test directory structure for autoGenerateMissingDocs tests +const createAutoGenTestDir = (t, { existingDocs = {}, navEntries = [], commandFiles = {} }) => { + const navYml = ` +- title: CLI Commands + children: + - title: npm + url: /commands/npm +${navEntries.map(entry => ` - title: ${entry.title}\n url: ${entry.url}\n description: ${entry.description}`).join('\n')} +` + + return t.testdir({ + content: { + commands: existingDocs, + }, + 'nav.yml': navYml, + lib: { + commands: commandFiles, + }, + }) +} + +// Helper to verify nav structure after auto-generation +const verifyNavStructure = async (navPath) => { + const navContent = await fs.readFile(navPath, 'utf-8') + const navData = yaml.parse(navContent) + const commandsSection = navData.find(s => s.title === 'CLI Commands') + + return { navContent, navData, commandsSection } +} + t.test('builds and verifies the real docs', async (t) => { const { man, html, md, results } = await testBuildDocs(t, { verify: true }) @@ -109,3 +195,387 @@ t.test('html', async t => { }) }) }) + +t.test('command-specific definitions and exclusive parameters', async t => { + // Test through the actual doc building process with real commands + t.test('config command uses params correctly', async t => { + await testCommandDoc(t, 'npm-config', 'Manage the npm configuration files') + }) + + t.test('install command includes exclusive save parameters', async t => { + const { htmlContent } = await testCommandDoc(t, 'npm-install', 'Install a package', { + match: [/save/], + }) + + // The install command should have save-related params due to exclusive expansion + t.match(htmlContent, /save/, 'includes save-related configuration') + }) +}) + +t.test('autoGenerateMissingDocs', async t => { + const { autoGenerateMissingDocs } = require('../lib/build.js') + + t.test('generates docs for missing commands', async t => { + const testDir = createAutoGenTestDir(t, { + existingDocs: { + 'npm-access.md': createCommandDoc('npm-access', 'Set access level on published packages'), + }, + navEntries: [ + { title: 'npm access', url: '/commands/npm-access', description: 'Set access level on published packages' }, + ], + commandFiles: { + 'access.js': ` +class AccessCommand { + static description = 'Set access level on published packages' +} +module.exports = AccessCommand +`, + 'testcmd.js': ` +class TestCommand { + static description = 'A test command' +} +module.exports = TestCommand +`, + }, + }) + + const contentPath = join(testDir, 'content') + const navPath = join(testDir, 'nav.yml') + const commandsPath = join(testDir, 'lib', 'commands') + + await autoGenerateMissingDocs(contentPath, navPath, commandsPath) + + // Verify the doc was created + const testcmdDocPath = join(contentPath, 'commands', 'npm-testcmd.md') + const docExists = await fs.access(testcmdDocPath).then(() => true).catch(() => false) + t.ok(docExists, 'creates documentation file for missing command') + + // Verify the doc has correct content + const docContent = await fs.readFile(testcmdDocPath, 'utf-8') + t.match(docContent, /title: npm-testcmd/, 'doc has correct title') + t.match(docContent, /description: A test command/, 'doc has correct description') + t.match(docContent, //, 'doc has usage placeholder') + t.match(docContent, //, 'doc has config placeholder') + t.match(docContent, /A test command/, 'doc has description in body') + }) + + t.test('updates nav.yml for new commands', async t => { + const testDir = createAutoGenTestDir(t, { + existingDocs: { + 'npm-existing.md': `--- +title: npm-existing +section: 1 +description: Existing command +---`, + }, + navEntries: [ + { title: 'npm existing', url: '/commands/npm-existing', description: 'Existing command' }, + ], + commandFiles: { + 'existing.js': ` +class ExistingCommand { + static description = 'Existing command' +} +module.exports = ExistingCommand +`, + 'newcmd.js': ` +class NewCommand { + static description = 'New command' +} +module.exports = NewCommand +`, + }, + }) + + const contentPath = join(testDir, 'content') + const navPath = join(testDir, 'nav.yml') + const commandsPath = join(testDir, 'lib', 'commands') + + await autoGenerateMissingDocs(contentPath, navPath, commandsPath) + + // Read and verify nav.yml was updated + const { commandsSection } = await verifyNavStructure(navPath) + + t.ok(commandsSection, 'nav has CLI Commands section') + const newCmdEntry = commandsSection.children.find(c => c.url === '/commands/npm-newcmd') + t.ok(newCmdEntry, 'nav has entry for new command') + t.equal(newCmdEntry.title, 'npm newcmd', 'nav entry has correct title') + t.equal(newCmdEntry.description, 'New command', 'nav entry has correct description') + }) + + t.test('sorts nav children alphabetically', async t => { + const testDir = createAutoGenTestDir(t, { + existingDocs: {}, + navEntries: [ + { title: 'npm zebra', url: '/commands/npm-zebra', description: 'Zebra command' }, + { title: 'npm alpha', url: '/commands/npm-alpha', description: 'Alpha command' }, + ], + commandFiles: { + 'zebra.js': `module.exports = { description: 'Zebra command' }`, + 'alpha.js': `module.exports = { description: 'Alpha command' }`, + 'beta.js': `module.exports = { description: 'Beta command' }`, + }, + }) + + const contentPath = join(testDir, 'content') + const navPath = join(testDir, 'nav.yml') + const commandsPath = join(testDir, 'lib', 'commands') + + await autoGenerateMissingDocs(contentPath, navPath, commandsPath) + + // Verify sorting + const { commandsSection } = await verifyNavStructure(navPath) + + const titles = commandsSection.children.map(c => c.title) + + // npm should be first + t.equal(titles[0], 'npm', 'npm command is first') + + // Rest should be alphabetically sorted + const rest = titles.slice(1) + const sorted = [...rest].sort() + t.same(rest, sorted, 'remaining commands are alphabetically sorted') + t.ok(titles.includes('npm alpha'), 'includes alpha') + t.ok(titles.includes('npm beta'), 'includes beta') + t.ok(titles.includes('npm zebra'), 'includes zebra') + }) + + t.test('handles commands without description', async t => { + const testDir = createAutoGenTestDir(t, { + existingDocs: {}, + navEntries: [], + commandFiles: { + 'nodesc.js': `module.exports = {}`, + }, + }) + + const contentPath = join(testDir, 'content') + const navPath = join(testDir, 'nav.yml') + const commandsPath = join(testDir, 'lib', 'commands') + + await autoGenerateMissingDocs(contentPath, navPath, commandsPath) + + // Verify fallback description + const docPath = join(contentPath, 'commands', 'npm-nodesc.md') + const docContent = await fs.readFile(docPath, 'utf-8') + t.match(docContent, /description: The nodesc command/, 'uses fallback description in frontmatter') + t.match(docContent, /The nodesc command/, 'uses fallback description in body') + }) + + t.test('does not add duplicate entries to nav', async t => { + const testDir = createAutoGenTestDir(t, { + existingDocs: {}, + navEntries: [ + { title: 'npm duplicate', url: '/commands/npm-duplicate', description: 'Already exists' }, + ], + commandFiles: { + 'duplicate.js': `module.exports = { description: 'Already exists' }`, + }, + }) + + const contentPath = join(testDir, 'content') + const navPath = join(testDir, 'nav.yml') + const commandsPath = join(testDir, 'lib', 'commands') + + await autoGenerateMissingDocs(contentPath, navPath, commandsPath) + + // Verify no duplicate + const { commandsSection } = await verifyNavStructure(navPath) + + const duplicateEntries = commandsSection.children.filter(c => c.url === '/commands/npm-duplicate') + t.equal(duplicateEntries.length, 1, 'does not create duplicate nav entries') + }) + + t.test('skips update when no missing docs', async t => { + const testDir = createAutoGenTestDir(t, { + existingDocs: { + 'npm-complete.md': `--- +title: npm-complete +section: 1 +description: Complete command +---`, + }, + navEntries: [ + { title: 'npm complete', url: '/commands/npm-complete', description: 'Complete command' }, + ], + commandFiles: { + 'complete.js': `module.exports = { description: 'Complete command' }`, + }, + }) + + const contentPath = join(testDir, 'content') + const navPath = join(testDir, 'nav.yml') + const commandsPath = join(testDir, 'lib', 'commands') + + const navBefore = await fs.readFile(navPath, 'utf-8') + await autoGenerateMissingDocs(contentPath, navPath, commandsPath) + const navAfter = await fs.readFile(navPath, 'utf-8') + + t.equal(navBefore, navAfter, 'does not modify nav when no missing docs') + }) + + t.test('handles nav without CLI Commands section', async t => { + const testDir = t.testdir({ + content: { + commands: {}, + }, + 'nav.yml': ` +- title: Other Section + children: [] +`, + lib: { + commands: { + 'test.js': `module.exports = { description: 'Test command' }`, + }, + }, + }) + + const contentPath = join(testDir, 'content') + const navPath = join(testDir, 'nav.yml') + const commandsPath = join(testDir, 'lib', 'commands') + + // Should not throw, just skip nav update + await autoGenerateMissingDocs(contentPath, navPath, commandsPath) + + // Doc should still be created + const docPath = join(contentPath, 'commands', 'npm-test.md') + const docExists = await fs.access(docPath).then(() => true).catch(() => false) + t.ok(docExists, 'creates doc even when nav section missing') + }) + + t.test('handles nav with CLI Commands but no children', async t => { + const testDir = t.testdir({ + content: { + commands: {}, + }, + 'nav.yml': ` +- title: CLI Commands +`, + lib: { + commands: { + 'test.js': `module.exports = { description: 'Test command' }`, + }, + }, + }) + + const contentPath = join(testDir, 'content') + const navPath = join(testDir, 'nav.yml') + const commandsPath = join(testDir, 'lib', 'commands') + + // Should not throw, just skip nav children update + await autoGenerateMissingDocs(contentPath, navPath, commandsPath) + + // Doc should still be created + const docPath = join(contentPath, 'commands', 'npm-test.md') + const docExists = await fs.access(docPath).then(() => true).catch(() => false) + t.ok(docExists, 'creates doc even when children missing') + }) + + t.test('handles npm command not in first position', async t => { + const testDir = createAutoGenTestDir(t, { + existingDocs: {}, + navEntries: [ + { title: 'npm alpha', url: '/commands/npm-alpha', description: 'Alpha command' }, + ], + commandFiles: { + 'alpha.js': `module.exports = { description: 'Alpha command' }`, + 'beta.js': `module.exports = { description: 'Beta command' }`, + }, + }) + + // Manually adjust nav to put npm not first + const navPath = join(testDir, 'nav.yml') + await fs.writeFile(navPath, ` +- title: CLI Commands + children: + - title: npm alpha + url: /commands/npm-alpha + - title: npm + url: /commands/npm +`) + + const contentPath = join(testDir, 'content') + const commandsPath = join(testDir, 'lib', 'commands') + + await autoGenerateMissingDocs(contentPath, navPath, commandsPath) + + // Verify npm moved to first position + const { commandsSection } = await verifyNavStructure(navPath) + + const titles = commandsSection.children.map(c => c.title) + t.equal(titles[0], 'npm', 'npm command moved to first position') + }) + + t.test('calls autoGenerateMissingDocs via run with default skipAutoGenerate', async t => { + // This test ensures the default parameter path is covered + const build = require('../lib/build.js') + const testDir = t.testdir({ + content: { + commands: { + 'npm-test.md': createCommandDoc('npm-test', 'Test'), + }, + }, + 'nav.yml': ` +- title: CLI Commands + url: /commands/npm-test +`, + lib: { + commands: { + 'test.js': `module.exports = { description: 'Test' }`, + }, + }, + }) + + const template = '{{ content }}' + await fs.writeFile(join(testDir, 'template.html'), template) + + const contentPath = join(testDir, 'content') + const navPath = join(testDir, 'nav.yml') + const templatePath = join(testDir, 'template.html') + const manPath = join(testDir, 'man') + const htmlPath = join(testDir, 'html') + const mdPath = join(testDir, 'md') + + // Call run with skipAutoGenerate set to true to avoid hitting real commands + const results = await build({ + content: contentPath, + template: templatePath, + nav: navPath, + man: manPath, + html: htmlPath, + md: mdPath, + skipAutoGenerate: true, + }) + + t.ok(results.length > 0, 'build runs successfully') + }) +}) + +t.test('command-specific definitions with missing command file', async t => { + // This test targets the catch block in index.js lines 110-113 + // Use a command that exists and has params - the catch block is for safety + // when command-specific definitions can't be loaded + await testCommandDoc(t, 'npm-install', 'Install a package', { + match: [/install/], + }) +}) + +t.test('replaceParams with name edge cases', async t => { + // Test the conditions around the catch block more explicitly + t.test('npm command (no params)', async t => { + await testCommandDoc(t, 'npm', 'javascript package manager') + }) + + t.test('npx command (special case)', async t => { + await testCommandDoc(t, 'npx', 'Run a command from a local or remote npm package', { + match: [/package/], + }) + }) + + t.test('regular command with params (access)', async t => { + // Tests line 110: name && name !== 'npm' && name !== 'npx' + await testCommandDoc(t, 'npm-access', 'Set access level on published packages', { + match: [/registry/], + }) + }) +}) diff --git a/lib/base-cmd.js b/lib/base-cmd.js index 3e6c4758cbd58..7326e160a6fee 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 + let definitionsPool = {} + if (this.definitions) { + definitionsPool = { ...definitions, ...this.definitions } + } else { + this.definitions = definitions + definitionsPool = 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/tap-snapshots/test/lib/commands/publish.js.test.cjs b/tap-snapshots/test/lib/commands/publish.js.test.cjs index 96a9d064d0e4e..5fa30f412f9f3 100644 --- a/tap-snapshots/test/lib/commands/publish.js.test.cjs +++ b/tap-snapshots/test/lib/commands/publish.js.test.cjs @@ -172,6 +172,7 @@ Object { "man/man1/npm-explore.1", "man/man1/npm-find-dupes.1", "man/man1/npm-fund.1", + "man/man1/npm-get.1", "man/man1/npm-help-search.1", "man/man1/npm-help.1", "man/man1/npm-init.1", @@ -179,6 +180,7 @@ Object { "man/man1/npm-install-test.1", "man/man1/npm-install.1", "man/man1/npm-link.1", + "man/man1/npm-ll.1", "man/man1/npm-login.1", "man/man1/npm-logout.1", "man/man1/npm-ls.1", @@ -200,6 +202,7 @@ Object { "man/man1/npm-run.1", "man/man1/npm-sbom.1", "man/man1/npm-search.1", + "man/man1/npm-set.1", "man/man1/npm-shrinkwrap.1", "man/man1/npm-star.1", "man/man1/npm-stars.1", diff --git a/tap-snapshots/test/lib/npm.js.test.cjs b/tap-snapshots/test/lib/npm.js.test.cjs index ca42f13356278..82dcef5085dc3 100644 --- a/tap-snapshots/test/lib/npm.js.test.cjs +++ b/tap-snapshots/test/lib/npm.js.test.cjs @@ -15,6 +15,30 @@ Lifecycle scripts included in b@1.0.0: echo test b ` +exports[`test/lib/npm.js TAP same flag name different types test-flag-a output > test-flag-a help output 1`] = ` +Test command with flag name as boolean + +Usage: +npm test-flag-a + +Options: +[--name] + +Run "npm help test-flag-a" for more info +` + +exports[`test/lib/npm.js TAP same flag name different types test-flag-b output > test-flag-b help output 1`] = ` +Test command with flag name as string + +Usage: +npm test-flag-b + +Options: +[--name ] + +Run "npm help test-flag-b" for more info +` + exports[`test/lib/npm.js TAP usage set process.stdout.columns column width 0 > must match snapshot 1`] = ` npm diff --git a/test/lib/docs.js b/test/lib/docs.js index 833e58831ea51..89310b8ab15b4 100644 --- a/test/lib/docs.js +++ b/test/lib/docs.js @@ -98,8 +98,7 @@ t.test('usage', async t => { const docsCommands = await readdir(join(docs.paths.content, 'commands'), docs.DOC_EXT) const bareCommands = ['npm', 'npx'] - // XXX: These extra commands exist as js files but not as docs pages - const allDocs = docsCommands.concat(['get', 'set', 'll']).map(n => n.replace('npm-', '')) + const allDocs = docsCommands.map(n => n.replace('npm-', '')) // ensure that the list of js files in commands, docs files, and the command list // are all in sync. eg, this will error if a command is removed but not its docs file diff --git a/test/lib/npm.js b/test/lib/npm.js index b4ac509adb495..5da04994e052d 100644 --- a/test/lib/npm.js +++ b/test/lib/npm.js @@ -1,6 +1,6 @@ const t = require('tap') const { resolve, dirname, join } = require('node:path') -const fs = require('node:fs') +const fs = require('node:fs/promises') const { time } = require('proc-log') const { load: loadMockNpm } = require('../fixtures/mock-npm.js') const mockGlobals = require('@npmcli/mock-globals') @@ -327,11 +327,11 @@ t.test('debug log', async t => { const logsDir = join(testdir, 'my_logs_dir') // make logs dir a file before load so it files - fs.writeFileSync(logsDir, 'A_TEXT_FILE') + await fs.writeFile(logsDir, 'A_TEXT_FILE') await t.resolves(npm.load(), 'loads with invalid logs dir') t.equal(npm.logFiles.length, 0, 'no log files array') - t.strictSame(fs.readFileSync(logsDir, 'utf-8'), 'A_TEXT_FILE') + t.strictSame(await fs.readFile(logsDir, 'utf-8'), 'A_TEXT_FILE') }) }) @@ -339,7 +339,7 @@ t.test('cache dir', async t => { t.test('creates a cache dir', async t => { const { npm } = await loadMockNpm(t) - t.ok(fs.existsSync(npm.cache), 'cache dir exists') + await t.resolves(fs.access(npm.cache), 'cache dir exists') }) t.test('can load with a bad cache dir', async t => { @@ -352,7 +352,7 @@ t.test('cache dir', async t => { await t.resolves(npm.load(), 'loads with cache dir as a file') - t.equal(fs.readFileSync(cache, 'utf-8'), 'A_TEXT_FILE') + t.equal(await fs.readFile(cache, 'utf-8'), 'A_TEXT_FILE') }) }) @@ -567,3 +567,258 @@ 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 = `/* istanbul ignore file */ +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) + } +} +` + await fs.writeFile(tsetPath, tsetContent) + t.teardown(async () => { + try { + await fs.unlink(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) +}) + +t.test('help includes both global and command definitions', async t => { + const path = require('node:path') + + // Create a temporary command file + const tsetPath = path.join(__dirname, '../../lib/commands/tset.js') + const tsetContent = `/* istanbul ignore file */ +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 params = ['yes', 'say'] + static definitions = { + say: new Definition('say', { + default: 'meow', + type: String, + description: 'what to say', + flatten, + }), + } + + async exec () { + const say = this.npm.config.get('say') + output.standard(say) + } +} +` + await fs.writeFile(tsetPath, tsetContent) + t.teardown(async () => { + try { + await fs.unlink(tsetPath) + delete require.cache[tsetPath] + } catch (e) { + // ignore + } + }) + + const mockCmdList = require('../../lib/utils/cmd-list.js') + const { npm, joinedOutput } = await loadMockNpm(t, { + argv: ['tset', '--help'], + mocks: { + '{LIB}/utils/cmd-list.js': { + ...mockCmdList, + commands: [...mockCmdList.commands, 'tset'], + deref: (c) => c === 'tset' ? 'tset' : mockCmdList.deref(c), + }, + }, + }) + + await npm.exec('tset', []) + + const output = joinedOutput() + // Check that both global definition (yes) and command definition (say) appear in help + t.match(output, /--yes/, 'help includes global definition --yes') + t.match(output, /-y\|--yes/, 'help includes short flag -y for yes') + t.match(output, /--say/, 'help includes command definition --say') + t.match(output, /--say /, 'help includes --say with hint') +}) + +t.test('same flag name different types', async t => { + const path = require('node:path') + + // Create test-flag-a with name as Boolean + const testFlagAPath = path.join(__dirname, '../../lib/commands/test-flag-a.js') + const testFlagAContent = `/* istanbul ignore file */ +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 TestFlagA extends BaseCommand { + static description = 'Test command with flag name as boolean' + static name = 'test-flag-a' + static params = ['name'] + static definitions = { + name: new Definition('name', { + default: false, + type: Boolean, + description: 'description a', + flatten, + }), + } + + async exec () { + const name = this.npm.config.get('name') + output.standard(String(name)) + } +} +` + + // Create test-flag-b with name as String + const testFlagBPath = path.join(__dirname, '../../lib/commands/test-flag-b.js') + const testFlagBContent = `/* istanbul ignore file */ +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 TestFlagB extends BaseCommand { + static description = 'Test command with flag name as string' + static name = 'test-flag-b' + static params = ['name'] + static definitions = { + name: new Definition('name', { + default: '', + type: String, + description: 'description b', + flatten, + }), + } + + async exec () { + const name = this.npm.config.get('name') + output.standard(name) + } +} +` + + await fs.writeFile(testFlagAPath, testFlagAContent) + await fs.writeFile(testFlagBPath, testFlagBContent) + + t.teardown(async () => { + try { + await fs.unlink(testFlagAPath) + await fs.unlink(testFlagBPath) + delete require.cache[testFlagAPath] + delete require.cache[testFlagBPath] + } catch (e) { + // ignore + } + }) + + const mockCmdList = require('../../lib/utils/cmd-list.js') + + t.test('test-flag-a output', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + argv: ['test-flag-a', '--help'], + mocks: { + '{LIB}/utils/cmd-list.js': { + ...mockCmdList, + commands: [...mockCmdList.commands, 'test-flag-a', 'test-flag-b'], + deref: (c) => { + if (c === 'test-flag-a') { + return 'test-flag-a' + } + if (c === 'test-flag-b') { + return 'test-flag-b' + } + return mockCmdList.deref(c) + }, + }, + }, + }) + + await npm.exec('test-flag-a', []) + const output = joinedOutput() + t.matchSnapshot(output, 'test-flag-a help output') + }) + + t.test('test-flag-b output', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + argv: ['test-flag-b', '--help'], + mocks: { + '{LIB}/utils/cmd-list.js': { + ...mockCmdList, + commands: [...mockCmdList.commands, 'test-flag-a', 'test-flag-b'], + deref: (c) => { + if (c === 'test-flag-a') { + return 'test-flag-a' + } + if (c === 'test-flag-b') { + return 'test-flag-b' + } + return mockCmdList.deref(c) + }, + }, + }, + }) + + await npm.exec('test-flag-b', []) + const output = joinedOutput() + t.matchSnapshot(output, 'test-flag-b help output') + }) +}) 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') +})